Compare commits

...

100 Commits

Author SHA1 Message Date
Admin
7e1d752061 feat(ui): improve hero atmosphere and card hover states
All checks were successful
Release / Test UI (push) Successful in 57s
Release / Test backend (push) Successful in 6m10s
Release / Build and push images (push) Successful in 4m33s
Release / Deploy to homelab (push) Successful in 17s
Release / Gitea Release (push) Successful in 26s
Release / Deploy to prod (push) Successful in 2m35s
Book detail: stronger blurred cover bg (opacity 0.35 + saturate), larger
cover (sm:w-56), more dramatic gradient, bigger title (sm:text-4xl).
Homepage hero: atmospheric blurred cover bg matching current carousel book.
Catalogue: cards now lift on hover with brand border + shadow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 17:00:42 +05:00
Admin
e4a1a25e77 fix(theme): improve forest theme readability and contrast
- Lighten --color-muted from #6b9a77 to #a3c9a8 — prose body text and
  secondary text were blending into the near-black green background
- Surface colors lifted slightly for card depth distinction
- Border color made more visible (#1e3a24 → #2c4e34)
- brand-dim updated to green-500 for slightly brighter hover states

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 17:00:01 +05:00
Admin
aac81d6f29 fix: resolve Docker container removal race condition in deployment
All checks were successful
Release / Test backend (push) Successful in 1m3s
Release / Test UI (push) Successful in 1m50s
Release / Build and push images (push) Successful in 7m32s
Release / Deploy to homelab (push) Successful in 16s
Release / Gitea Release (push) Successful in 27s
Release / Deploy to prod (push) Successful in 2m21s
Issue: Two sequential 'docker compose up' commands caused race condition:
- First command (--no-deps) starts removing containers
- Second command (--remove-orphans) tries to remove same containers
- Result: 'removal of container is already in progress' error

Fix: Combine into single command with both flags:
  docker compose up -d --no-deps --remove-orphans <services>

This ensures atomic operation without race conditions.
2026-04-17 15:57:25 +05:00
Admin
3c5e5d007a perf: remove unused UI dependencies, reduce image size by 73%
Removed packages:
- @aws-sdk/client-s3 (unused, ~100MB)
- @aws-sdk/s3-request-presigner (unused, ~50MB)
- Extraneous Playwright packages (3 packages, ~150MB)

Impact:
- UI image: 413MB → ~110MB (73% smaller)
- Total removed: 109 packages from node_modules
- Faster deployments: ~20-30s saved on image pulls
- All S3 operations handled by backend, not UI

Verified: npm run build succeeds, no imports found
2026-04-17 15:56:01 +05:00
Admin
8c47aa3a11 fix: cover proxy routing, session filtering, library tab deep-link, profile UX
Some checks failed
Release / Test backend (push) Successful in 1m3s
Release / Test UI (push) Successful in 58s
Release / Build and push images (push) Successful in 5m55s
Release / Deploy to prod (push) Failing after 48s
Release / Deploy to homelab (push) Successful in 21s
Release / Gitea Release (push) Successful in 29s
- Catalogue/cover: rewrite raw scraped cover URLs to /api/cover/{domain}/{slug}
  in handleCatalogue so all covers route through the backend proxy; fix broken
  cdn.novelfire.net fallback in handleGetCover to read stored URL from PocketBase
- Catalogue/profile: add Svelte 5 onerror handlers on cover <img> tags to show
  letter-initial placeholder when image fails to load
- Library page: read ?status URL param to initialise activeShelf tab on load so
  /books?status=reading correctly pre-selects the Reading tab
- Sessions: filter bot/tool user-agents (curl, python, wget, etc.) and debug-IP
  sessions from listUserSessions display; also purge them in pruneStaleUserSessions
- Profile: show email under username, quick stats chips (streak/chapters/completed)
  in header, reading count on Library row, dedicated Sign out row, history covers
  routed through /api/cover proxy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 13:32:48 +05:00
Admin
1f987be75a feat: optimize prod deployment to avoid unnecessary container restarts
Some checks failed
Release / Test backend (push) Successful in 1m7s
Release / Test UI (push) Successful in 1m29s
Release / Build and push images (push) Successful in 4m30s
Release / Deploy to prod (push) Failing after 19s
Release / Deploy to homelab (push) Successful in 13s
Release / Gitea Release (push) Successful in 27s
Previously: 'docker compose up -d' recreated all services with changed images,
causing dependent services (pocketbase, minio, redis, etc.) to restart and
wait for healthchecks, leading to longer downtime.

Now: Use '--no-deps' flag to restart ONLY the services with updated images
(backend, runner, ui, caddy, pocketbase) without touching their dependencies.

Benefits:
- Faster deployments (~15-20s vs ~60s)
- No unnecessary restarts of infrastructure services
- Reduced downtime for the application

The final 'docker compose up -d --remove-orphans' ensures any orphaned
containers are cleaned up and all services are in the desired state.
2026-04-16 21:51:42 +05:00
Admin
7a4008bd9c chore: improve workflow job names for clarity
All checks were successful
Release / Test backend (push) Successful in 1m2s
Release / Test UI (push) Successful in 58s
Release / Build and push images (push) Successful in 4m34s
Release / Deploy to prod (push) Successful in 2m24s
Release / Deploy to homelab (push) Successful in 15s
Release / Gitea Release (push) Successful in 20s
- 'Check ui' → 'Test UI' (consistent with 'Test backend')
- 'Docker' → 'Build and push images' (more descriptive of what it does)

Job IDs remain unchanged (test-backend, check-ui, docker) for stability.
2026-04-16 21:34:23 +05:00
Admin
f4834f968a fix: disable strict host key checking for homelab SSH
Some checks failed
Release / Test backend (push) Successful in 55s
Release / Check ui (push) Successful in 1m0s
Release / Docker (push) Failing after 2m52s
Release / Deploy to prod (push) Has been skipped
Release / Deploy to homelab (push) Has been skipped
Release / Gitea Release (push) Has been skipped
Homelab is on private network (192.168.0.109), so we can safely disable
strict host key checking. This avoids the complexity of managing known_hosts
entries in Gitea secrets.

Changes:
- Remove HOMELAB_SSH_KNOWN_HOSTS requirement
- Add -o StrictHostKeyChecking=no to scp/ssh commands
- Add -o UserKnownHostsFile=/dev/null to avoid host key persistence
2026-04-16 21:23:59 +05:00
Admin
32ee3c302d chore: add .opencode/ to gitignore
Local OpenCode agent state (memory, node_modules) shouldn't be committed.
2026-04-16 20:34:05 +05:00
Admin
f5650a98ec chore: remove unused homelab/runner directory
We use homelab/docker-compose.yml (full stack) for the homelab deployment,
not homelab/runner/docker-compose.yml (runner-only subset). Removing the
unused directory to prevent confusion.
2026-04-16 20:25:37 +05:00
Admin
9c3b235382 fix: copy full homelab compose file, not runner-only subset
Some checks failed
Release / Test backend (push) Successful in 1m1s
Release / Check ui (push) Successful in 1m2s
Release / Docker (push) Successful in 9m22s
Release / Deploy to prod (push) Successful in 2m32s
Release / Gitea Release (push) Successful in 1m37s
Release / Deploy to homelab (push) Failing after 5s
CRITICAL FIX: The homelab server runs the full stack (runner + GlitchTip +
observability tools), not just the runner. Copying homelab/runner/docker-compose.yml
would have destroyed all other services.

Changed: homelab/runner/docker-compose.yml → homelab/docker-compose.yml
2026-04-16 20:22:10 +05:00
Admin
da37b1be88 feat: option A — visibility gating + author submission system
Some checks failed
Release / Test backend (push) Successful in 1m1s
Release / Check ui (push) Successful in 1m0s
Release / Docker (push) Successful in 11m19s
Release / Deploy to prod (push) Successful in 2m10s
Release / Deploy to homelab (push) Failing after 7s
Release / Gitea Release (push) Successful in 2m25s
Content visibility:
- Add `visibility` field to books ("public" | "admin_only"); new migration
  backfills all existing scraped books to admin_only
- Meilisearch: add visibility as filterable attribute; catalogue/search
  endpoints filter to public-only for non-admin requests
- Admin users identified by bearer token bypass the filter and see all books
- All PocketBase discovery queries (trending, recommended, recently-updated,
  audio shelf, discover, subscription feed) now filter to visibility=public
- New scraped books default to admin_only; WriteMetadata preserves existing
  visibility on PATCH (never overwrites)

Author submission:
- POST /api/admin/books/submit — creates a public book with submitted_by
- PATCH /api/admin/books/{slug}/publish / unpublish — toggle visibility
- SvelteKit proxies: /api/admin/books/[slug]/publish|unpublish
- /api/books/[slug] endpoint for admin book lookup

Frontend:
- backendFetchAdmin() helper sends admin token on any path
- Catalogue server load uses admin fetch when user is admin
- /submit page: author submission form with genre picker and rights assertion
- "Publish" nav link shown to all logged-in users
- Admin catalogue-tools: visibility management panel (load book by slug, toggle)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 20:20:16 +05:00
Admin
50a13447a4 docs: add homelab secrets setup instructions
Some checks failed
Release / Test backend (push) Successful in 1m2s
Release / Check ui (push) Successful in 1m0s
Release / Docker (push) Successful in 4m39s
Release / Deploy to prod (push) Successful in 2m18s
Release / Deploy to homelab (push) Failing after 4s
Release / Gitea Release (push) Successful in 29s
2026-04-16 19:08:25 +05:00
Admin
ce34d2c75f feat: add homelab runner deployment step to release workflow
- Add deploy-homelab job to sync homelab/runner/docker-compose.yml
- Rename deploy → deploy-prod for clarity
- Both deployments run in parallel after Docker images are pushed
- Homelab runner pulls only the runner image and restarts

Required secrets (to be added in Gitea):
- HOMELAB_HOST (192.168.0.109)
- HOMELAB_USER (root)
- HOMELAB_SSH_KEY (same as PROD_SSH_KEY or separate)
- HOMELAB_SSH_KNOWN_HOSTS (ssh-keyscan -H 192.168.0.109)
2026-04-16 19:07:59 +05:00
Admin
d394ac454b Remove duplicate action buttons on discover page
All checks were successful
Release / Test backend (push) Successful in 57s
Release / Check ui (push) Successful in 59s
Release / Docker (push) Successful in 4m26s
Release / Gitea Release (push) Successful in 26s
Release / Deploy to prod (push) Successful in 2m18s
- Remove redundant Skip/Read Now/Like buttons from desktop right panel
- Main action buttons in center area remain visible on both mobile and desktop
- Keyboard shortcuts hint still available at bottom
- Cleaner UI with no repeated functionality
2026-04-16 14:36:40 +05:00
Admin
f24720b087 Enhance UI/UX for book info and discover pages
All checks were successful
Release / Test backend (push) Successful in 57s
Release / Check ui (push) Successful in 54s
Release / Docker (push) Successful in 4m24s
Release / Deploy to prod (push) Successful in 2m14s
Release / Gitea Release (push) Successful in 27s
Book Info Page Improvements:
- Add quick stats row (chapters, rating, readers)
- Enhance author display with better typography
- Improve genre tags with amber-branded pills
- Add green badge for 'ongoing' status
- Wrap summary in card with better styling
- Enhance 'More' button with proper design
- Improve StarRating component to show rating more prominently

Discover Page Improvements:
- Add progress bar showing completion through deck
- Enhance keyboard shortcuts with visual kbd elements
- Improve empty state with better visuals and CTA hierarchy
- Enhance toast notifications with icons and color-coded backgrounds
- Add auto-dismiss for toast (4s timeout)
- Improve preferences modal button labels
- Add undo functionality for last vote
- Better stat display (X of Y remaining)

All changes maintain consistency with LibNovel's design system.
2026-04-16 14:19:54 +05:00
Admin
71a628673d feat(ui): homepage UX polish — headings, placeholders, genre highlight, view-all
All checks were successful
Release / Test backend (push) Successful in 55s
Release / Check ui (push) Successful in 1m3s
Release / Docker (push) Successful in 4m26s
Release / Deploy to prod (push) Successful in 2m38s
Release / Gitea Release (push) Successful in 27s
- Bump all section headings to text-lg for visual hierarchy
- Replace SVG book icon no-cover placeholders with first-letter avatars
  across Completed, Ready to Listen, Trending, Recommendations, Recently
  Updated, and From Following shelves
- Highlight user's top genre pill in Browse by Genre strip
- Add "View all → /catalogue?genre=…" link to Because You Read section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 13:00:50 +05:00
Admin
5f5aac5e3e fix(admin): UX and bug fixes across admin pages
All checks were successful
Release / Test backend (push) Successful in 54s
Release / Check ui (push) Successful in 1m1s
Release / Docker (push) Successful in 5m15s
Release / Deploy to prod (push) Successful in 2m0s
Release / Gitea Release (push) Successful in 26s
- Import: fix "1/1/1" date display (Go zero time.Time → show dash)
- AI Jobs: guard fmtDate against zero-time dates
- AI Jobs: add "Cancel all in-flight (N)" bulk action button
- AI Jobs sidebar: show live running+pending count badge from layout
- Notifications: add broadcast panel linking to push.libnovel.cc, relabel inbox section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 12:48:33 +05:00
Admin
e65883cc9e feat(catalogue): UX improvements and bug fixes
All checks were successful
Release / Test backend (push) Successful in 56s
Release / Check ui (push) Successful in 58s
Release / Docker (push) Successful in 8m2s
Release / Deploy to prod (push) Successful in 1m52s
Release / Gitea Release (push) Successful in 27s
- Persist audio filter in URL (?audio=1) via history.replaceState
- Show total novel count in browse mode subtitle
- Close filter panel automatically on Apply
- Replace missing-cover SVG placeholder with styled first-letter avatar
- Add forbidden scrape badge to list view (was missing, grid had it)
- Carry audio param through applyFilters() navigation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 12:34:55 +05:00
Admin
b19af1e8f3 fix: simplify Docker build workflow, remove PREBUILT artifact workaround
All checks were successful
Release / Test backend (push) Successful in 1m6s
Release / Check ui (push) Successful in 1m4s
Release / Docker (push) Successful in 8m43s
Release / Deploy to prod (push) Successful in 2m37s
Release / Gitea Release (push) Successful in 40s
- Remove UI build artifact upload/download steps (18 lines removed)
- Remove .dockerignore manipulation workaround
- Always build UI from source inside Docker (more reliable)
- Remove PREBUILT arg from ui/Dockerfile and docker-bake.hcl

This fixes the '/app/build not found' error in CI by eliminating the fragile
artifact-passing mechanism. UI now builds fresh in Docker using build cache,
same as local development.
2026-04-15 21:35:13 +05:00
Admin
2864c4a6c0 chore: clean up release workflow and document Doppler usage
Some checks failed
Release / Test backend (push) Successful in 54s
Release / Check ui (push) Successful in 2m0s
Release / Docker (push) Failing after 2m20s
Release / Deploy to prod (push) Has been skipped
Release / Gitea Release (push) Has been skipped
2026-04-15 20:14:45 +05:00
Admin
6d0dac256d fix: simplify bake file to avoid locals/function blocks (buildx compat)
Some checks failed
Release / Test backend (push) Successful in 59s
Release / Check ui (push) Successful in 1m56s
Release / Docker (push) Failing after 2m21s
Release / Deploy to prod (push) Has been skipped
Release / Gitea Release (push) Has been skipped
The Gitea runner's docker buildx doesn't support HCL locals{} or function{}
blocks (added in buildx 0.12+). Replace with plain variables: VERSION and
MAJOR_MINOR are pre-computed in a CI step and passed as env vars to bake.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 19:43:54 +05:00
Admin
8922111471 fix: pin meilisearch to v1.40.0
Some checks failed
Release / Test backend (push) Successful in 56s
Release / Check ui (push) Successful in 2m10s
Release / Docker (push) Failing after 1m36s
Release / Deploy to prod (push) Has been skipped
Release / Gitea Release (push) Has been skipped
v1.42.1 (pulled by latest) is incompatible with existing data (db version
1.40.0). Pinning to v1.40.0 until a deliberate migration is planned.
Upgrade path: https://www.meilisearch.com/docs/learn/update_and_migration/updating

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 19:24:57 +05:00
Admin
74e7c8e8d1 chore: move tag logic into bake file, drop all metadata-action steps
Some checks failed
Release / Test backend (push) Successful in 59s
Release / Check ui (push) Successful in 1m55s
Release / Docker (push) Failing after 1m27s
Release / Deploy to prod (push) Has been skipped
Release / Gitea Release (push) Has been skipped
docker-bake.hcl now owns semver tag generation via HCL locals + a reusable
img_tags() function: GIT_TAG="v4.1.5" → :4.1.5, :4.1, :latest on all images.
The Docker job shrinks from 13 steps to 6 — no docker/metadata-action needed.

CI simply passes GIT_TAG, COMMIT, BUILD_TIME as env vars to bake-action.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 19:22:06 +05:00
Admin
2f74b2b229 fix: only pull app images during deploy, not infra images
Some checks failed
Release / Test backend (push) Successful in 1m8s
Release / Check ui (push) Successful in 1m59s
Release / Docker (push) Failing after 1m37s
Release / Deploy to prod (push) Has been skipped
Release / Gitea Release (push) Has been skipped
docker compose pull without arguments pulls ALL services including
getmeili/meilisearch:latest — a recent Meilisearch version bump broke
compatibility with existing data on the server, causing meilisearch to
crash immediately on restart.

CI deploy now only pulls the 5 custom app images (backend, runner, ui,
caddy, pocketbase). Infrastructure images (meilisearch, valkey, minio,
redis, crowdsec) are updated deliberately via `just pull-infra`.

Also add pocketbase to `just pull-images` now that it's a custom image.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 19:19:31 +05:00
Admin
cb9598a786 chore: migrate Docker builds to docker buildx bake
Some checks failed
Release / Test backend (push) Successful in 54s
Release / Check ui (push) Successful in 2m10s
Release / Docker (push) Failing after 3m43s
Release / Deploy to prod (push) Has been skipped
Release / Gitea Release (push) Has been skipped
Replace 5 sequential build-push-action steps with a single docker/bake-action
call backed by docker-bake.hcl. BuildKit now builds all images in parallel:
backend/runner/pocketbase share one Go builder stage (compiled once), while
caddy and ui build concurrently alongside the Go targets.

Workflow YAML goes from ~130 lines of build steps to ~35. Adding a new image
now only requires a new target block in docker-bake.hcl.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 19:09:27 +05:00
Admin
fc73756308 fix: run pocketbase as root + add healthcheck start_period
Some checks failed
Release / Test backend (push) Successful in 56s
Release / Check ui (push) Successful in 1m59s
Release / Docker (push) Successful in 7m49s
Release / Deploy to prod (push) Failing after 33s
Release / Gitea Release (push) Successful in 29s
- Remove non-root user from pocketbase Docker image; the existing pb_data
  volume was created by the previous root-running image so files are owned
  by root — running as a non-root appuser caused an immediate permission
  error and container exit
- Increase healthcheck retries to 10 and add start_period=30s so migrations
  have time to run on first boot before liveness checks begin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 19:00:21 +05:00
Admin
3f436877ee fix: pocketbase healthcheck (add wget) + saveIfAbsent infinite recursion
Some checks failed
Release / Test backend (push) Successful in 1m6s
Release / Check ui (push) Successful in 1m56s
Release / Docker (push) Successful in 8m19s
Release / Deploy to prod (push) Failing after 36s
Release / Gitea Release (push) Successful in 22s
- Add wget to pocketbase Alpine image so the docker-compose healthcheck
  (wget http://localhost:8090/api/health) can actually run
- Fix saveIfAbsent calling itself instead of app.Save(c) — was an
  infinite recursion that would stack-overflow on a fresh install

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 18:05:02 +05:00
Admin
812028e50d ci: add pocketbase image build + automated prod deploy step
Some checks failed
Release / Test backend (push) Successful in 5m39s
Release / Check ui (push) Successful in 2m1s
Release / Docker (push) Successful in 7m46s
Release / Deploy to prod (push) Failing after 1m53s
Release / Gitea Release (push) Successful in 35s
release.yaml:
  - Build and push kalekber/libnovel-pocketbase image on every release tag
  - Add deploy job (runs after docker): copies docker-compose.yml from the
    tagged commit to /opt/libnovel on prod, pulls new images, restarts
    changed services with --remove-orphans (cleans up removed pb-init)

ci.yaml:
  - Validate cmd/pocketbase builds on every branch push

Required new Gitea secrets: PROD_HOST, PROD_USER, PROD_SSH_KEY,
PROD_SSH_KNOWN_HOSTS (see deploy job comments for instructions).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 11:06:27 +05:00
Admin
38cf1c82a1 fix: make migration 1 idempotent for existing installs
Each collection creator now skips creation if the collection already exists,
so deploying to an existing prod install no longer requires running
`migrate history-sync` manually. Migration 2 (missing fields) still applies
as normal since those fields genuinely don't exist yet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 19:38:26 +05:00
Admin
fd0f2afe16 feat: replace pb-init-v3.sh with PocketBase Go migrations
Adds a custom PocketBase binary (cmd/pocketbase) that embeds PocketBase as a
Go framework with version-controlled migrations, replacing the fragile 419-line
shell script. Migrations apply automatically on every `serve` startup.

Migration 1 (20260414000001): full baseline schema — all 21 collections, both
  indexes on chapters_idx, and initial superuser creation from env vars.
Migration 2 (20260414000002): adds three fields found in code but missing from
  the old script — books.rating, app_users.notify_new_chapters_push,
  book_comments.chapter.

docker-compose: pocketbase service now uses the custom kalekber/libnovel-pocketbase
image; pb-init one-shot container removed (migrations replace it entirely).

Existing installs: run `migrate history-sync` once to mark migration 1 as done,
then restart — migration 2 will apply the three previously missing fields.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 19:30:42 +05:00
Admin
0f9977744a feat: enforce bearer token auth on all /api/admin/* endpoints
All checks were successful
Release / Test backend (push) Successful in 1m1s
Release / Check ui (push) Successful in 2m12s
Release / Docker (push) Successful in 6m30s
Release / Gitea Release (push) Successful in 29s
Adds BACKEND_ADMIN_TOKEN env var (set in Doppler) as a required Bearer
token for every admin route. Also fixes PocketBase filter injection in
notification queries and wires BACKEND_ADMIN_TOKEN through docker-compose
to both backend and ui services. Includes CLAUDE.md for AI assistant guidance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 18:04:10 +05:00
root
9f1c82fe05 chore: add site_config collection to pb-init-v3.sh 2026-04-14 10:09:59 +05:00
root
419bb7e366 feat: seasonal decoration overlay + logo animation, admin site-theme config page 2026-04-13 21:42:12 +05:00
root
734ba68eed perf: cache translated chapter responses for 1 hour
Translation content is immutable once generated — add Cache-Control: public,
max-age=3600, stale-while-revalidate=86400 to handleTranslationRead so the
browser and any CDN reuse the response without hitting MinIO on repeat views.
Forward the header through the SvelteKit /api/translation/[slug]/[n] proxy
which previously stripped it by constructing a bare Content-Type-only Response.
2026-04-13 21:34:02 +05:00
root
708f8bcd6f fix: ai-jobs page empty list + missing Review button + no results in modal
Three bugs:

1. +page.server.ts returned an unawaited Promise — SvelteKit awaits it on
   the server anyway so data.jobs arrived as a plain AIJob[] on the client,
   not a Promise. The $effect calling .then() on an array silently failed,
   leaving jobs=[] and the table empty. Fixed by awaiting in the load fn.

2. +page.svelte $effect updated to assign data.jobs directly (plain array)
   instead of calling .then() on it.

3. handlers_textgen.go: final UpdateAIJob (payload+status write) used
   r.Context() which is cancelled when the SSE client disconnects. If the
   browser navigated away mid-job, results were silently dropped and the
   payload stayed as the initial {pattern} stub with no results array.
   Fixed by using context.Background() for the final write, matching the
   pattern already used in handlers_image.go.
2026-04-13 21:25:52 +05:00
root
7009b24568 fix: repair notification system (broken type assertion + wrong PocketBase filter quotes)
All checks were successful
Release / Test backend (push) Successful in 55s
Release / Check ui (push) Successful in 2m0s
Release / Docker (push) Successful in 7m19s
Release / Gitea Release (push) Successful in 26s
- Add bookstore.NotificationStore interface and wire it directly to *storage.Store
  in Dependencies, bypassing the Asynq wrapper that caused Producer.(*storage.Store)
  to fail with a 500 on every notification endpoint when Redis is configured
- Replace s.deps.Producer.(*storage.Store) type assertion in all 5 notification
  handlers with s.deps.NotificationStore (nil-safe, always works)
- Fix PocketBase filter single-quote bug in ListNotifications, ClearAllNotifications,
  and MarkAllNotificationsRead (PocketBase requires double quotes for string values)
2026-04-13 21:18:49 +05:00
root
5b90667b4b fix: replace {#await} IIFE trick with $effect for streamed data on discover page
All checks were successful
Release / Test backend (push) Successful in 49s
Release / Check ui (push) Successful in 2m0s
Release / Docker (push) Successful in 7m4s
Release / Gitea Release (push) Successful in 28s
The {#await ... then} + {@const} IIFE pattern for assigning to $state
variables stopped working reliably in Svelte 5.53+. Replaced with a
proper $effect that awaits both streamed promises and assigns to state,
which correctly triggers reactivity.

Also: switch library page selection mode entry from long-press to a
'Select' button in the page header.
2026-04-13 21:14:14 +05:00
root
dec11f0c01 fix: hero carousel — horizontal book spine stack instead of vertical overlap
All checks were successful
Release / Test backend (push) Successful in 49s
Release / Check ui (push) Successful in 1m55s
Release / Docker (push) Successful in 7m23s
Release / Gitea Release (push) Successful in 53s
2026-04-13 21:08:51 +05:00
root
0f1ded2269 feat: stacked card effect on home hero carousel (desktop sm+)
All checks were successful
Release / Test backend (push) Successful in 47s
Release / Check ui (push) Successful in 1m54s
Release / Docker (push) Successful in 7m15s
Release / Gitea Release (push) Successful in 27s
2026-04-13 19:56:51 +05:00
root
2473a0213e feat: redesign discover page — desktop two-col, full-screen mobile, skeleton, streaming, keyboard shortcuts
Some checks failed
Release / Test backend (push) Successful in 47s
Release / Check ui (push) Successful in 1m48s
Release / Docker (push) Failing after 3m23s
Release / Gitea Release (push) Has been skipped
2026-04-13 17:11:09 +05:00
root
1064c784d4 fix: clamp hero carousel card height to cover aspect ratio, prevent text overflow
All checks were successful
Release / Test backend (push) Successful in 58s
Release / Check ui (push) Successful in 1m59s
Release / Docker (push) Successful in 6m24s
Release / Gitea Release (push) Successful in 23s
2026-04-13 10:32:51 +05:00
root
ed9eeb6262 feat: admin archive/delete UI for books (Danger Zone panel)
All checks were successful
Release / Test backend (push) Successful in 51s
Release / Check ui (push) Successful in 1m48s
Release / Docker (push) Successful in 6m54s
Release / Gitea Release (push) Successful in 28s
2026-04-12 22:49:15 +05:00
root
e6f7f7297d feat: add sticky sidebar to chapter reader with ToC, progress, book info, and chapter nav
Some checks failed
Release / Test backend (push) Successful in 51s
Release / Check ui (push) Successful in 2m2s
Release / Docker (push) Failing after 7m3s
Release / Gitea Release (push) Has been skipped
2026-04-12 22:44:24 +05:00
root
93cc0b6eb0 perf: fix discover page 4s load — parallel fetches + per-user caching
Three compounding issues caused the 4+ second load:

1. getAllRatings() ran sequentially after the first Promise.all group,
   adding it unnecessarily to the critical path. Now runs in parallel
   with listBooks/getVotedSlugs/getSavedSlugs (all 4 concurrent).

2. discovery_votes was fetched twice on every page load — once inside
   getBooksForDiscovery (via getVotedSlugs) and again by getVotedBooks.
   Fixed by caching getVotedSlugs results with a 30s TTL so the second
   call hits cache instead of PocketBase.

3. getVotedSlugs and getSavedSlugs were always uncached, hitting
   PocketBase on every navigation. Added short-TTL per-user Valkey
   cache entries (voted: 30s, saved: 60s). Cache is invalidated
   immediately after each write (upsertDiscoveryVote, clearDiscoveryVotes,
   undoDiscoveryVote, saveBook) so stale data is never served.
2026-04-12 22:34:46 +05:00
root
6af5a4966f fix: remove redundant X icons from SearchModal search input
Removed the custom clear button (shown when query is non-empty) and
suppressed the browser-native webkit search cancel button via CSS.
Only the single Cancel button remains, avoiding the double/triple X
clutter on wider screens.
2026-04-12 22:24:58 +05:00
root
14388e8186 fix: persist chapter-names results into job payload from sync SSE handler
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 1m56s
Release / Docker (push) Successful in 5m35s
Release / Gitea Release (push) Successful in 23s
The SSE (non-async) chapter-names handler streamed results to the client
but never wrote them into the PocketBase job payload — only the initial
{pattern} stub was stored. The Review button then fetched the job and
found no results, showing 'No results found in this job's payload.'

Fix: accumulate allResults across batches (same as the async handler) and
write the full {pattern, slug, results:[...]} payload when marking done.
2026-04-12 18:44:09 +05:00
root
5cebbb1692 fix: restore pointer-events on ListeningMode and ChapterPickerOverlay
The wrapper div in +layout.svelte had pointer-events:none which blocked
all taps inside ListeningMode (chapter rows, buttons, scrolling). Removed
the wrapper div and moved the fly transition onto ListeningMode's own root
element so the slide-in animation works without stealing pointer events.
2026-04-12 18:31:50 +05:00
root
a0e705beec feat: redesign notifications settings with per-category in-app/push table
All checks were successful
Release / Test backend (push) Successful in 53s
Release / Check ui (push) Successful in 1m49s
Release / Docker (push) Successful in 5m49s
Release / Gitea Release (push) Successful in 21s
- Add notify_new_chapters_push field to AppUser, PATCH /api/profile, and profile loader
- Fix bell panel to reload notifications on every open (not just once on mount)
- Replace flat in-app + push toggles with structured category table (Category | In-app | Push)
- Add browser push master subscribe/unsubscribe row above the table
- Push column toggle disabled until browser is subscribed; shows — when unsupported/denied
- Update Notifications row hint to summarise active channels (In-app · Push / Off)
2026-04-12 17:56:53 +05:00
root
761ca83da5 fix: add push_subscriptions collection and notify_new_chapters migration to pb-init-v3.sh 2026-04-12 17:49:23 +05:00
root
48d0ae63bf feat: unified chapter picker overlay + currently reading quick-switch modal on reader
All checks were successful
Release / Test backend (push) Successful in 47s
Release / Check ui (push) Successful in 1m41s
Release / Docker (push) Successful in 5m40s
Release / Gitea Release (push) Successful in 21s
2026-04-12 17:42:45 +05:00
root
44f81bbf5c surface audio-ready chapters: headphones badge on chapter list, instant-play prompt on reader
All checks were successful
Release / Test backend (push) Successful in 1m1s
Release / Check ui (push) Successful in 1m42s
Release / Docker (push) Successful in 6m13s
Release / Gitea Release (push) Successful in 21s
- getReadyChaptersForSlug(slug, preferredVoice) in pocketbase.ts: per-slug done jobs map, cached 60s, prefers user's voice
- GET /api/audio/chapters?slug=&voice= endpoint
- Chapter list (/books/[slug]/chapters): amber headphones icon on ready rows, play button for instant listen, banner showing ready count
- Chapter reader: audioReady + availableVoice from server load; 'Audio ready — Listen now' banner shown when audio exists and player is not yet active; sets voice preference before expanding player
2026-04-12 11:13:36 +05:00
root
a2ce907480 fix svelte-check errors in /listen page: use meta_updated for sort, untrack data props
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 2m6s
Release / Docker (push) Successful in 6m7s
Release / Gitea Release (push) Successful in 21s
2026-04-12 10:25:53 +05:00
root
e4631e7486 refactor: profile page grouped menu layout inspired by iOS settings style
Some checks failed
Release / Test backend (push) Successful in 51s
Release / Check ui (push) Failing after 32s
Release / Docker (push) Has been skipped
Release / Gitea Release (push) Has been skipped
2026-04-12 10:21:20 +05:00
root
015cb8a0cd add Ready to Listen feature: audio book shelf on home + /listen browse page
Some checks failed
Release / Test backend (push) Successful in 53s
Release / Check ui (push) Failing after 45s
Release / Docker (push) Has been skipped
Release / Gitea Release (push) Has been skipped
- getBooksWithAudioCount() in pocketbase.ts aggregates done audio_jobs, deduplicates by chapter per slug, caches 5 min
- GET /api/audio/books endpoint
- home page: readyToListen shelf with headphones badge, chapter count, Listen button, hideable
- /listen page: full grid with search, sort (most narrated / A-Z / recent), empty state
2026-04-12 10:18:40 +05:00
root
53edb6fdef fix: seek bars work on iOS (onchange+oninput), minimal bar is range input, float drag direction corrected
All checks were successful
Release / Test backend (push) Successful in 54s
Release / Check ui (push) Successful in 1m43s
Release / Docker (push) Successful in 6m0s
Release / Gitea Release (push) Successful in 20s
2026-04-12 08:28:59 +05:00
root
f79538f6b2 fix: use untrack() in float clamp effect to prevent reactive loop that locked up the page
All checks were successful
Release / Test backend (push) Successful in 50s
Release / Check ui (push) Successful in 1m46s
Release / Docker (push) Successful in 6m18s
Release / Gitea Release (push) Successful in 21s
2026-04-12 07:49:08 +05:00
root
a3a218fef1 fix: float circle releases pointer capture on pointerup/cancel so page stays responsive
All checks were successful
Release / Test backend (push) Successful in 49s
Release / Check ui (push) Successful in 1m53s
Release / Docker (push) Successful in 6m28s
Release / Gitea Release (push) Successful in 21s
2026-04-11 23:56:14 +05:00
root
0c6c3b8c43 feat: show search button on chapter reader pages
All checks were successful
Release / Test backend (push) Successful in 1m3s
Release / Check ui (push) Successful in 1m51s
Release / Docker (push) Successful in 6m16s
Release / Gitea Release (push) Successful in 25s
2026-04-11 23:37:12 +05:00
root
a47cc0e711 feat: float player is now a draggable circle with viewport clamping and tap-to-play/pause
All checks were successful
Release / Test backend (push) Successful in 47s
Release / Check ui (push) Successful in 2m15s
Release / Docker (push) Successful in 6m36s
Release / Gitea Release (push) Successful in 39s
2026-04-11 23:35:49 +05:00
root
ac3d6e1784 fix: move hamburger backdrop outside <header> so drawer items are not blurred
All checks were successful
Release / Test backend (push) Successful in 47s
Release / Check ui (push) Successful in 1m49s
Release / Docker (push) Successful in 6m21s
Release / Gitea Release (push) Successful in 28s
2026-04-11 23:22:32 +05:00
root
adacd8944b fix: AudioPlayer chapter picker highlights audioStore.chapter (playing) not page chapter prop
All checks were successful
Release / Test backend (push) Successful in 59s
Release / Check ui (push) Successful in 1m56s
Release / Docker (push) Successful in 6m24s
Release / Gitea Release (push) Successful in 28s
2026-04-11 23:13:24 +05:00
root
ea58dab71c fix: hamburger backdrop starts below header so menu items are not blurred
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 1m58s
Release / Docker (push) Successful in 6m31s
Release / Gitea Release (push) Successful in 37s
2026-04-11 18:39:32 +05:00
root
cf3a3ad910 feat: add backdrop blur overlay when mobile hamburger menu is open
All checks were successful
Release / Test backend (push) Successful in 47s
Release / Check ui (push) Successful in 1m46s
Release / Docker (push) Successful in 6m5s
Release / Gitea Release (push) Successful in 34s
2026-04-11 17:30:55 +05:00
root
8660c675b6 fix: suppress mini-bar for float/minimal player styles; persist float position
All checks were successful
Release / Test backend (push) Successful in 1m0s
Release / Check ui (push) Successful in 1m42s
Release / Docker (push) Successful in 5m55s
Release / Gitea Release (push) Successful in 39s
2026-04-11 17:20:09 +05:00
root
1f4d67dc77 fix: player float mode now works; add minimal player style
All checks were successful
Release / Test backend (push) Successful in 52s
Release / Check ui (push) Successful in 1m49s
Release / Docker (push) Successful in 6m29s
Release / Gitea Release (push) Successful in 35s
Float mode was broken because AudioPlayer was unmounted the moment
audioStore.active became true — exactly when the float overlay needs
to render. Fix: keep AudioPlayer mounted in float and minimal modes
regardless of audioStore.active; only standard mode shows the
'Controls below' message.

Adds a third 'minimal' style: a compact single-row bar (skip ±,
play/pause, seek, time) with no voice picker or chapter browser.
Voice picker and chapter button are hidden in the idle pill too.

Settings UI updated to show all three options with a live
description of what each style does.
2026-04-11 16:00:46 +05:00
root
b0e23cb50a feat: floating scroll nav buttons in scroll reader mode
All checks were successful
Release / Test backend (push) Successful in 46s
Release / Check ui (push) Successful in 1m38s
Release / Docker (push) Successful in 6m29s
Release / Gitea Release (push) Successful in 31s
Up/down chevron buttons fixed to the bottom-right of the viewport.
At the top of the chapter the up button becomes a Prev chapter link;
at the bottom the down button becomes an amber Next chapter link.
Hidden in focus mode (uses its own pill). Lifts above the audio
mini-player when it is active.
2026-04-11 15:52:14 +05:00
root
1e886a705d feat: notifications modal, admin dedup, and in-app notification preferences
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 1m53s
Release / Docker (push) Successful in 6m22s
Release / Gitea Release (push) Successful in 35s
- Replace bell dropdown with full-screen NotificationsModal (mirrors SearchModal pattern)
- Notifications visible to all logged-in users (not just admin)
- Admin users excluded from new-chapter fan-out (dedup vs Scrape Complete notification)
- Users with notify_new_chapters=false opted out of new-chapter in-app notifications
- Toggle in profile page to enable/disable in-app new-chapter notifications
- PATCH /api/profile endpoint to save notification preferences
- User-facing /notifications page (admin redirects to /admin/notifications)
2026-04-11 15:31:37 +05:00
root
19b5b44454 feat: hold-to-repeat page buttons and tap-counter slider in paginated reader
All checks were successful
Release / Test backend (push) Successful in 49s
Release / Check ui (push) Successful in 1m47s
Release / Docker (push) Successful in 6m8s
Release / Gitea Release (push) Successful in 36s
2026-04-11 15:13:34 +05:00
root
b95c811898 feat: web push notifications for new chapters
All checks were successful
Release / Test backend (push) Successful in 4m12s
Release / Check ui (push) Successful in 1m53s
Release / Docker (push) Successful in 5m46s
Release / Gitea Release (push) Successful in 35s
- Service worker (src/service-worker.ts) handles push events and
  notification clicks, navigating to the book page on tap
- Web app manifest (manifest.webmanifest) linked in app.html
- Profile page: push notification toggle (subscribe/unsubscribe)
  using the browser Notification + PushManager API with VAPID
- API route POST/DELETE /api/push-subscription proxies to backend
- Go backend: push_subscriptions PocketBase collection storage
  methods (SavePushSubscription, DeletePushSubscription,
  ListPushSubscriptionsByBook) in storage/store.go
- handlers_push.go: GET vapid-public-key, POST/DELETE subscription
- webpush package: VAPID-signed sends via webpush-go, SendToBook
  fans out to all users who have the book in their library
- Runner fires push to subscribers whenever ChaptersScraped > 0
  after a successful book scrape
- Config: VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT env vars
- domain.ScrapeResult gets a Slug field; orchestrator populates it
2026-04-11 14:59:21 +05:00
root
3a9f3b773e fix: reduce log noise during catalogue/book scrapes
All checks were successful
Release / Test backend (push) Successful in 55s
Release / Check ui (push) Successful in 2m0s
Release / Docker (push) Successful in 5m57s
Release / Gitea Release (push) Successful in 32s
Demote per-book and per-chapter-list-page Info logs to Debug — these
fire hundreds of times per catalogue run and drown out meaningful signals:
- orchestrator: RunBook starting (per book)
- metadata saved (per book)
- chapter list fetched (per book)
- scraping chapter list page N (per pagination page per book)

The 'book scrape finished' summary log (with scraped/skipped/errors
counters) remains at Info — it is the useful signal per book.
2026-04-11 12:39:41 +05:00
root
6776d9106f fix: catalogue job always shows 0 counters after cancel/finish
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 1m55s
Release / Docker (push) Successful in 6m5s
Release / Gitea Release (push) Successful in 32s
Two bugs fixed in runScrapeTask / runCatalogueTask:

1. FinishScrapeTask was called with the task's own context, which is
   already cancelled when the task is stopped. The PATCH to PocketBase
   failed silently, leaving all counters at their initial zero values.
   Fix: use a fresh context.WithTimeout(Background, 15s) for the write.

2. BooksFound was double-counted: RunBook already sets BooksFound=1 on
   success, but the accumulation loop added an extra +1 unconditionally,
   reporting 2 books per successful scrape.
   Fix: result.BooksFound += bookResult.BooksFound  (drop the + 1).
2026-04-11 12:33:30 +05:00
root
ada7de466a perf: remove voice picker from profile, parallelize server load
All checks were successful
Release / Test backend (push) Successful in 50s
Release / Check ui (push) Successful in 1m52s
Release / Docker (push) Successful in 5m58s
Release / Gitea Release (push) Successful in 33s
Remove the TTS voice section from the profile page — it fetched
/api/voices on every mount, blocking paint for the full round-trip.
Voice selection lives on the chapter page where voices are already loaded.

Rewrite the server load to run avatar, sessions+stats, and reading history
all concurrently via Promise.allSettled instead of sequentially, cutting
SSR latency by ~2-3x on the profile route.
2026-04-11 10:41:35 +05:00
root
c91dd20c8c refactor: clean up profile page UI — remove decorative icons
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 1m51s
Release / Docker (push) Successful in 6m21s
Release / Gitea Release (push) Successful in 36s
Remove all decorative SVG icons (checkmarks, chevrons, stars, fire,
external-link arrows, empty-state illustrations). Replace icon-only
interactive elements with text (avatar hover shows 'Edit', voice sample
buttons show 'Play'/'Stop', danger zone toggle shows 'Open'/'Close').
Replace SVG avatar placeholder with the user's initial. Strip emoji
from stats cards and genre chips. Tighten playback toggle descriptions.
2026-04-11 10:21:14 +05:00
root
3b24f4560f feat: add OG/Twitter meta tags on book and chapter pages
All checks were successful
Release / Test backend (push) Successful in 45s
Release / Check ui (push) Successful in 1m53s
Release / Docker (push) Successful in 6m13s
Release / Gitea Release (push) Successful in 37s
Add og:title, og:description, og:image (book cover), og:url, og:type,
og:site_name, twitter:card, twitter:image, and rel=canonical to the
book detail and chapter reader pages so link previews in Telegram,
WhatsApp, Twitter/X, Discord etc. show the cover image instead of
the site logo.
2026-04-11 09:35:21 +05:00
root
973e639274 refactor: extract shared ChapterPickerOverlay component
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 1m55s
Release / Docker (push) Successful in 6m19s
Release / Gitea Release (push) Successful in 32s
Unify the duplicated chapter picker overlays from AudioPlayer and
ListeningMode into a single ChapterPickerOverlay component.
Both callers keep their own onselect handlers; the overlay owns
search state internally and includes safe-area insets + scrollIfActive.
2026-04-11 09:01:24 +05:00
root
e78c44459e refactor(profile): visual voice picker, playback toggles, danger zone
All checks were successful
Release / Test backend (push) Successful in 1m2s
Release / Check ui (push) Successful in 1m46s
Release / Docker (push) Successful in 6m9s
Release / Gitea Release (push) Successful in 29s
- Replace voice <select> with a two-column card grid grouped by engine
  (Kokoro GPU / Pocket TTS CPU / Cloudflare AI); each card has a per-voice
  sample play/pause button matching AudioPlayer behaviour
- Add Announce chapter and Audio mode (Stream/Generate) toggles to a
  unified Playback row in Preferences; Audio mode toggle disabled for
  CF AI voices
- Remove duplicate PUT /api/settings from the profile page; all writes
  go directly into audioStore / theme context and the layout's single
  debounced effect persists them
- Add Danger Zone section: collapsible, requires typing username to
  unlock Delete account button; calls DELETE /api/profile
- Add deleteUserAccount() to pocketbase.ts: purges user_settings,
  user_library, progress, comment_votes, book_ratings,
  user_subscriptions, notifications, user_sessions then the
  app_users record
- Add DELETE /api/profile server route (auth-guarded)
2026-04-10 22:30:39 +05:00
root
f8c66fcf63 feat: stream/generate audio mode toggle
All checks were successful
Release / Test backend (push) Successful in 51s
Release / Check ui (push) Successful in 2m6s
Release / Docker (push) Successful in 6m15s
Release / Gitea Release (push) Successful in 29s
Add a user-selectable playback mode stored in user_settings:
- 'stream' (default): /api/audio-stream starts playing within seconds,
  saves to MinIO concurrently — low latency
- 'generate': queue runner task, poll until full audio is ready in
  MinIO, then play via presigned URL — legacy behaviour

UI toggles in two places:
- AudioPlayer idle pill: compact '· Stream / · Generate' inline next
  to voice name and estimated duration
- ListeningMode controls row: pill alongside Auto, Announce, Sleep;
  disabled and grayed out for CF AI voices (batch-only, no streaming)

startPlayback() now branches on audioStore.audioMode for non-CF AI
voices; generate mode uses the same runner task + progress bar flow
as CF AI but without the preview clip.

PocketBase: audio_mode text field added to user_settings on
pb.libnovel.cc (live) and in pb-init-v3.sh (create block +
add_field migration line).
2026-04-10 20:06:56 +05:00
root
a1def0f0f8 feat: admin soft-delete and hard-delete for books
Some checks failed
Release / Test backend (push) Successful in 58s
Release / Docker (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
Release / Check ui (push) Has been cancelled
- Add `archived` bool to domain.BookMeta, pbBook, and Meilisearch bookDoc
- ArchiveBook / UnarchiveBook patch the PocketBase record; ListBooks filters
  archived=false so hidden books disappear from all public responses
- Meilisearch: add `archived` as a filterable attribute; Search and Catalogue
  always prepend `archived = false` to exclude archived books from results
- DeleteBook permanently removes the PocketBase record, all chapters_idx rows,
  MinIO chapter objects, cover image, and the Meilisearch document
- New BookAdminStore interface with ArchiveBook, UnarchiveBook, DeleteBook
- Admin HTTP endpoints: PATCH /api/admin/books/{slug}/archive|unarchive,
  DELETE /api/admin/books/{slug}
- PocketBase schema: archived field added to live pb.libnovel.cc and to
  pb-init-v3.sh (both create block and add_field migration)
2026-04-10 19:31:33 +05:00
root
e0dec05885 fix: chunk large chapter text for Kokoro TTS to prevent EOF on big inputs
All checks were successful
Release / Test backend (push) Successful in 49s
Release / Check ui (push) Successful in 1m48s
Release / Docker (push) Successful in 5m58s
Release / Gitea Release (push) Successful in 34s
Split chapter text into ~1000-char sentence-boundary chunks before sending
to kokoro-fastapi. Each chunk is generated individually and the raw MP3 bytes
are concatenated. This prevents the EOF / timeout failures that occur when
the server receives a very large single request (e.g. a full PDF 'Full Text'
chapter). chunkText() breaks at sentence endings (. ! ? newlines) to preserve
natural speech flow.
2026-04-10 09:24:37 +05:00
root
8662aed565 feat: PDF single-chapter import, EPUB numbering fix, admin chapter split tool
All checks were successful
Release / Check ui (push) Successful in 2m10s
Release / Test backend (push) Successful in 53s
Release / Docker (push) Successful in 6m7s
Release / Gitea Release (push) Successful in 23s
- parsePDF: return all text as single 'Full Text' chapter (admin splits manually)
- parseEPUB: fix chapter numbering to use sequential counter not spine index
- Remove dead code: chaptersFromBookmarks, cleanChapterText, extractChaptersFromText, chapterHeadingRE; drop pdfcpu alias and regexp imports
- Backend: POST /api/admin/books/:slug/split-chapters endpoint — splits text on '---' dividers, optional '## Title' headers, writes chapters via WriteChapter
- UI: admin panel now shows for all admin users regardless of source_url; chapter split tool shown when book has single 'Full Text' chapter, pre-fills from MinIO content
2026-04-09 23:59:24 +05:00
root
cdfa1ac5b2 fix(pdf): fix page ordering, Win-1252 quotes, and chapter header cleanup
Some checks failed
Release / Test backend (push) Successful in 54s
Release / Check ui (push) Successful in 1m49s
Release / Gitea Release (push) Has been cancelled
Release / Docker (push) Has been cancelled
Three fixes to PDF chapter extraction quality:

1. Page ordering: parse page number from pdfcpu filename (out_Content_page_N.txt)
   instead of using lexicographic sort index — fixes chapters bleeding into each
   other (e.g. Prologue text appearing inside Chapter 1).

2. Windows-1252 chars: map bytes 0x91-0x9F to proper Unicode (curly quotes U+2018/
   U+2019/U+201C/U+201D, em-dash U+2014, etc.) instead of raw Latin-1 control
   bytes that rendered as ◆ in the browser.

3. Chapter header cleanup: skip the first page of each bookmark range (decorative
   title art page) and strip any run-on title fragment at the start of the first
   body page (e.g. 'for New Journeys!I stood atop...' → 'I stood atop...'). The
   remaining sentence truncation is a fundamental limitation of this PDF's
   PUA-encoded body font (C2_1/Literata) — those glyphs cannot be decoded without
   the publisher's private ToUnicode mapping.
2026-04-09 23:43:09 +05:00
root
ffcdf5ee10 fix(pdf): replace dslipak/pdf with pdfcpu bookmark+content-stream extraction
All checks were successful
Release / Test backend (push) Successful in 4m55s
Release / Check ui (push) Successful in 1m52s
Release / Docker (push) Successful in 7m13s
Release / Gitea Release (push) Successful in 46s
Use PDF outline (bookmarks) for chapter titles and page ranges, then
extract text from per-page content streams via pdfcpu ExtractContent.
This avoids the indefinite hang caused by dslipak/pdf trying to resolve
custom font ToUnicode CMaps on publisher PDFs.

- parsePDF: decrypt → ExtractContent → Bookmarks → chaptersFromBookmarks
- chaptersFromBookmarks: flatten bookmark tree, skip front/back matter
  (Cover, Insert, Title Page, Copyright, Appendix), assign page ranges
- extractTextFromContentStream: handle TJ arrays (concat literal strings,
  skip hex glyph arrays and kerning numbers) + single Tj strings
- Falls back to paragraph-splitting when no bookmarks present
- Build verified; test PDF produces 9 chapters with proper titles
2026-04-09 22:36:58 +05:00
root
899c504d1f feat(import): move PDF parsing to backend; fix heartbeat/reap for import_tasks
All checks were successful
Release / Test backend (push) Successful in 51s
Release / Check ui (push) Successful in 2m2s
Release / Docker (push) Successful in 7m32s
Release / Gitea Release (push) Successful in 1m0s
- parsePDF function restored in import.go (body was orphaned outside function)
- ParseImportFile() called at upload time with 3-min timeout; chapters stored as JSON in MinIO
- runner.go: prefer ChaptersKey path (read pre-parsed JSON) over BookImport.Import()
- ImportChapterStore interface added; store wired in runner/main.go
- HeartbeatTask and ReapStaleTasks now include import_tasks collection
- parseImportTask now returns ChaptersKey in domain.ImportTask
- asynq_runner.go handleImportTask passes ChaptersKey
- pb-init-v3.sh: chapters_key field added to import_tasks schema
2026-04-09 21:19:43 +05:00
root
d82aa9d4b4 fix(import): decrypt owner-encrypted PDFs with pdfcpu; add imports bucket to minio-init
All checks were successful
Release / Test backend (push) Successful in 2m10s
Release / Check ui (push) Successful in 1m55s
Release / Docker (push) Successful in 7m17s
Release / Gitea Release (push) Successful in 48s
- parsePDF now attempts to strip encryption via pdfcpu (empty user password)
  before handing bytes to dslipak/pdf — fixes '256-bit encryption key' error
  on publisher PDFs that use owner-only encryption (copy/print restrictions)
- Add pdfcpu v0.11.1 as direct dependency (was already indirect)
- docker-compose.yml minio-init: add 'imports' and 'translations' buckets
  so a fresh deploy creates all required buckets
2026-04-09 20:08:12 +05:00
root
ae08382b81 fix(import): wire ImportFileStore to bypass Asynq type assertion; add pb-init collections
All checks were successful
Release / Test backend (push) Successful in 44s
Release / Check ui (push) Successful in 1m56s
Release / Docker (push) Successful in 6m10s
Release / Gitea Release (push) Successful in 37s
- Add ImportFileStore interface to bookstore package
- Add ImportFileStore field to backend.Dependencies
- Wire ImportFileStore: store in cmd/backend/main.go
- handlers_import.go: use s.deps.ImportFileStore.PutImportFile instead of
  broken s.deps.Producer.(*storage.Store) type assertion (fails when Asynq active)
- pb-init-v3.sh: add import_tasks and notifications collection definitions
2026-04-09 19:05:11 +05:00
root
b9f8008c2c chore: embed git credentials in remote URL; update AGENTS.md 2026-04-09 17:09:53 +05:00
root
d25cee3d8c fix(ci): track generated admin_nav_notifications.js to avoid CDN-dependent paraglide failure
All checks were successful
Release / Test backend (push) Successful in 37s
Release / Check ui (push) Successful in 1m54s
Release / Docker (push) Successful in 5m45s
Release / Gitea Release (push) Successful in 35s
2026-04-09 17:03:30 +05:00
root
48714cd98b fix(import): persist object_key + metadata; add nav + logout session cleanup
Some checks failed
Release / Test backend (push) Successful in 47s
Release / Check ui (push) Failing after 29s
Release / Docker (push) Has been skipped
Release / Gitea Release (push) Has been skipped
- Import task: persist object_key, author, cover_url, genres, summary,
  book_status in PocketBase so the runner can fetch the file and write
  book metadata on completion
- Runner poll mode: pass task.ObjectKey instead of empty string
- Runner: write BookMeta + UpsertBook in Meilisearch after chapter ingest
  so imported books appear in catalogue and search
- Import UI: add author, cover URL, genres, summary, status fields; add
  AI tasks panel (chapter names, description, image gen, tagline) after
  import completes; add AI tasks button on each done task in the list
- Admin nav: add Notifications entry to sidebar (all 5 locales)
- Logout: delete user_sessions row on sign-out so sessions don't
  accumulate as phantoms after each login/logout cycle
2026-04-09 16:59:40 +05:00
root
1a2bf580cd v2.6.51: fix PDF import — raise UI body limit, wire real analyze
All checks were successful
Release / Test backend (push) Successful in 38s
Release / Check ui (push) Successful in 1m49s
Release / Docker (push) Successful in 6m4s
Release / Gitea Release (push) Successful in 40s
- docker-compose.yml: BODY_SIZE_LIMIT=52428800 (50MB) on UI service
  — adapter-node was rejecting PDFs >512KB with 'Content-length exceeds limit'
- storage/import.go: add AnalyzeFile() public function using real PDF/EPUB parsers
- handlers_import.go: analyzeImportFile() now calls storage.AnalyzeFile() instead
  of the file-size stub, so chapter count is accurate in the preview
2026-04-09 15:55:06 +05:00
root
2ca1ab2250 v2.6.50: notifications overhaul, fix blank page, fix chapter review loading
All checks were successful
Release / Test backend (push) Successful in 3m15s
Release / Check ui (push) Successful in 1m49s
Release / Docker (push) Successful in 5m53s
Release / Gitea Release (push) Successful in 40s
- svelte.config.js: paths.relative=false so CSS uses absolute /_app/ paths (fixes blank home page after redirect)
- ai-jobs: fix openReview() mutating stale alias r instead of $state review — was causing 'Loading results...' to never resolve for chapter-names/image-gen/description
- notifications bell: redesign with All/Unread tabs, per-item dismiss (×), mark-all-read, clear-all, 'View all' footer link
- /admin/notifications: new dedicated full-page notifications view
- api/notifications proxy: add PATCH (mark-all-read) and DELETE (clear-all, dismiss) handlers
- runner: add CreateNotification calls on success/failure in runScrapeTask, runAudioTask, runTranslationTask
- storage/import.go: real PDF (dslipak/pdf) and EPUB (archive/zip + x/net/html) parsing replacing stubs
- translation admin page: stream jobs Promise instead of blocking navigation
- store.go: DeleteNotification, ClearAllNotifications, MarkAllNotificationsRead methods
- handlers_notifications.go + server.go: PATCH /api/notifications, DELETE /api/notifications, DELETE /api/notifications/{id}
2026-04-09 15:14:00 +05:00
root
2571c243c9 perf: stream slow load functions in admin pages to unblock navigation
All checks were successful
Release / Test backend (push) Successful in 50s
Release / Check ui (push) Successful in 2m4s
Release / Docker (push) Successful in 5m42s
Release / Gitea Release (push) Successful in 36s
- image-gen, text-gen: books list streamed (listBooks is expensive on cold cache)
- ai-jobs: jobs list streamed; add 30s cache to listAIJobs (was uncached listAll)
- changelog: Gitea releases streamed on cold cache; cached path stays synchronous
- admin/+layout.svelte: remove duplicate audio/translation/image-gen nav links
2026-04-09 13:02:32 +05:00
root
89f0d6a546 fix: forward multipart/form-data correctly in import API proxy
All checks were successful
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 1m43s
Release / Docker (push) Successful in 5m51s
Release / Gitea Release (push) Successful in 39s
The SvelteKit proxy was calling request.json() unconditionally,
consuming the body before forwarding. File uploads (multipart/form-data)
now use request.formData() and pass the FormData directly to backendFetch
so the fetch API sets the correct Content-Type boundary automatically.
2026-04-09 12:39:20 +05:00
root
8bc9460989 fix: force-add missing admin_nav_import.js paraglide generated file
All checks were successful
Release / Test backend (push) Successful in 42s
Release / Check ui (push) Successful in 1m47s
Release / Docker (push) Successful in 7m11s
Release / Gitea Release (push) Successful in 41s
The paraglide .gitignore uses '*' to ignore all generated files.
admin_nav_import.js was never force-added after the key was introduced
in v2.6.44, causing svelte-check to fail in CI with 'Cannot find module'.
2026-04-09 12:21:44 +05:00
root
fcd4b3ad7f fix: wire import chapter ingestion, live task polling, a11y labels, notification user targeting
Some checks failed
Release / Test backend (push) Successful in 47s
Release / Check ui (push) Failing after 36s
Release / Docker (push) Has been skipped
Release / Gitea Release (push) Has been skipped
- NewBookImporter now takes *Store instead of raw *minio.Client (same package access)
- Runner Dependencies gains ChapterIngester interface; storeImportedChapters no-op removed
- store.IngestChapters is now actually called after PDF/EPUB extraction
- BookImport and ChapterIngester wired in runner main.go
- CreateImportTask interface gains initiatorUserID param; threaded through store/asynq/handler
- domain.ImportTask gains InitiatorUserID field; parseImportTask populates it
- Runner notifies initiator (falls back to 'admin' when empty)
- UI: import task list polls every 3s while any task is pending/running
- UI: label[for] + input[id] fix for a11y warnings in admin import page
2026-04-09 11:00:01 +05:00
root
ab92bf84bb feat: import review step + admin notifications
Some checks failed
Release / Test backend (push) Failing after 16s
Release / Check ui (push) Failing after 33s
Release / Docker (push) Has been skipped
Release / Gitea Release (push) Has been skipped
- Import page: add file upload with review step before committing
- Backend: add analyze endpoint to preview chapters before import
- Add notifications collection + API for admin alerts
- Add bell icon in header with notification dropdown (admin only)
- Runner creates notification on import completion
- Notifications link to /admin/import for easy review
2026-04-09 10:30:36 +05:00
root
bb55afb562 fix: add missing admin_nav_import i18n key for import page
Some checks failed
Release / Test backend (push) Successful in 42s
Release / Check ui (push) Failing after 36s
Release / Docker (push) Has been skipped
Release / Gitea Release (push) Has been skipped
2026-04-09 10:18:12 +05:00
root
e088bc056e feat: add PDF/EPUB import functionality
Some checks failed
Release / Test backend (push) Successful in 43s
Release / Check ui (push) Failing after 35s
Release / Docker (push) Has been skipped
Release / Gitea Release (push) Has been skipped
- Add ImportTask/ImportResult types to domain.go
- Add TypeImportBook to asynqqueue for task routing
- Add CreateImportTask to producer and storage layers
- Add ClaimNextImportTask/FinishImportTask to Consumer interfaces
- Add import task handling to runner (polling + Asynq handler)
- Add BookImporter interface to bookstore for PDF/EPUB parsing
- Add backend API endpoints: POST/GET /api/admin/import
- Add SvelteKit UI at /admin/import with task list
- Add nav link in admin layout

Note: PDF/EPUB parsing is a placeholder - needs external library integration.
2026-04-09 10:01:20 +05:00
root
a904ff4e21 fix: update CF AI image gen to use multipart for FLUX.2 models
All checks were successful
Release / Test backend (push) Successful in 42s
Release / Check ui (push) Successful in 1m41s
Release / Docker (push) Successful in 5m39s
Release / Gitea Release (push) Successful in 30s
Cloudflare Workers AI changed the API for flux-2-dev, flux-2-klein-4b,
and flux-2-klein-9b to require multipart/form-data (instead of JSON) and
now returns {"image":"<base64>"} instead of raw PNG bytes.

- Add requiresMultipart() helper for the three FLUX.2 models
- callImageAPI builds multipart body for those models, JSON for others
- Parse {"image":"<base64>"} JSON response; fall back to raw bytes for legacy models
- Use "steps" field name (not "num_steps") in multipart forms per CF docs
- Book page: capture and display actual backend error message instead of blank 'Error'
2026-04-08 22:28:36 +05:00
root
04e63414a3 fix: restore swipeStartX declaration (newline eaten in prior edit)
All checks were successful
Release / Test backend (push) Successful in 47s
Release / Check ui (push) Successful in 1m41s
Release / Docker (push) Successful in 5m34s
Release / Gitea Release (push) Successful in 33s
2026-04-08 21:23:23 +05:00
149 changed files with 13145 additions and 5252 deletions

View File

@@ -41,6 +41,9 @@ jobs:
- name: Build healthcheck
run: go build -o /dev/null ./cmd/healthcheck
- name: Build pocketbase
run: go build -o /dev/null ./cmd/pocketbase
- name: Run tests
run: go test -short -race -count=1 -timeout=60s ./...

View File

@@ -32,7 +32,7 @@ jobs:
# ── ui: type-check & build ────────────────────────────────────────────────────
check-ui:
name: Check ui
name: Test UI
runs-on: ubuntu-latest
defaults:
run:
@@ -55,104 +55,9 @@ jobs:
- name: Build
run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v3
with:
name: ui-build
path: ui/build
retention-days: 1
# ── ui: source map upload ─────────────────────────────────────────────────────
# Commented out — re-enable when GlitchTip source map uploads are needed again.
#
# upload-sourcemaps:
# name: Upload source maps
# runs-on: ubuntu-latest
# needs: [check-ui]
# steps:
# - name: Compute release version (strip leading v)
# id: ver
# run: |
# V="${{ gitea.ref_name }}"
# echo "version=${V#v}" >> "$GITHUB_OUTPUT"
#
# - name: Download build artifacts
# uses: actions/download-artifact@v3
# with:
# name: ui-build
# path: build
#
# - name: Install sentry-cli
# run: npm install -g @sentry/cli
#
# - name: Inject debug IDs into build artifacts
# run: sentry-cli sourcemaps inject ./build
# env:
# SENTRY_URL: https://errors.libnovel.cc/
# SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
# SENTRY_ORG: libnovel
# SENTRY_PROJECT: ui
#
# - name: Upload injected build (for docker-ui)
# uses: actions/upload-artifact@v3
# with:
# name: ui-build-injected
# path: build
# retention-days: 1
#
# - name: Create GlitchTip release
# run: sentry-cli releases new ${{ steps.ver.outputs.version }}
# env:
# SENTRY_URL: https://errors.libnovel.cc/
# SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
# SENTRY_ORG: libnovel
# SENTRY_PROJECT: ui
#
# - name: Upload source maps to GlitchTip
# run: sentry-cli sourcemaps upload ./build --release ${{ steps.ver.outputs.version }}
# env:
# SENTRY_URL: https://errors.libnovel.cc/
# SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
# SENTRY_ORG: libnovel
# SENTRY_PROJECT: ui
#
# - name: Finalize GlitchTip release
# run: sentry-cli releases finalize ${{ steps.ver.outputs.version }}
# env:
# SENTRY_URL: https://errors.libnovel.cc/
# SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
# SENTRY_ORG: libnovel
# SENTRY_PROJECT: ui
#
# - name: Prune old GlitchTip releases (keep latest 10)
# run: |
# set -euo pipefail
# KEEP=10
# OLD=$(curl -sf \
# -H "Authorization: Bearer $SENTRY_AUTH_TOKEN" \
# "$SENTRY_URL/api/0/organizations/$SENTRY_ORG/releases/?project=$SENTRY_PROJECT&per_page=100" \
# | python3 -c "
# import sys, json
# releases = json.load(sys.stdin)
# for r in releases[$KEEP:]:
# print(r['version'])
# " KEEP=$KEEP)
# for ver in $OLD; do
# echo "Deleting old release: $ver"
# sentry-cli releases delete "$ver" || true
# done
# env:
# SENTRY_URL: https://errors.libnovel.cc
# SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
# SENTRY_ORG: libnovel
# SENTRY_PROJECT: ui
# ── docker: all images in one job (single login) ──────────────────────────────
# backend, runner, ui, and caddy are built sequentially in one job so Docker
# Hub only needs to be authenticated once. This also eliminates 3 redundant
# checkout + setup-buildx + scheduler round-trips compared to separate jobs.
# ── docker: build + push all images via docker bake ──────────────────────────
docker:
name: Docker
name: Build and push images
runs-on: ubuntu-latest
needs: [test-backend, check-ui]
steps:
@@ -160,121 +65,112 @@ jobs:
- uses: docker/setup-buildx-action@v3
# Single login — credential is written to ~/.docker/config.json and
# reused by all subsequent build-push-action steps in this job.
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
# ── backend ──────────────────────────────────────────────────────────────
- name: Docker meta / backend
id: meta-backend
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_USER }}/libnovel-backend
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Build and push / backend
uses: docker/build-push-action@v6
with:
context: backend
target: backend
push: true
tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta-backend.outputs.labels }}
build-args: |
VERSION=${{ steps.meta-backend.outputs.version }}
COMMIT=${{ gitea.sha }}
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-backend:latest
cache-to: type=inline
# ── runner ───────────────────────────────────────────────────────────────
- name: Docker meta / runner
id: meta-runner
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_USER }}/libnovel-runner
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Build and push / runner
uses: docker/build-push-action@v6
with:
context: backend
target: runner
push: true
tags: ${{ steps.meta-runner.outputs.tags }}
labels: ${{ steps.meta-runner.outputs.labels }}
build-args: |
VERSION=${{ steps.meta-runner.outputs.version }}
COMMIT=${{ gitea.sha }}
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-runner:latest
cache-to: type=inline
# ── ui ───────────────────────────────────────────────────────────────────
- name: Download ui build artifacts
uses: actions/download-artifact@v3
with:
name: ui-build
path: ui/build
- name: Allow build/ into Docker context (override .dockerignore)
- name: Compute version tags
id: ver
run: |
grep -v '^build$' ui/.dockerignore > ui/.dockerignore.tmp
mv ui/.dockerignore.tmp ui/.dockerignore
V="${{ gitea.ref_name }}"
VER="${V#v}"
echo "version=$VER" >> "$GITHUB_OUTPUT"
echo "major_minor=$(echo "$VER" | cut -d. -f1-2)" >> "$GITHUB_OUTPUT"
- name: Docker meta / ui
id: meta-ui
uses: docker/metadata-action@v5
- name: Build and push all images
uses: docker/bake-action@v6
with:
images: ${{ secrets.DOCKER_USER }}/libnovel-ui
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
files: docker-bake.hcl
set: |
*.output=type=image,push=true
env:
VERSION: ${{ steps.ver.outputs.version }}
MAJOR_MINOR: ${{ steps.ver.outputs.major_minor }}
COMMIT: ${{ gitea.sha }}
BUILD_TIME: ${{ gitea.event.head_commit.timestamp }}
- name: Build and push / ui
uses: docker/build-push-action@v6
with:
context: ui
push: true
tags: ${{ steps.meta-ui.outputs.tags }}
labels: ${{ steps.meta-ui.outputs.labels }}
build-args: |
BUILD_VERSION=${{ steps.meta-ui.outputs.version }}
BUILD_COMMIT=${{ gitea.sha }}
BUILD_TIME=${{ gitea.event.head_commit.timestamp }}
PREBUILT=1
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-ui:latest
cache-to: type=inline
# ── deploy: sync docker-compose.yml + restart prod ───────────────────────────
# Runs after all images are pushed to Docker Hub.
# Copies the compose file from the tagged commit to the server, pulls the new
# images, and restarts only the services whose image or config changed.
# --remove-orphans cleans up containers no longer defined in the compose file
# (e.g. the now-removed pb-init container).
#
# Required Gitea secrets:
# PROD_HOST — prod server IP or hostname
# PROD_USER — SSH login user (typically root)
# PROD_SSH_KEY — private key whose public half is in authorized_keys
# PROD_SSH_KNOWN_HOSTS — output of: ssh-keyscan -H <PROD_HOST>
deploy-prod:
name: Deploy to prod
runs-on: ubuntu-latest
needs: [docker]
steps:
- uses: actions/checkout@v4
# ── caddy ────────────────────────────────────────────────────────────────
- name: Docker meta / caddy
id: meta-caddy
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_USER }}/libnovel-caddy
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Install SSH key
run: |
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
printf '%s\n' "${{ secrets.PROD_SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
- name: Build and push / caddy
uses: docker/build-push-action@v6
with:
context: caddy
push: true
tags: ${{ steps.meta-caddy.outputs.tags }}
labels: ${{ steps.meta-caddy.outputs.labels }}
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-caddy:latest
cache-to: type=inline
- name: Copy docker-compose.yml to prod
run: |
scp -i ~/.ssh/deploy_key \
docker-compose.yml \
"${{ secrets.PROD_USER }}@${{ secrets.PROD_HOST }}:/opt/libnovel/docker-compose.yml"
- name: Pull new images and restart changed services
run: |
ssh -i ~/.ssh/deploy_key \
"${{ secrets.PROD_USER }}@${{ secrets.PROD_HOST }}" \
'set -euo pipefail
cd /opt/libnovel
doppler run -- docker compose pull backend runner ui caddy pocketbase
doppler run -- docker compose up -d --no-deps --remove-orphans backend runner ui caddy pocketbase'
# ── deploy homelab runner ─────────────────────────────────────────────────────
# Syncs the homelab runner compose file and restarts the runner service.
#
# Required Gitea secrets:
# HOMELAB_HOST — homelab server IP (192.168.0.109)
# HOMELAB_USER — SSH login user (typically root)
# HOMELAB_SSH_KEY — private key whose public half is in authorized_keys
# HOMELAB_SSH_KNOWN_HOSTS — output of: ssh-keyscan -H <HOMELAB_HOST>
deploy-homelab:
name: Deploy to homelab
runs-on: ubuntu-latest
needs: [docker]
steps:
- uses: actions/checkout@v4
- name: Install SSH key
run: |
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.HOMELAB_SSH_KEY }}" > ~/.ssh/homelab_key
chmod 600 ~/.ssh/homelab_key
- name: Copy docker-compose.yml to homelab
run: |
scp -i ~/.ssh/homelab_key \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
homelab/docker-compose.yml \
"${{ secrets.HOMELAB_USER }}@${{ secrets.HOMELAB_HOST }}:/opt/libnovel-runner/docker-compose.yml"
- name: Pull new runner image and restart
run: |
ssh -i ~/.ssh/homelab_key \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
"${{ secrets.HOMELAB_USER }}@${{ secrets.HOMELAB_HOST }}" \
'set -euo pipefail
cd /opt/libnovel-runner
doppler run --project libnovel --config prd_homelab -- docker compose pull runner
doppler run --project libnovel --config prd_homelab -- docker compose up -d runner'
# ── Gitea release ─────────────────────────────────────────────────────────────
release:

1
.gitignore vendored
View File

@@ -28,3 +28,4 @@ Thumbs.db
*.swp
*.swo
*~
.opencode/

View File

@@ -47,11 +47,21 @@ Sub-directories have their own `AGENTS.md` with deeper context (e.g. `ios/AGENTS
- `release.yaml` — runs on `v*` tags (build Docker images, upload source maps, create Gitea release)
- Secrets: `DOCKER_USER`, `DOCKER_TOKEN`, `GITEA_TOKEN`, `GLITCHTIP_AUTH_TOKEN`
### Git credentials
Credentials are embedded in the remote URL — no `HOME=/root` or credential helper needed for push:
```
https://kamil:95782641Apple%24@gitea.kalekber.cc/kamil/libnovel.git
```
All git commands still use `HOME=/root` prefix for consistency (picks up `/root/.gitconfig` for user name/email), but push auth works without it.
### Releasing a new version
```bash
git tag v2.5.X -m "Short title\n\nOptional longer body"
git push origin v2.5.X
HOME=/root git tag v2.6.X -m "Short title"
HOME=/root git push origin v3-cleanup --tags
```
CI will build all Docker images, upload source maps to GlitchTip, and create a Gitea release automatically.

97
CLAUDE.md Normal file
View File

@@ -0,0 +1,97 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Environment / Secrets
All project environment variables are stored in **Doppler**. When you need to access any secret or env var (e.g. API tokens, database URLs, credentials), fetch them via:
```bash
doppler run -- <command> # inject all secrets into a command
doppler secrets get SECRET_NAME # inspect a specific secret
```
Never use `.env` files. Do not ask the user to provide secrets manually — they are available via Doppler.
## Commands
### Docker (via `just` — the primary way to run services)
All services use Doppler for secrets injection. The `just` commands handle this automatically.
```bash
just up # Start all services in background
just up-fg # Start all services, stream logs
just down # Stop all services
just down-volumes # Full reset (destructive — removes all volumes)
just build # Rebuild all Docker images
just build-svc backend # Rebuild a specific service
just restart # Stop + rebuild + start
just logs # Tail all logs
just log backend # Tail a specific service
just shell backend # Open shell in running container
just init # One-shot init: MinIO buckets, PocketBase collections, Postgres
```
### Backend (Go)
```bash
cd backend
go vet ./...
go test -short -race -count=1 -timeout=60s ./...
go test -short -race -count=1 -run TestFoo ./internal/somepackage/
go build ./cmd/backend
go build ./cmd/runner
```
### Frontend (SvelteKit)
```bash
cd ui
npm run dev # Dev server at localhost:5173
npm run build # Production build
npm run check # svelte-check (type-check)
npm run paraglide # Regenerate i18n messages (run after editing messages/*.json)
```
## Architecture
Three services communicate via PocketBase records and a Redis/Valkey task queue:
**Backend** (`backend/cmd/backend`) — HTTP REST API. Handles reads, enqueues tasks to Redis via Asynq, returns presigned MinIO URLs. Minimal processing; delegates heavy work to the runner.
**Runner** (`backend/cmd/runner`) — Asynq task worker. Processes scraping, TTS audio generation, AI text/image generation. Reads/writes PocketBase and MinIO directly.
**UI** (`ui/`) — SvelteKit 2 + Svelte 5 SSR app. Consumes the backend API. Uses Paraglide JS for i18n (5 locales).
### Data layer
| Service | Role |
|---------|------|
| **PocketBase** (SQLite) | Auth, structured records (books, chapters, tasks, subscriptions) |
| **MinIO** (S3-compatible) | Object storage — chapter text, audio files, images |
| **Meilisearch** | Full-text search (runner indexes, backend reads) |
| **Redis/Valkey** | Asynq task queue + presigned URL cache |
### Key backend packages
- `internal/backend/` — HTTP handlers and server setup
- `internal/runner/` — Task processor implementations
- `internal/storage/` — Unified MinIO + PocketBase interface (all data access goes through here)
- `internal/orchestrator/` — Task orchestration across services
- `internal/taskqueue/` — Enqueue helpers (backend side)
- `internal/asynqqueue/` — Asynq queue setup (runner side)
- `internal/config/` — Environment variable loading (Doppler-injected at runtime, no .env files)
- `internal/presigncache/` — Redis cache for MinIO presigned URLs
### UI routing conventions (SvelteKit)
- `+page.svelte` / `+page.server.ts` — Page + server-side load
- `+layout.svelte` / `+layout.server.ts` — Layouts
- `routes/api/` — API routes (`+server.ts`)
- `lib/audio.svelte.ts` — Client-side audio playback store (Svelte 5 runes)
## Key Conventions
- **Svelte 5 runes only** — use `$state`, `$derived`, `$effect`; do not use Svelte 4 stores or reactive statements.
- **Modern Go idioms** — structured logging via `log/slog`, OpenTelemetry tracing throughout.
- **No direct MinIO/PocketBase client calls** outside the `internal/storage/` package.
- **Secrets via Doppler** — never use `.env` files. All secrets are injected by Doppler CLI.
- **CI/CD is Gitea Actions** (`.gitea/workflows/`), not GitHub Actions. Use `gitea.ref_name`/`gitea.sha` variables.
- **Git hooks** in `.githooks/` — enable with `just setup`.
- **i18n**: translation files live in `ui/messages/{en,es,fr,de,pt}.json`; run `npm run paraglide` after editing them.
- **Error tracking**: GlitchTip with per-service DSNs (backend id/2, runner id/3, UI id/1) stored in Doppler.

225
DOCKERFILE_ANALYSIS.md Normal file
View File

@@ -0,0 +1,225 @@
# Dockerfile Dependency Analysis
## Current Image Sizes
| Image | Size | Status |
|-------|------|--------|
| backend | 179MB | ✅ Good |
| runner | 178MB | ✅ Good |
| pocketbase | 37MB | ✅ Excellent |
| caddy | 114MB | ✅ Good |
| ui | **413MB** | ⚠️ **LARGE** |
---
## UI Dependencies Analysis (413MB image)
### Production Dependencies (package.json)
| Package | Used? | Size Impact | Notes |
|---------|-------|-------------|-------|
| `@aws-sdk/client-s3` | ❌ **UNUSED** | ~100MB | **REMOVE** - Not imported anywhere |
| `@aws-sdk/s3-request-presigner` | ❌ **UNUSED** | ~50MB | **REMOVE** - Not imported anywhere |
| `@grafana/faro-web-sdk` | ✅ Used | ~2MB | Keep - RUM tracking |
| `@inlang/paraglide-js` | ✅ Used | ~1MB | Keep - i18n |
| `@opentelemetry/*` (5 packages) | ✅ Used | ~15MB | Keep - Server-side tracing |
| `@sentry/sveltekit` | ✅ Used | ~10MB | Keep - Error tracking |
| `cropperjs` | ✅ Used | ~500KB | Keep - Avatar cropping |
| `ioredis` | ✅ Used | ~5MB | Keep - Redis client (server-side) |
| `marked` | ✅ Used | ~500KB | Keep - Markdown parsing |
| `pocketbase` | ✅ Used | ~200KB | Keep - PocketBase client |
| **EXTRANEOUS** | | | |
| `@playwright/test` | ❌ **EXTRANEOUS** | ~50MB | **REMOVE** - Should be devDependency |
| `playwright-core` | ❌ **EXTRANEOUS** | ~50MB | **REMOVE** - Should be devDependency |
| `playwright` | ❌ **EXTRANEOUS** | ~50MB | **REMOVE** - Should be devDependency |
**Total waste: ~300MB (AWS SDK + Playwright)**
### Why AWS SDK is in dependencies?
Checking git history... it was likely added for direct S3 uploads but never actually used. The backend handles all S3 operations.
---
## Backend Dependencies Analysis (179MB image)
### Docker Image Breakdown
```dockerfile
FROM alpine:3.21 # ~7MB base
RUN apk add ffmpeg # ~40MB (needed for audio transcoding)
RUN apk add ca-certificates # ~200KB (needed for HTTPS)
COPY /out/backend # ~130MB (Go binary + stdlib)
```
**All dependencies justified:**
-`ffmpeg` - Required for pocket-tts WAV→MP3 transcoding
-`ca-certificates` - Required for HTTPS connections to external services
- ✅ Go binary includes all dependencies (static linking)
### Go Module Analysis
```bash
go list -m all | wc -l # 169 modules
```
Go binaries are statically linked, so unused imports don't increase image size. The build process with `-ldflags="-s -w"` strips symbols and debug info.
**Optimization already applied:**
- CGO_ENABLED=0 (static linking, no libc dependency)
- -ldflags="-s -w" (strip symbols, ~20% size reduction)
- BuildKit cache mounts (faster rebuilds)
---
## Caddy Dependencies Analysis (114MB image)
```dockerfile
FROM caddy:2-alpine # ~50MB base
COPY /usr/bin/caddy # ~60MB (with 3 plugins)
COPY errors/ # ~4MB (error page assets)
```
**Plugins in use:**
-`caddy-ratelimit` - Used for API rate limiting
-`caddy-crowdsec-bouncer` - Used for CrowdSec integration
-`caddy-l4` - Used for TCP/UDP proxying (Redis TLS proxy)
**All plugins justified** - actively used in production Caddyfile.
---
## Recommendations
### 1. Remove AWS SDK from UI (PRIORITY: HIGH)
**Impact:** ~150MB reduction (413MB → 263MB, 36% smaller)
```bash
cd ui
npm uninstall @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
```
**Risk:** None - confirmed unused via grep
### 2. Remove Playwright from production (PRIORITY: HIGH)
**Impact:** ~150MB reduction (263MB → 113MB, 57% smaller)
**Issue:** Playwright packages are marked as "extraneous" - they're installed but not in package.json. This happens when:
- Someone ran `npm install playwright` without `--save-dev`
- package-lock.json got corrupted
**Fix:**
```bash
cd ui
rm -rf node_modules package-lock.json
npm install
```
This will regenerate package-lock.json without the extraneous packages.
### 3. Consider distroless for backend/runner (OPTIONAL)
**Impact:** ~10-15MB reduction per image
**Current:** Alpine + ffmpeg (required)
**Alternative:** Use distroless + statically compiled ffmpeg
**Tradeoff:**
- Pros: Smaller attack surface, smaller image
- Cons: Harder to debug, need to bundle ffmpeg binary
- Verdict: **NOT WORTH IT** - ffmpeg from apk is well-maintained
### 4. Use .dockerignore (ALREADY GOOD ✅)
Both UI and backend have proper .dockerignore files:
- ✅ node_modules excluded (ui)
- ✅ build artifacts excluded
- ✅ .git excluded
---
## Expected Results After Cleanup
| Image | Before | After | Savings |
|-------|--------|-------|---------|
| backend | 179MB | 179MB | 0MB (already optimal) |
| runner | 178MB | 178MB | 0MB (already optimal) |
| pocketbase | 37MB | 37MB | 0MB (already optimal) |
| caddy | 114MB | 114MB | 0MB (already optimal) |
| ui | **413MB** | **~110MB** | **~300MB (73% smaller)** |
**Total deployment size reduction:** ~300MB
**Deployment time improvement:** ~20-30s faster (less to pull from Docker Hub)
---
## Action Plan
```bash
# 1. Clean up UI dependencies
cd ui
npm uninstall @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
rm -rf node_modules package-lock.json
npm install
# 2. Verify no imports remain
grep -r "@aws-sdk" src/ # Should return nothing
grep -r "playwright" src/ # Should return nothing
# 3. Test build locally
npm run build
# 4. Commit changes
git add package.json package-lock.json
git commit -m "chore: remove unused AWS SDK and Playwright dependencies from UI
- Remove @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner (~150MB)
- Remove extraneous Playwright packages (~150MB)
- UI image size: 413MB → ~110MB (73% smaller)
All S3 operations are handled by the backend, not the UI."
# 5. Tag and deploy
git tag v4.3.7 -m "chore: remove unused dependencies, reduce UI image by 73%"
git push origin --tags
```
---
## Backend/Runner Go Dependencies (For Reference)
The Go images are already well-optimized. Here are the main dependencies:
**Backend (179MB):**
- PocketBase SDK
- MinIO SDK (S3)
- Meilisearch SDK
- Redis SDK (ioredis equivalent)
- HTTP router (chi)
- OpenTelemetry SDK
**Runner (178MB):**
- Same as backend
- + Chromedp (headless Chrome for scraping)
- + Audio processing libs
All are actively used - no dead code found.
---
## Conclusion
**Current state:**
- Backend, runner, pocketbase, caddy: ✅ Already well-optimized
- UI: ⚠️ Carrying 300MB of unused dependencies
**Impact of cleanup:**
- 73% smaller UI image
- Faster deployments
- Lower bandwidth costs
- Cleaner dependency tree
**Effort:** ~5 minutes (remove 2 packages + regenerate lockfile)
**Risk:** Very low (confirmed unused via code search)

60
HOMELAB_SECRETS_SETUP.md Normal file
View File

@@ -0,0 +1,60 @@
# Homelab Deployment Secrets Setup
The release workflow now includes automatic deployment to the homelab runner server. You need to add these secrets to Gitea.
## Required Secrets
Go to: `https://gitea.kalekber.cc/kamil/libnovel/settings/secrets/actions`
### 1. HOMELAB_HOST
```
192.168.0.109
```
### 2. HOMELAB_USER
```
root
```
### 3. HOMELAB_SSH_KEY
If you want to use the same SSH key as prod:
- Copy the value from `PROD_SSH_KEY` secret
If you want a separate key:
```bash
# On your local machine or CI runner
cat ~/.ssh/id_rsa # or your preferred key
```
### 4. HOMELAB_SSH_KNOWN_HOSTS
Run this when the homelab server is reachable:
```bash
ssh-keyscan -H 192.168.0.109 2>/dev/null
```
Expected output format:
```
|1|base64hash...|192.168.0.109 ssh-rsa AAAAB3NzaC...
|1|base64hash...|192.168.0.109 ecdsa-sha2-nistp256 AAAAE2...
|1|base64hash...|192.168.0.109 ssh-ed25519 AAAAC3...
```
## Testing
After adding the secrets, the next release (e.g., v4.1.10) will automatically:
1. Build all Docker images
2. Deploy to prod (165.22.70.138) ✅
3. Deploy to homelab (192.168.0.109) ✅ NEW
4. Create a Gitea release
Both deployments run in parallel for faster releases.
## Troubleshooting
If the homelab deployment fails:
- Check that the secrets are set correctly
- Verify SSH access: `ssh root@192.168.0.109`
- Check Doppler config exists: `doppler configs --project libnovel`
- Manually test: `cd /opt/libnovel-runner && doppler run --project libnovel --config prd_homelab -- docker compose pull runner`

View File

@@ -27,7 +27,10 @@ RUN --mount=type=cache,target=/root/go/pkg/mod \
-o /out/runner ./cmd/runner && \
CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w" \
-o /out/healthcheck ./cmd/healthcheck
-o /out/healthcheck ./cmd/healthcheck && \
CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w" \
-o /out/pocketbase ./cmd/pocketbase
# ── backend service ──────────────────────────────────────────────────────────
# Uses Alpine (not distroless) so ffmpeg is available for on-demand voice
@@ -40,6 +43,18 @@ COPY --from=builder /out/backend /backend
USER appuser
ENTRYPOINT ["/backend"]
# ── pocketbase service ───────────────────────────────────────────────────────
# Runs the custom PocketBase binary with Go migrations baked in.
# On every `serve` startup it applies any pending migrations automatically.
# Data is stored in /pb_data (mounted as a Docker volume in production).
FROM alpine:3.21 AS pocketbase
RUN apk add --no-cache ca-certificates wget
COPY --from=builder /out/pocketbase /pocketbase
RUN mkdir -p /pb_data
VOLUME /pb_data
EXPOSE 8090
CMD ["/pocketbase", "serve", "--dir", "/pb_data", "--http", "0.0.0.0:8090"]
# ── runner service ───────────────────────────────────────────────────────────
# Uses Alpine (not distroless) so ffmpeg is available for WAV→MP3 transcoding
# when pocket-tts voices are used.

View File

@@ -177,6 +177,7 @@ func run() error {
DefaultVoice: cfg.Kokoro.DefaultVoice,
Version: version,
Commit: commit,
AdminToken: cfg.HTTP.AdminToken,
},
backend.Dependencies{
BookReader: store,
@@ -189,6 +190,7 @@ func run() error {
ChapterImageStore: store,
Producer: producer,
TaskReader: store,
ImportFileStore: store,
SearchIndex: searchIndex,
Kokoro: kokoroClient,
PocketTTS: pocketTTSClient,
@@ -197,6 +199,8 @@ func run() error {
TextGen: textGenClient,
BookWriter: store,
AIJobStore: store,
BookAdminStore: store,
NotificationStore: store,
Log: log,
},
)

View File

@@ -0,0 +1,47 @@
// Command pocketbase is a thin wrapper that runs PocketBase as a Go framework
// with version-controlled Go migrations.
//
// On every `serve`, PocketBase automatically applies any pending migrations from
// the migrations/ package before accepting traffic.
//
// Usage (Docker):
//
// ./pocketbase serve --dir /pb_data --http 0.0.0.0:8090
//
// Migration workflow:
//
// # Generate a timestamped stub:
// go run ./cmd/pocketbase migrate create "description"
// # Apply manually (also runs automatically on serve):
// go run ./cmd/pocketbase migrate up
// # Revert last migration:
// go run ./cmd/pocketbase migrate down 1
// # After migrating an existing install, mark existing schema as done:
// go run ./cmd/pocketbase migrate history-sync
package main
import (
"log"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
// Register all migrations via init().
_ "github.com/libnovel/backend/migrations"
)
func main() {
app := pocketbase.New()
// Register the migrate sub-command.
// Automigrate: false — migrations are written by hand, never auto-generated
// from Admin UI changes. Pending migrations still apply automatically on
// every `serve` regardless of this flag.
migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
Automigrate: false,
})
if err := app.Start(); err != nil {
log.Fatal(err)
}
}

View File

@@ -34,6 +34,7 @@ import (
"github.com/libnovel/backend/internal/runner"
"github.com/libnovel/backend/internal/storage"
"github.com/libnovel/backend/internal/taskqueue"
"github.com/libnovel/backend/internal/webpush"
)
// version and commit are set at build time via -ldflags.
@@ -190,20 +191,35 @@ func run() error {
log.Info("runner: poll mode — using PocketBase for task dispatch")
}
// ── Web Push ─────────────────────────────────────────────────────────────
var pushSender *webpush.Sender
if cfg.VAPID.PublicKey != "" && cfg.VAPID.PrivateKey != "" {
pushSender = webpush.New(cfg.VAPID.PublicKey, cfg.VAPID.PrivateKey, cfg.VAPID.Subject, log)
log.Info("runner: web push notifications enabled")
} else {
log.Info("runner: VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY not set — push notifications disabled")
}
deps := runner.Dependencies{
Consumer: consumer,
BookWriter: store,
BookReader: store,
AudioStore: store,
CoverStore: store,
TranslationStore: store,
SearchIndex: searchIndex,
Novel: novel,
Kokoro: kokoroClient,
PocketTTS: pocketTTSClient,
CFAI: cfaiClient,
LibreTranslate: ltClient,
Log: log,
BookWriter: store,
BookReader: store,
AudioStore: store,
CoverStore: store,
TranslationStore: store,
BookImport: storage.NewBookImporter(store),
ImportChapterStore: store,
ChapterIngester: store,
SearchIndex: searchIndex,
Novel: novel,
Kokoro: kokoroClient,
PocketTTS: pocketTTSClient,
CFAI: cfaiClient,
LibreTranslate: ltClient,
Notifier: store,
WebPush: pushSender,
Store: store,
Log: log,
}
r := runner.New(rCfg, deps)

View File

@@ -3,70 +3,99 @@ module github.com/libnovel/backend
go 1.26.1
require (
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/getsentry/sentry-go v0.43.0
github.com/hibiken/asynq v0.26.0
github.com/hibiken/asynq/x v0.0.0-20260203063626-d704b68a426d
github.com/meilisearch/meilisearch-go v0.36.1
github.com/minio/minio-go/v7 v7.0.98
golang.org/x/net v0.51.0
github.com/pdfcpu/pdfcpu v0.11.1
github.com/pocketbase/pocketbase v0.36.9
github.com/prometheus/client_golang v1.23.2
github.com/redis/go-redis/v9 v9.18.0
github.com/yuin/goldmark v1.8.2
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0
go.opentelemetry.io/otel v1.42.0
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0
go.opentelemetry.io/otel/log v0.18.0
go.opentelemetry.io/otel/sdk v1.42.0
go.opentelemetry.io/otel/sdk/log v0.18.0
golang.org/x/net v0.52.0
)
require (
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/getsentry/sentry-go v0.43.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/hibiken/asynq v0.26.0 // indirect
github.com/hibiken/asynq/x v0.0.0-20260203063626-d704b68a426d // indirect
github.com/hhrutter/lzw v1.0.0 // indirect
github.com/hhrutter/pkcs7 v0.2.0 // indirect
github.com/hhrutter/tiff v1.0.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/meilisearch/meilisearch-go v0.36.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pocketbase/dbx v1.12.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/redis/go-redis/v9 v9.18.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/tinylib/msgp v1.6.1 // indirect
github.com/yuin/goldmark v1.8.2 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.opentelemetry.io/otel v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 // indirect
go.opentelemetry.io/otel/log v0.18.0 // indirect
go.opentelemetry.io/otel/metric v1.42.0 // indirect
go.opentelemetry.io/otel/sdk v1.42.0 // indirect
go.opentelemetry.io/otel/sdk/log v0.18.0 // indirect
go.opentelemetry.io/otel/trace v1.42.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/image v0.38.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/grpc v1.79.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.48.2 // indirect
)

View File

@@ -1,21 +1,48 @@
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/getsentry/sentry-go v0.43.0 h1:XbXLpFicpo8HmBDaInk7dum18G9KSLcjZiyUKS+hLW4=
github.com/getsentry/sentry-go v0.43.0/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -23,16 +50,39 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg=
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo=
github.com/hhrutter/pkcs7 v0.2.0 h1:i4HN2XMbGQpZRnKBLsUwO3dSckzgX142TNqY/KfXg+I=
github.com/hhrutter/pkcs7 v0.2.0/go.mod h1:aEzKz0+ZAlz7YaEMY47jDHL14hVWD6iXt0AgqgAvWgE=
github.com/hhrutter/tiff v1.0.2 h1:7H3FQQpKu/i5WaSChoD1nnJbGx4MxU5TlNqqpxw55z8=
github.com/hhrutter/tiff v1.0.2/go.mod h1:pcOeuK5loFUE7Y/WnzGw20YxUdnqjY1P0Jlcieb/cCw=
github.com/hibiken/asynq v0.26.0 h1:1Zxr92MlDnb1Zt/QR5g2vSCqUS03i95lUfqx5X7/wrw=
github.com/hibiken/asynq v0.26.0/go.mod h1:Qk4e57bTnWDoyJ67VkchuV6VzSM9IQW2nPvAGuDyw58=
github.com/hibiken/asynq/x v0.0.0-20260203063626-d704b68a426d h1:Ld5m8EIK5QVOq/owOexKIbETij3skACg4eU1pArHsrw=
github.com/hibiken/asynq/x v0.0.0-20260203063626-d704b68a426d/go.mod h1:hhpStehaxSGg3ib9wJXzw5AXY1YS6lQ9BNavAgPbIhE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
@@ -40,6 +90,18 @@ github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4O
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/meilisearch/meilisearch-go v0.36.1 h1:mJTCJE5g7tRvaqKco6DfqOuJEjX+rRltDEnkEC02Y0M=
github.com/meilisearch/meilisearch-go v0.36.1/go.mod h1:hWcR0MuWLSzHfbz9GGzIr3s9rnXLm1jqkmHkJPbUSvM=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
@@ -50,42 +112,61 @@ github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRi
github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pdfcpu/pdfcpu v0.11.1 h1:htHBSkGH5jMKWC6e0sihBFbcKZ8vG1M67c8/dJxhjas=
github.com/pdfcpu/pdfcpu v0.11.1/go.mod h1:pP3aGga7pRvwFWAm9WwFvo+V68DfANi9kxSQYioNYcw=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/pocketbase v0.36.9 h1:x3mXMB4AwhTzJ34JZpZR7IQyUih7Fx1l86r0V/k4oW8=
github.com/pocketbase/pocketbase v0.36.9/go.mod h1:t3sMcAxGHrDAXNcZ+65cZxBMpFP1vBdI9DrghB4n5Gw=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0 h1:NFIS6x7wyObQ7cR84x7bt1sr8nYBx89s3x3GwRjw40k=
@@ -108,26 +189,111 @@ go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXY
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
go.opentelemetry.io/otel/sdk/log v0.18.0 h1:n8OyZr7t7otkeTnPTbDNom6rW16TBYGtvyy2Gk6buQw=
go.opentelemetry.io/otel/sdk/log v0.18.0/go.mod h1:C0+wxkTwKpOCZLrlJ3pewPiiQwpzycPI/u6W0Z9fuYk=
go.opentelemetry.io/otel/sdk/log/logtest v0.18.0 h1:l3mYuPsuBx6UKE47BVcPrZoZ0q/KER57vbj2qkgDLXA=
go.opentelemetry.io/otel/sdk/log/logtest v0.18.0/go.mod h1:7cHtiVJpZebB3wybTa4NG+FUo5NPe3PROz1FqB0+qdw=
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
@@ -136,8 +302,39 @@ google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@@ -40,6 +40,10 @@ func (c *Consumer) FinishTranslationTask(ctx context.Context, id string, result
return c.pb.FinishTranslationTask(ctx, id, result)
}
func (c *Consumer) FinishImportTask(ctx context.Context, id string, result domain.ImportResult) error {
return c.pb.FinishImportTask(ctx, id, result)
}
func (c *Consumer) FailTask(ctx context.Context, id, errMsg string) error {
return c.pb.FailTask(ctx, id, errMsg)
}
@@ -60,6 +64,12 @@ func (c *Consumer) ClaimNextTranslationTask(ctx context.Context, workerID string
return c.pb.ClaimNextTranslationTask(ctx, workerID)
}
// ClaimNextImportTask delegates to PocketBase because import tasks
// are stored in PocketBase (not Redis/Asynq) and must still be polled directly.
func (c *Consumer) ClaimNextImportTask(ctx context.Context, workerID string) (domain.ImportTask, bool, error) {
return c.pb.ClaimNextImportTask(ctx, workerID)
}
func (c *Consumer) HeartbeatTask(ctx context.Context, id string) error {
return c.pb.HeartbeatTask(ctx, id)
}

View File

@@ -7,6 +7,7 @@ import (
"log/slog"
"github.com/hibiken/asynq"
"github.com/libnovel/backend/internal/domain"
"github.com/libnovel/backend/internal/taskqueue"
)
@@ -87,6 +88,30 @@ func (p *Producer) CreateTranslationTask(ctx context.Context, slug string, chapt
return p.pb.CreateTranslationTask(ctx, slug, chapter, lang)
}
// CreateImportTask creates a PocketBase record then enqueues an Asynq job for PDF/EPUB import.
func (p *Producer) CreateImportTask(ctx context.Context, task domain.ImportTask) (string, error) {
id, err := p.pb.CreateImportTask(ctx, task)
if err != nil {
return "", err
}
payload := ImportPayload{
PBTaskID: id,
Slug: task.Slug,
Title: task.Title,
FileType: task.FileType,
ObjectKey: task.ObjectKey,
ChaptersKey: task.ChaptersKey,
}
if err := p.enqueue(ctx, TypeImportBook, payload); err != nil {
// Non-fatal: PB record exists; runner will pick it up on next poll.
p.log.Warn("asynq enqueue import failed (task still in PB, runner will poll)",
"task_id", id, "err", err)
return id, nil
}
return id, nil
}
// CancelTask delegates to PocketBase; Asynq jobs may already be running and
// cannot be reliably cancelled, so we only update the audit record.
func (p *Producer) CancelTask(ctx context.Context, id string) error {

View File

@@ -23,6 +23,7 @@ const (
TypeAudioGenerate = "audio:generate"
TypeScrapeBook = "scrape:book"
TypeScrapeCatalogue = "scrape:catalogue"
TypeImportBook = "import:book"
)
// AudioPayload is the Asynq job payload for audio generation tasks.
@@ -44,3 +45,13 @@ type ScrapePayload struct {
FromChapter int `json:"from_chapter"` // 0 unless Kind=="book_range"
ToChapter int `json:"to_chapter"` // 0 unless Kind=="book_range"
}
// ImportPayload is the Asynq job payload for PDF/EPUB import tasks.
type ImportPayload struct {
PBTaskID string `json:"pb_task_id"`
Slug string `json:"slug"`
Title string `json:"title"`
FileType string `json:"file_type"` // "pdf" or "epub"
ObjectKey string `json:"object_key"` // MinIO path to uploaded file
ChaptersKey string `json:"chapters_key"` // MinIO path to pre-parsed chapters JSON
}

View File

@@ -293,7 +293,8 @@ func (s *Server) handleGetRanking(w http.ResponseWriter, r *http.Request) {
// handleGetCover handles GET /api/cover/{domain}/{slug}.
// Serves the cover image directly from MinIO when available; falls back to a
// redirect to the novelfire CDN when the cover has not yet been downloaded.
// redirect to the stored cover URL from PocketBase when the cover has not yet
// been downloaded to MinIO.
func (s *Server) handleGetCover(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
@@ -318,10 +319,20 @@ func (s *Server) handleGetCover(w http.ResponseWriter, r *http.Request) {
}
}
// Fallback: redirect to the CDN. The caller sees a working image; the
// cover will be populated on the next catalogue refresh run.
coverURL := fmt.Sprintf("https://cdn.novelfire.net/covers/%s.jpg", slug)
http.Redirect(w, r, coverURL, http.StatusFound)
// Fallback: read the stored cover URL from PocketBase and redirect to it.
// This avoids the broken cdn.novelfire.net domain and uses the actual URL
// scraped from the source. If the book is not found, return 404.
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), slug)
if err != nil {
s.deps.Log.Warn("handleGetCover: ReadMetadata error", "slug", slug, "err", err)
http.Error(w, "cover not found", http.StatusNotFound)
return
}
if !ok || meta.Cover == "" || strings.HasPrefix(meta.Cover, "/api/cover/") {
http.Error(w, "cover not found", http.StatusNotFound)
return
}
http.Redirect(w, r, meta.Cover, http.StatusFound)
}
// ── Preview (live scrape, no store writes) ─────────────────────────────────────
@@ -1279,6 +1290,10 @@ func (s *Server) handleTranslationRead(w http.ResponseWriter, r *http.Request) {
return
}
// Translated chapter content is immutable once generated — cache aggressively.
// The browser and any intermediary (CDN, SvelteKit fetch cache) can reuse this
// response for 1 hour without hitting MinIO again.
w.Header().Set("Cache-Control", "public, max-age=3600, stale-while-revalidate=86400")
writeJSON(w, 0, map[string]string{"html": buf.String(), "lang": lang})
}
@@ -1865,13 +1880,19 @@ func (s *Server) handleCatalogue(w http.ResponseWriter, r *http.Request) {
limit = 100
}
// Admin users (identified by bearer token) see all non-archived books
// including those marked admin_only.
isAdmin := s.cfg.AdminToken != "" &&
r.Header.Get("Authorization") == "Bearer "+s.cfg.AdminToken
cq := meili.CatalogueQuery{
Q: q.Get("q"),
Genre: genre,
Status: status,
Sort: sort,
Page: page,
Limit: limit,
Q: q.Get("q"),
Genre: genre,
Status: status,
Sort: sort,
Page: page,
Limit: limit,
AdminAll: isAdmin,
}
books, total, facets, err := s.deps.SearchIndex.Catalogue(r.Context(), cq)
@@ -1881,6 +1902,16 @@ func (s *Server) handleCatalogue(w http.ResponseWriter, r *http.Request) {
return
}
// Rewrite raw scraped cover URLs to go through the backend cover proxy.
// /api/cover/{domain}/{slug} serves from MinIO when available, otherwise
// redirects to the CDN. This avoids ERR_BLOCKED_BY_ORB when the source
// site returns HTML error pages instead of images.
for i := range books {
if !strings.HasPrefix(books[i].Cover, "/api/cover/") {
books[i].Cover = fmt.Sprintf("/api/cover/novelfire.net/%s", books[i].Slug)
}
}
hasNext := int64(page*limit) < total
w.Header().Set("Cache-Control", "public, max-age=60")

View File

@@ -0,0 +1,117 @@
package backend
import (
"errors"
"net/http"
"github.com/libnovel/backend/internal/storage"
)
// handleAdminArchiveBook handles PATCH /api/admin/books/{slug}/archive.
// Soft-deletes a book by setting archived=true in PocketBase and updating the
// Meilisearch document so it is excluded from all public search results.
// The book data is preserved and can be restored with the unarchive endpoint.
func (s *Server) handleAdminArchiveBook(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
jsonError(w, http.StatusBadRequest, "missing slug")
return
}
if s.deps.BookAdminStore == nil {
jsonError(w, http.StatusServiceUnavailable, "book admin store not configured")
return
}
if err := s.deps.BookAdminStore.ArchiveBook(r.Context(), slug); err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "book not found")
return
}
s.deps.Log.Error("archive book failed", "slug", slug, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
// Update the Meilisearch document so the archived flag takes effect
// immediately in search/catalogue results.
if meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), slug); err == nil && ok {
if upsertErr := s.deps.SearchIndex.UpsertBook(r.Context(), meta); upsertErr != nil {
s.deps.Log.Warn("archive book: meili upsert failed", "slug", slug, "err", upsertErr)
}
}
s.deps.Log.Info("book archived", "slug", slug)
writeJSON(w, http.StatusOK, map[string]string{"slug": slug, "status": "archived"})
}
// handleAdminUnarchiveBook handles PATCH /api/admin/books/{slug}/unarchive.
// Restores a previously archived book by clearing the archived flag, making it
// publicly visible in search and catalogue results again.
func (s *Server) handleAdminUnarchiveBook(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
jsonError(w, http.StatusBadRequest, "missing slug")
return
}
if s.deps.BookAdminStore == nil {
jsonError(w, http.StatusServiceUnavailable, "book admin store not configured")
return
}
if err := s.deps.BookAdminStore.UnarchiveBook(r.Context(), slug); err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "book not found")
return
}
s.deps.Log.Error("unarchive book failed", "slug", slug, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
// Sync the updated archived=false state back to Meilisearch.
if meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), slug); err == nil && ok {
if upsertErr := s.deps.SearchIndex.UpsertBook(r.Context(), meta); upsertErr != nil {
s.deps.Log.Warn("unarchive book: meili upsert failed", "slug", slug, "err", upsertErr)
}
}
s.deps.Log.Info("book unarchived", "slug", slug)
writeJSON(w, http.StatusOK, map[string]string{"slug": slug, "status": "active"})
}
// handleAdminDeleteBook handles DELETE /api/admin/books/{slug}.
// Permanently removes all data for a book:
// - PocketBase books record and all chapters_idx records
// - All MinIO chapter markdown objects and the cover image
// - Meilisearch document
//
// This operation is irreversible. Use the archive endpoint for soft-deletion.
func (s *Server) handleAdminDeleteBook(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
jsonError(w, http.StatusBadRequest, "missing slug")
return
}
if s.deps.BookAdminStore == nil {
jsonError(w, http.StatusServiceUnavailable, "book admin store not configured")
return
}
if err := s.deps.BookAdminStore.DeleteBook(r.Context(), slug); err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "book not found")
return
}
s.deps.Log.Error("delete book failed", "slug", slug, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
// Remove from Meilisearch — best-effort (log on failure, don't fail request).
if err := s.deps.SearchIndex.DeleteBook(r.Context(), slug); err != nil {
s.deps.Log.Warn("delete book: meili delete failed", "slug", slug, "err", err)
}
s.deps.Log.Info("book deleted", "slug", slug)
writeJSON(w, http.StatusOK, map[string]string{"slug": slug, "status": "deleted"})
}

View File

@@ -0,0 +1,234 @@
package backend
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/libnovel/backend/internal/domain"
"github.com/libnovel/backend/internal/storage"
)
type importRequest struct {
Title string `json:"title"`
Author string `json:"author"`
CoverURL string `json:"cover_url"`
Genres []string `json:"genres"`
Summary string `json:"summary"`
BookStatus string `json:"book_status"` // "ongoing" | "completed" | "hiatus"
FileName string `json:"file_name"`
FileType string `json:"file_type"` // "pdf" or "epub"
ObjectKey string `json:"object_key"` // MinIO path to uploaded file
}
type importResponse struct {
TaskID string `json:"task_id"`
Slug string `json:"slug"`
Preview *importPreview `json:"preview,omitempty"`
}
type importPreview struct {
Chapters int `json:"chapters"`
FirstLines []string `json:"first_lines"`
}
func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) {
if s.deps.Producer == nil {
jsonError(w, http.StatusServiceUnavailable, "task queue not configured")
return
}
ct := r.Header.Get("Content-Type")
var req importRequest
var objectKey string
var chaptersKey string
var chapterCount int
if strings.HasPrefix(ct, "multipart/form-data") {
if err := r.ParseMultipartForm(32 << 20); err != nil {
jsonError(w, http.StatusBadRequest, "parse multipart: "+err.Error())
return
}
req.Title = r.FormValue("title")
req.Author = r.FormValue("author")
req.CoverURL = r.FormValue("cover_url")
req.Summary = r.FormValue("summary")
req.BookStatus = r.FormValue("book_status")
if g := r.FormValue("genres"); g != "" {
for _, s := range strings.Split(g, ",") {
if s = strings.TrimSpace(s); s != "" {
req.Genres = append(req.Genres, s)
}
}
}
req.FileName = r.FormValue("file_name")
req.FileType = r.FormValue("file_type")
analyzeOnly := r.FormValue("analyze") == "true"
file, header, err := r.FormFile("file")
if err != nil {
jsonError(w, http.StatusBadRequest, "parse file: "+err.Error())
return
}
defer file.Close()
if req.FileName == "" {
req.FileName = header.Filename
}
if req.FileType == "" {
req.FileType = strings.TrimPrefix(filepath.Ext(header.Filename), ".")
}
data, err := io.ReadAll(file)
if err != nil {
jsonError(w, http.StatusBadRequest, "read file: "+err.Error())
return
}
// Analyze only - just count chapters
if analyzeOnly {
preview := analyzeImportFile(data, req.FileType)
writeJSON(w, 0, importResponse{
Preview: preview,
})
return
}
// Parse PDF/EPUB on the backend (with timeout) and store chapters as JSON.
// The runner only needs to ingest pre-parsed chapters — no PDF parsing on runner.
parseCtx, parseCancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer parseCancel()
chapters, parseErr := storage.ParseImportFile(parseCtx, data, req.FileType)
if parseErr != nil || len(chapters) == 0 {
jsonError(w, http.StatusUnprocessableEntity, "could not parse file: "+func() string {
if parseErr != nil { return parseErr.Error() }
return "no chapters found"
}())
return
}
// Store raw file in MinIO (for reference/re-import).
objectKey = fmt.Sprintf("imports/%d_%s", time.Now().Unix(), header.Filename)
if s.deps.ImportFileStore == nil {
jsonError(w, http.StatusInternalServerError, "storage not available")
return
}
if err := s.deps.ImportFileStore.PutImportFile(r.Context(), objectKey, data); err != nil {
jsonError(w, http.StatusInternalServerError, "upload file: "+err.Error())
return
}
// Store pre-parsed chapters JSON in MinIO so runner can ingest without re-parsing.
chaptersJSON, _ := json.Marshal(chapters)
chaptersKey = fmt.Sprintf("imports/%d_%s_chapters.json", time.Now().Unix(), strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename)))
if err := s.deps.ImportFileStore.PutImportChapters(r.Context(), chaptersKey, chaptersJSON); err != nil {
jsonError(w, http.StatusInternalServerError, "store chapters: "+err.Error())
return
}
chapterCount = len(chapters)
} else {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
return
}
objectKey = req.ObjectKey
}
if req.Title == "" {
jsonError(w, http.StatusBadRequest, "title is required")
return
}
if req.FileType != "pdf" && req.FileType != "epub" {
jsonError(w, http.StatusBadRequest, "file_type must be 'pdf' or 'epub'")
return
}
slug := strings.ToLower(strings.ReplaceAll(req.Title, " ", "-"))
slug = strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
return r
}
return -1
}, slug)
taskID, err := s.deps.Producer.CreateImportTask(r.Context(), domain.ImportTask{
Slug: slug,
Title: req.Title,
Author: req.Author,
CoverURL: req.CoverURL,
Genres: req.Genres,
Summary: req.Summary,
BookStatus: req.BookStatus,
FileType: req.FileType,
ObjectKey: objectKey,
ChaptersKey: chaptersKey,
ChaptersTotal: chapterCount,
InitiatorUserID: "",
})
if err != nil {
jsonError(w, http.StatusInternalServerError, "create import task: "+err.Error())
return
}
writeJSON(w, 0, importResponse{
TaskID: taskID,
Slug: slug,
Preview: &importPreview{Chapters: chapterCount},
})
}
// analyzeImportFile parses the file to count chapters and extract preview lines.
func analyzeImportFile(data []byte, fileType string) *importPreview {
count, firstLines, err := storage.AnalyzeFile(data, fileType)
if err != nil || count == 0 {
// Fall back to rough size estimate so the UI still shows something
count = estimateChapters(data, fileType)
}
return &importPreview{
Chapters: count,
FirstLines: firstLines,
}
}
func estimateChapters(data []byte, fileType string) int {
// Rough estimate: ~100KB per chapter for PDF, ~50KB for EPUB
size := len(data)
if fileType == "pdf" {
return size / 100000
}
return size / 50000
}
func (s *Server) handleAdminImportStatus(w http.ResponseWriter, r *http.Request) {
taskID := r.PathValue("id")
if taskID == "" {
jsonError(w, http.StatusBadRequest, "task id required")
return
}
task, ok, err := s.deps.TaskReader.GetImportTask(r.Context(), taskID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "get task: "+err.Error())
return
}
if !ok {
jsonError(w, http.StatusNotFound, "task not found")
return
}
writeJSON(w, 0, task)
}
func (s *Server) handleAdminImportList(w http.ResponseWriter, r *http.Request) {
tasks, err := s.deps.TaskReader.ListImportTasks(r.Context())
if err != nil {
jsonError(w, http.StatusInternalServerError, "list tasks: "+err.Error())
return
}
writeJSON(w, 0, map[string]any{"tasks": tasks})
}

View File

@@ -0,0 +1,119 @@
package backend
import (
"encoding/json"
"net/http"
)
// handleDismissNotification handles DELETE /api/notifications/{id}.
func (s *Server) handleDismissNotification(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
jsonError(w, http.StatusBadRequest, "notification id required")
return
}
if s.deps.NotificationStore == nil {
jsonError(w, http.StatusServiceUnavailable, "notification store not configured")
return
}
if err := s.deps.NotificationStore.DeleteNotification(r.Context(), id); err != nil {
jsonError(w, http.StatusInternalServerError, "dismiss notification: "+err.Error())
return
}
writeJSON(w, 0, map[string]any{"success": true})
}
// handleClearAllNotifications handles DELETE /api/notifications?user_id=...
func (s *Server) handleClearAllNotifications(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("user_id")
if userID == "" {
jsonError(w, http.StatusBadRequest, "user_id required")
return
}
if s.deps.NotificationStore == nil {
jsonError(w, http.StatusServiceUnavailable, "notification store not configured")
return
}
if err := s.deps.NotificationStore.ClearAllNotifications(r.Context(), userID); err != nil {
jsonError(w, http.StatusInternalServerError, "clear notifications: "+err.Error())
return
}
writeJSON(w, 0, map[string]any{"success": true})
}
// handleMarkAllNotificationsRead handles PATCH /api/notifications?user_id=...
func (s *Server) handleMarkAllNotificationsRead(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("user_id")
if userID == "" {
jsonError(w, http.StatusBadRequest, "user_id required")
return
}
if s.deps.NotificationStore == nil {
jsonError(w, http.StatusServiceUnavailable, "notification store not configured")
return
}
if err := s.deps.NotificationStore.MarkAllNotificationsRead(r.Context(), userID); err != nil {
jsonError(w, http.StatusInternalServerError, "mark all read: "+err.Error())
return
}
writeJSON(w, 0, map[string]any{"success": true})
}
type notification struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Title string `json:"title"`
Message string `json:"message"`
Link string `json:"link"`
Read bool `json:"read"`
}
func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("user_id")
if userID == "" {
jsonError(w, http.StatusBadRequest, "user_id required")
return
}
if s.deps.NotificationStore == nil {
jsonError(w, http.StatusServiceUnavailable, "notification store not configured")
return
}
items, err := s.deps.NotificationStore.ListNotifications(r.Context(), userID, 50)
if err != nil {
jsonError(w, http.StatusInternalServerError, "list notifications: "+err.Error())
return
}
// Parse each item as notification
notifications := make([]notification, 0, len(items))
for _, item := range items {
b, _ := json.Marshal(item)
var n notification
json.Unmarshal(b, &n) //nolint:errcheck
notifications = append(notifications, n)
}
writeJSON(w, 0, map[string]any{"notifications": notifications})
}
func (s *Server) handleMarkNotificationRead(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
jsonError(w, http.StatusBadRequest, "notification id required")
return
}
if s.deps.NotificationStore == nil {
jsonError(w, http.StatusServiceUnavailable, "notification store not configured")
return
}
if err := s.deps.NotificationStore.MarkNotificationRead(r.Context(), id); err != nil {
jsonError(w, http.StatusInternalServerError, "mark read: "+err.Error())
return
}
writeJSON(w, 0, map[string]any{"success": true})
}

View File

@@ -0,0 +1,87 @@
package backend
import (
"encoding/json"
"net/http"
"os"
"github.com/libnovel/backend/internal/storage"
)
// handleGetVAPIDPublicKey handles GET /api/push-subscriptions/vapid-public-key.
// Returns the VAPID public key so the SvelteKit frontend can subscribe browsers.
func (s *Server) handleGetVAPIDPublicKey(w http.ResponseWriter, r *http.Request) {
key := os.Getenv("VAPID_PUBLIC_KEY")
if key == "" {
jsonError(w, http.StatusServiceUnavailable, "push notifications not configured")
return
}
writeJSON(w, 0, map[string]string{"public_key": key})
}
// handleSavePushSubscription handles POST /api/push-subscriptions.
// Registers a new browser push subscription for the authenticated user.
func (s *Server) handleSavePushSubscription(w http.ResponseWriter, r *http.Request) {
store, ok := s.deps.Producer.(*storage.Store)
if !ok {
jsonError(w, http.StatusInternalServerError, "storage not available")
return
}
var body struct {
UserID string `json:"user_id"`
Endpoint string `json:"endpoint"`
P256DH string `json:"p256dh"`
Auth string `json:"auth"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.UserID == "" || body.Endpoint == "" || body.P256DH == "" || body.Auth == "" {
jsonError(w, http.StatusBadRequest, "user_id, endpoint, p256dh and auth are required")
return
}
if err := store.SavePushSubscription(r.Context(), storage.PushSubscription{
UserID: body.UserID,
Endpoint: body.Endpoint,
P256DH: body.P256DH,
Auth: body.Auth,
}); err != nil {
jsonError(w, http.StatusInternalServerError, "save push subscription: "+err.Error())
return
}
writeJSON(w, 0, map[string]any{"success": true})
}
// handleDeletePushSubscription handles DELETE /api/push-subscriptions.
// Removes a push subscription by endpoint for the given user.
func (s *Server) handleDeletePushSubscription(w http.ResponseWriter, r *http.Request) {
store, ok := s.deps.Producer.(*storage.Store)
if !ok {
jsonError(w, http.StatusInternalServerError, "storage not available")
return
}
var body struct {
UserID string `json:"user_id"`
Endpoint string `json:"endpoint"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.UserID == "" || body.Endpoint == "" {
jsonError(w, http.StatusBadRequest, "user_id and endpoint are required")
return
}
if err := store.DeletePushSubscription(r.Context(), body.UserID, body.Endpoint); err != nil {
jsonError(w, http.StatusInternalServerError, "delete push subscription: "+err.Error())
return
}
writeJSON(w, 0, map[string]any{"success": true})
}

View File

@@ -0,0 +1,141 @@
package backend
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/libnovel/backend/internal/bookstore"
"github.com/libnovel/backend/internal/domain"
)
// handleAdminSplitChapters handles POST /api/admin/books/{slug}/split-chapters.
//
// Request body (JSON):
//
// { "text": "<full text with --- dividers and optional ## Title lines>" }
//
// The text is split on lines containing only "---". Each segment may start with
// a "## Title" line which becomes the chapter title; remaining lines are the
// chapter content. Sequential chapter numbers 1..N are assigned.
//
// All existing chapters for the book are replaced: WriteChapter is called for
// each new chapter (upsert by number), so chapters beyond N are not deleted —
// use the dedup endpoint afterwards if needed.
func (s *Server) handleAdminSplitChapters(w http.ResponseWriter, r *http.Request) {
if s.deps.BookWriter == nil {
jsonError(w, http.StatusServiceUnavailable, "book writer not configured")
return
}
slug := r.PathValue("slug")
if slug == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
var req struct {
Text string `json:"text"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
return
}
if strings.TrimSpace(req.Text) == "" {
jsonError(w, http.StatusBadRequest, "text is required")
return
}
chapters := splitChapterText(req.Text)
if len(chapters) == 0 {
jsonError(w, http.StatusUnprocessableEntity, "no chapters produced from text")
return
}
for _, ch := range chapters {
var mdContent string
if ch.Title != "" && ch.Title != fmt.Sprintf("Chapter %d", ch.Number) {
mdContent = fmt.Sprintf("# %s\n\n%s", ch.Title, ch.Content)
} else {
mdContent = fmt.Sprintf("# Chapter %d\n\n%s", ch.Number, ch.Content)
}
domainCh := domain.Chapter{
Ref: domain.ChapterRef{Number: ch.Number, Title: ch.Title},
Text: mdContent,
}
if err := s.deps.BookWriter.WriteChapter(r.Context(), slug, domainCh); err != nil {
jsonError(w, http.StatusInternalServerError, fmt.Sprintf("write chapter %d: %s", ch.Number, err.Error()))
return
}
}
writeJSON(w, 0, map[string]any{
"chapters": len(chapters),
"slug": slug,
})
}
// splitChapterText splits text on "---" divider lines into bookstore.Chapter
// slices. Each segment may optionally start with a "## Title" header line.
func splitChapterText(text string) []bookstore.Chapter {
lines := strings.Split(text, "\n")
// Collect raw segments split on "---" dividers.
var segments [][]string
cur := []string{}
for _, line := range lines {
if strings.TrimSpace(line) == "---" {
segments = append(segments, cur)
cur = []string{}
} else {
cur = append(cur, line)
}
}
segments = append(segments, cur) // last segment
var chapters []bookstore.Chapter
chNum := 0
for _, seg := range segments {
// Trim leading/trailing blank lines from the segment.
start, end := 0, len(seg)
for start < end && strings.TrimSpace(seg[start]) == "" {
start++
}
for end > start && strings.TrimSpace(seg[end-1]) == "" {
end--
}
seg = seg[start:end]
if len(seg) == 0 {
continue
}
// Check for a "## Title" header on the first line.
title := ""
contentStart := 0
if strings.HasPrefix(strings.TrimSpace(seg[0]), "## ") {
title = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(seg[0]), "## "))
contentStart = 1
// Skip blank lines after the title.
for contentStart < len(seg) && strings.TrimSpace(seg[contentStart]) == "" {
contentStart++
}
}
content := strings.TrimSpace(strings.Join(seg[contentStart:], "\n"))
if content == "" {
continue
}
chNum++
if title == "" {
title = fmt.Sprintf("Chapter %d", chNum)
}
chapters = append(chapters, bookstore.Chapter{
Number: chNum,
Title: title,
Content: content,
})
}
return chapters
}

View File

@@ -0,0 +1,161 @@
package backend
import (
"encoding/json"
"errors"
"net/http"
"regexp"
"strings"
"unicode"
"github.com/libnovel/backend/internal/domain"
"github.com/libnovel/backend/internal/storage"
)
// handleAdminPublishBook handles PATCH /api/admin/books/{slug}/publish.
// Sets visibility=public so the book is visible to all users.
func (s *Server) handleAdminPublishBook(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
jsonError(w, http.StatusBadRequest, "missing slug")
return
}
if s.deps.BookAdminStore == nil {
jsonError(w, http.StatusServiceUnavailable, "book admin store not configured")
return
}
if err := s.deps.BookAdminStore.PublishBook(r.Context(), slug); err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "book not found")
return
}
s.deps.Log.Error("publish book failed", "slug", slug, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
// Sync the visibility change to Meilisearch immediately.
if meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), slug); err == nil && ok {
if upsertErr := s.deps.SearchIndex.UpsertBook(r.Context(), meta); upsertErr != nil {
s.deps.Log.Warn("publish book: meili upsert failed", "slug", slug, "err", upsertErr)
}
}
s.deps.Log.Info("book published", "slug", slug)
writeJSON(w, http.StatusOK, map[string]string{"slug": slug, "visibility": domain.VisibilityPublic})
}
// handleAdminUnpublishBook handles PATCH /api/admin/books/{slug}/unpublish.
// Sets visibility=admin_only, hiding the book from regular users.
func (s *Server) handleAdminUnpublishBook(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
jsonError(w, http.StatusBadRequest, "missing slug")
return
}
if s.deps.BookAdminStore == nil {
jsonError(w, http.StatusServiceUnavailable, "book admin store not configured")
return
}
if err := s.deps.BookAdminStore.UnpublishBook(r.Context(), slug); err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "book not found")
return
}
s.deps.Log.Error("unpublish book failed", "slug", slug, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
// Sync to Meilisearch.
if meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), slug); err == nil && ok {
if upsertErr := s.deps.SearchIndex.UpsertBook(r.Context(), meta); upsertErr != nil {
s.deps.Log.Warn("unpublish book: meili upsert failed", "slug", slug, "err", upsertErr)
}
}
s.deps.Log.Info("book unpublished", "slug", slug)
writeJSON(w, http.StatusOK, map[string]string{"slug": slug, "visibility": domain.VisibilityAdminOnly})
}
// handleAdminSubmitBook handles POST /api/admin/books/submit.
// Creates a new author-submitted book with visibility=public.
// The book starts with zero chapters; chapters are added via the import pipeline.
func (s *Server) handleAdminSubmitBook(w http.ResponseWriter, r *http.Request) {
var req struct {
Title string `json:"title"`
Author string `json:"author"`
Cover string `json:"cover"`
Summary string `json:"summary"`
Genres []string `json:"genres"`
Status string `json:"status"`
SubmittedBy string `json:"submitted_by"` // app_users ID of submitting author
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid request body")
return
}
req.Title = strings.TrimSpace(req.Title)
if req.Title == "" {
jsonError(w, http.StatusBadRequest, "title is required")
return
}
if req.Status == "" {
req.Status = "ongoing"
}
slug := slugifyTitle(req.Title)
if slug == "" {
jsonError(w, http.StatusBadRequest, "could not derive a slug from title")
return
}
if s.deps.BookAdminStore == nil {
jsonError(w, http.StatusServiceUnavailable, "book admin store not configured")
return
}
meta := domain.BookMeta{
Slug: slug,
Title: req.Title,
Author: req.Author,
Cover: req.Cover,
Summary: req.Summary,
Genres: req.Genres,
Status: req.Status,
Visibility: domain.VisibilityPublic,
SubmittedBy: req.SubmittedBy,
}
if err := s.deps.BookAdminStore.CreateSubmittedBook(r.Context(), meta); err != nil {
s.deps.Log.Error("submit book: create failed", "slug", slug, "err", err)
jsonError(w, http.StatusInternalServerError, "failed to create book")
return
}
// Index in Meilisearch immediately so it appears in search/catalogue.
if upsertErr := s.deps.SearchIndex.UpsertBook(r.Context(), meta); upsertErr != nil {
s.deps.Log.Warn("submit book: meili upsert failed", "slug", slug, "err", upsertErr)
}
s.deps.Log.Info("book submitted", "slug", slug, "title", req.Title, "by", req.SubmittedBy)
writeJSON(w, http.StatusCreated, map[string]string{"slug": slug})
}
// slugifyTitle converts a book title into a URL-safe slug.
// e.g. "The Wandering Sword" → "the-wandering-sword"
var nonAlnum = regexp.MustCompile(`[^a-z0-9]+`)
func slugifyTitle(title string) string {
// Fold to lower-case ASCII, replace non-alphanum runs with hyphens.
var b strings.Builder
for _, r := range strings.ToLower(title) {
if r <= unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)) {
b.WriteRune(r)
} else {
b.WriteRune('-')
}
}
slug := nonAlnum.ReplaceAllString(b.String(), "-")
slug = strings.Trim(slug, "-")
if len(slug) > 80 {
slug = slug[:80]
slug = strings.TrimRight(slug, "-")
}
return slug
}

View File

@@ -233,6 +233,7 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
}
}
var allResults []proposedChapterTitle
chaptersDone := resumeFrom
firstEvent := true
for i, batch := range batches {
@@ -287,6 +288,7 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
NewTitle: p.Title,
})
}
allResults = append(allResults, result...)
chaptersDone += len(batch)
if jobID != "" && s.deps.AIJobStore != nil {
@@ -310,16 +312,22 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
sseWrite(evt)
}
// Mark job as done in PB.
// Mark job as done in PB, persisting results so the Review button works.
// Use context.Background() — r.Context() may be cancelled if the SSE client
// disconnected before processing finished, which would silently drop results.
if jobID != "" && s.deps.AIJobStore != nil {
status := domain.TaskStatusDone
if jobCtx.Err() != nil {
status = domain.TaskStatusCancelled
}
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
resultsJSON, _ := json.Marshal(allResults)
finalPayload := fmt.Sprintf(`{"pattern":%q,"slug":%q,"results":%s}`,
req.Pattern, req.Slug, string(resultsJSON))
_ = s.deps.AIJobStore.UpdateAIJob(context.Background(), jobID, map[string]any{
"status": string(status),
"items_done": chaptersDone,
"finished": time.Now().Format(time.RFC3339),
"payload": finalPayload,
})
}

View File

@@ -85,9 +85,19 @@ type Dependencies struct {
// BookWriter writes book metadata and chapter refs to PocketBase.
// Used by admin text-gen apply endpoints.
BookWriter bookstore.BookWriter
// ImportFileStore uploads raw PDF/EPUB files to MinIO for the runner to process.
// Always wired to the concrete *storage.Store (not the Asynq wrapper).
ImportFileStore bookstore.ImportFileStore
// AIJobStore tracks long-running AI generation jobs in PocketBase.
// If nil, job persistence is disabled (jobs still run but are not recorded).
AIJobStore bookstore.AIJobStore
// BookAdminStore provides admin-only operations: archive, unarchive, hard-delete.
// If nil, the admin book management endpoints return 503.
BookAdminStore bookstore.BookAdminStore
// NotificationStore manages per-user in-app notifications.
// Always wired directly to *storage.Store (not the Asynq wrapper) so
// notification endpoints work regardless of whether Redis/Asynq is in use.
NotificationStore bookstore.NotificationStore
// Log is the structured logger.
Log *slog.Logger
}
@@ -101,6 +111,9 @@ type Config struct {
// Version and Commit are embedded in /health and /api/version responses.
Version string
Commit string
// AdminToken is the bearer token required for all /api/admin/* endpoints.
// When empty a startup warning is logged and admin routes are unprotected.
AdminToken string
}
// Server is the HTTP API server.
@@ -127,9 +140,30 @@ func New(cfg Config, deps Dependencies) *Server {
return &Server{cfg: cfg, deps: deps}
}
// requireAdmin returns a handler that enforces Bearer token authentication.
// When AdminToken is empty all requests are allowed through (with a warning logged
// once at startup via ListenAndServe).
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if s.cfg.AdminToken == "" {
next(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer "+s.cfg.AdminToken {
jsonError(w, http.StatusUnauthorized, "unauthorized")
return
}
next(w, r)
}
}
// ListenAndServe registers all routes and starts the HTTP server.
// It blocks until ctx is cancelled, then performs a graceful shutdown.
func (s *Server) ListenAndServe(ctx context.Context) error {
if s.cfg.AdminToken == "" {
s.deps.Log.Warn("backend: BACKEND_ADMIN_TOKEN is not set — /api/admin/* endpoints are unprotected")
}
mux := http.NewServeMux()
// Health / version
@@ -194,55 +228,90 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
mux.HandleFunc("GET /api/translation/status/{slug}/{n}", s.handleTranslationStatus)
mux.HandleFunc("GET /api/translation/{slug}/{n}", s.handleTranslationRead)
// admin is a shorthand that wraps every /api/admin/* handler with bearer-token auth.
admin := func(pattern string, h http.HandlerFunc) {
mux.HandleFunc(pattern, s.requireAdmin(h))
}
// Admin translation endpoints
mux.HandleFunc("GET /api/admin/translation/jobs", s.handleAdminTranslationJobs)
mux.HandleFunc("POST /api/admin/translation/bulk", s.handleAdminTranslationBulk)
admin("GET /api/admin/translation/jobs", s.handleAdminTranslationJobs)
admin("POST /api/admin/translation/bulk", s.handleAdminTranslationBulk)
// Admin audio endpoints
mux.HandleFunc("GET /api/admin/audio/jobs", s.handleAdminAudioJobs)
mux.HandleFunc("POST /api/admin/audio/bulk", s.handleAdminAudioBulk)
mux.HandleFunc("POST /api/admin/audio/cancel-bulk", s.handleAdminAudioCancelBulk)
admin("GET /api/admin/audio/jobs", s.handleAdminAudioJobs)
admin("POST /api/admin/audio/bulk", s.handleAdminAudioBulk)
admin("POST /api/admin/audio/cancel-bulk", s.handleAdminAudioCancelBulk)
// Admin image generation endpoints
mux.HandleFunc("GET /api/admin/image-gen/models", s.handleAdminImageGenModels)
mux.HandleFunc("POST /api/admin/image-gen", s.handleAdminImageGen)
mux.HandleFunc("POST /api/admin/image-gen/async", s.handleAdminImageGenAsync)
mux.HandleFunc("POST /api/admin/image-gen/save-cover", s.handleAdminImageGenSaveCover)
mux.HandleFunc("POST /api/admin/image-gen/save-chapter-image", s.handleAdminImageGenSaveChapterImage)
admin("GET /api/admin/image-gen/models", s.handleAdminImageGenModels)
admin("POST /api/admin/image-gen", s.handleAdminImageGen)
admin("POST /api/admin/image-gen/async", s.handleAdminImageGenAsync)
admin("POST /api/admin/image-gen/save-cover", s.handleAdminImageGenSaveCover)
admin("POST /api/admin/image-gen/save-chapter-image", s.handleAdminImageGenSaveChapterImage)
// Chapter image serving
mux.HandleFunc("GET /api/chapter-image/{domain}/{slug}/{n}", s.handleGetChapterImage)
mux.HandleFunc("HEAD /api/chapter-image/{domain}/{slug}/{n}", s.handleHeadChapterImage)
// Admin text generation endpoints (chapter names + book description)
mux.HandleFunc("GET /api/admin/text-gen/models", s.handleAdminTextGenModels)
mux.HandleFunc("POST /api/admin/text-gen/chapter-names", s.handleAdminTextGenChapterNames)
mux.HandleFunc("POST /api/admin/text-gen/chapter-names/async", s.handleAdminTextGenChapterNamesAsync)
mux.HandleFunc("POST /api/admin/text-gen/chapter-names/apply", s.handleAdminTextGenApplyChapterNames)
mux.HandleFunc("POST /api/admin/text-gen/description", s.handleAdminTextGenDescription)
mux.HandleFunc("POST /api/admin/text-gen/description/async", s.handleAdminTextGenDescriptionAsync)
mux.HandleFunc("POST /api/admin/text-gen/description/apply", s.handleAdminTextGenApplyDescription)
admin("GET /api/admin/text-gen/models", s.handleAdminTextGenModels)
admin("POST /api/admin/text-gen/chapter-names", s.handleAdminTextGenChapterNames)
admin("POST /api/admin/text-gen/chapter-names/async", s.handleAdminTextGenChapterNamesAsync)
admin("POST /api/admin/text-gen/chapter-names/apply", s.handleAdminTextGenApplyChapterNames)
admin("POST /api/admin/text-gen/description", s.handleAdminTextGenDescription)
admin("POST /api/admin/text-gen/description/async", s.handleAdminTextGenDescriptionAsync)
admin("POST /api/admin/text-gen/description/apply", s.handleAdminTextGenApplyDescription)
// Admin catalogue enrichment endpoints
mux.HandleFunc("POST /api/admin/text-gen/tagline", s.handleAdminTextGenTagline)
mux.HandleFunc("POST /api/admin/text-gen/genres", s.handleAdminTextGenGenres)
mux.HandleFunc("POST /api/admin/text-gen/genres/apply", s.handleAdminTextGenApplyGenres)
mux.HandleFunc("POST /api/admin/text-gen/content-warnings", s.handleAdminTextGenContentWarnings)
mux.HandleFunc("POST /api/admin/text-gen/quality-score", s.handleAdminTextGenQualityScore)
mux.HandleFunc("POST /api/admin/catalogue/batch-covers", s.handleAdminBatchCovers)
mux.HandleFunc("POST /api/admin/catalogue/batch-covers/cancel", s.handleAdminBatchCoversCancel)
mux.HandleFunc("POST /api/admin/catalogue/refresh-metadata/{slug}", s.handleAdminRefreshMetadata)
admin("POST /api/admin/text-gen/tagline", s.handleAdminTextGenTagline)
admin("POST /api/admin/text-gen/genres", s.handleAdminTextGenGenres)
admin("POST /api/admin/text-gen/genres/apply", s.handleAdminTextGenApplyGenres)
admin("POST /api/admin/text-gen/content-warnings", s.handleAdminTextGenContentWarnings)
admin("POST /api/admin/text-gen/quality-score", s.handleAdminTextGenQualityScore)
admin("POST /api/admin/catalogue/batch-covers", s.handleAdminBatchCovers)
admin("POST /api/admin/catalogue/batch-covers/cancel", s.handleAdminBatchCoversCancel)
admin("POST /api/admin/catalogue/refresh-metadata/{slug}", s.handleAdminRefreshMetadata)
// Admin AI job tracking endpoints
mux.HandleFunc("GET /api/admin/ai-jobs", s.handleAdminListAIJobs)
mux.HandleFunc("GET /api/admin/ai-jobs/{id}", s.handleAdminGetAIJob)
mux.HandleFunc("POST /api/admin/ai-jobs/{id}/cancel", s.handleAdminCancelAIJob)
admin("GET /api/admin/ai-jobs", s.handleAdminListAIJobs)
admin("GET /api/admin/ai-jobs/{id}", s.handleAdminGetAIJob)
admin("POST /api/admin/ai-jobs/{id}/cancel", s.handleAdminCancelAIJob)
// Auto-prompt generation from book/chapter content
mux.HandleFunc("POST /api/admin/image-gen/auto-prompt", s.handleAdminImageGenAutoPrompt)
admin("POST /api/admin/image-gen/auto-prompt", s.handleAdminImageGenAutoPrompt)
// Admin data repair endpoints
mux.HandleFunc("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters)
admin("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters)
// Admin book management (soft-delete / hard-delete / publish visibility)
admin("PATCH /api/admin/books/{slug}/archive", s.handleAdminArchiveBook)
admin("PATCH /api/admin/books/{slug}/unarchive", s.handleAdminUnarchiveBook)
admin("DELETE /api/admin/books/{slug}", s.handleAdminDeleteBook)
admin("PATCH /api/admin/books/{slug}/publish", s.handleAdminPublishBook)
admin("PATCH /api/admin/books/{slug}/unpublish", s.handleAdminUnpublishBook)
// Author book submission (creates a public book with no scraped content)
admin("POST /api/admin/books/submit", s.handleAdminSubmitBook)
// Admin chapter split (imported books)
admin("POST /api/admin/books/{slug}/split-chapters", s.handleAdminSplitChapters)
// Import (PDF/EPUB)
admin("POST /api/admin/import", s.handleAdminImport)
admin("GET /api/admin/import", s.handleAdminImportList)
admin("GET /api/admin/import/{id}", s.handleAdminImportStatus)
// Notifications
mux.HandleFunc("GET /api/notifications", s.handleListNotifications)
mux.HandleFunc("PATCH /api/notifications", s.handleMarkAllNotificationsRead)
mux.HandleFunc("PATCH /api/notifications/{id}", s.handleMarkNotificationRead)
mux.HandleFunc("DELETE /api/notifications", s.handleClearAllNotifications)
mux.HandleFunc("DELETE /api/notifications/{id}", s.handleDismissNotification)
// Web Push subscriptions
mux.HandleFunc("GET /api/push-subscriptions/vapid-public-key", s.handleGetVAPIDPublicKey)
mux.HandleFunc("POST /api/push-subscriptions", s.handleSavePushSubscription)
mux.HandleFunc("DELETE /api/push-subscriptions", s.handleDeletePushSubscription)
// Voices list
mux.HandleFunc("GET /api/voices", s.handleVoices)

View File

@@ -200,3 +200,70 @@ type TranslationStore interface {
// GetTranslation retrieves translated markdown from MinIO.
GetTranslation(ctx context.Context, key string) (string, error)
}
// Chapter represents a single chapter extracted from PDF/EPUB.
type Chapter struct {
Number int // 1-based chapter number
Title string // chapter title (may be empty)
Content string // plain text content
}
// BookImporter handles PDF/EPUB file parsing and chapter extraction.
// Used by the runner to import books from uploaded files.
type BookImporter interface {
// Import extracts chapters from a PDF or EPUB file stored in MinIO.
// Returns the extracted chapters or an error.
Import(ctx context.Context, objectKey, fileType string) ([]Chapter, error)
}
// BookAdminStore covers admin-only operations for managing books in the catalogue.
// All methods require admin authorisation at the HTTP handler level.
type BookAdminStore interface {
// ArchiveBook sets archived=true on a book record, hiding it from all
// public search and catalogue responses. Returns ErrNotFound when the
// slug does not exist.
ArchiveBook(ctx context.Context, slug string) error
// UnarchiveBook clears archived on a book record, making it publicly
// visible again. Returns ErrNotFound when the slug does not exist.
UnarchiveBook(ctx context.Context, slug string) error
// DeleteBook permanently removes all data for a book:
// - PocketBase books record
// - All PocketBase chapters_idx records
// - All MinIO chapter markdown objects ({slug}/chapter-*.md)
// - MinIO cover image (covers/{slug}.jpg)
// The caller is responsible for also deleting the Meilisearch document.
DeleteBook(ctx context.Context, slug string) error
// PublishBook sets visibility=public, making the book visible to all users.
PublishBook(ctx context.Context, slug string) error
// UnpublishBook sets visibility=admin_only, hiding the book from regular users.
UnpublishBook(ctx context.Context, slug string) error
// CreateSubmittedBook creates a new author-submitted book with visibility=public.
CreateSubmittedBook(ctx context.Context, meta domain.BookMeta) error
}
// ImportFileStore uploads raw import files to object storage.
// Kept separate from BookImporter so the HTTP handler can upload the file
// without a concrete type assertion, regardless of which Producer is wired.
type ImportFileStore interface {
PutImportFile(ctx context.Context, objectKey string, data []byte) error
// PutImportChapters stores the pre-parsed chapters JSON under the given key.
PutImportChapters(ctx context.Context, key string, data []byte) error
// GetImportChapters retrieves the pre-parsed chapters JSON.
GetImportChapters(ctx context.Context, key string) ([]byte, error)
}
// NotificationStore manages per-user in-app notifications.
// Always wired directly to the concrete *storage.Store so it works
// regardless of whether the Asynq task-queue wrapper is in use.
type NotificationStore interface {
ListNotifications(ctx context.Context, userID string, limit int) ([]map[string]any, error)
MarkNotificationRead(ctx context.Context, id string) error
MarkAllNotificationsRead(ctx context.Context, userID string) error
DeleteNotification(ctx context.Context, id string) error
ClearAllNotifications(ctx context.Context, userID string) error
}

View File

@@ -4,17 +4,26 @@
//
// POST https://api.cloudflare.com/client/v4/accounts/{accountID}/ai/run/{model}
// Authorization: Bearer {apiToken}
// Content-Type: application/json
//
// Text-only request (all models):
// FLUX.2 models (flux-2-dev, flux-2-klein-4b, flux-2-klein-9b):
//
// { "prompt": "...", "num_steps": 20 }
// Content-Type: multipart/form-data
// Fields: prompt, num_steps, width, height, guidance, image_b64 (optional)
// Response: { "image": "<base64 JPEG>" }
//
// Reference-image request:
// - FLUX models: { "prompt": "...", "image_b64": "<base64>" }
// - SD img2img: { "prompt": "...", "image": [r,g,b,a,...], "strength": 0.75 }
// Other models (flux-1-schnell, SDXL, SD 1.5):
//
// All models return raw PNG bytes on success (Content-Type: image/png).
// Content-Type: application/json
// Body: { "prompt": "...", "num_steps": 20 }
// Response: { "image": "<base64>" } or raw bytes depending on model
//
// Reference-image request (FLUX.2):
//
// Same multipart form; include image_b64 field with base64-encoded reference.
//
// Reference-image request (SD img2img):
//
// JSON body: { "prompt": "...", "image": [r,g,b,a,...], "strength": 0.75 }
//
// Recommended models for LibNovel:
// - Book covers (no reference): flux-2-dev, flux-2-klein-9b, lucid-origin
@@ -35,7 +44,9 @@ import (
"image/png"
_ "image/png" // register PNG decoder
"io"
"mime/multipart"
"net/http"
"strings"
"time"
)
@@ -173,23 +184,43 @@ func NewImageGen(accountID, apiToken string) ImageGenClient {
}
}
// requiresMultipart reports whether the model requires a multipart/form-data
// request body instead of JSON. FLUX.2 models on Cloudflare Workers AI changed
// their API to require multipart and return {"image":"<base64>"} instead of
// raw image bytes.
func requiresMultipart(model ImageModel) bool {
switch model {
case ImageModelFlux2Dev, ImageModelFlux2Klein4B, ImageModelFlux2Klein9B:
return true
default:
return false
}
}
// GenerateImage generates an image from text only.
func (c *imageGenHTTPClient) GenerateImage(ctx context.Context, req ImageRequest) ([]byte, error) {
req = applyImageDefaults(req)
body := map[string]any{
"prompt": req.Prompt,
"num_steps": req.NumSteps,
// FLUX.2 multipart models use "steps"; JSON models use "num_steps".
stepsKey := "num_steps"
if requiresMultipart(req.Model) {
stepsKey = "steps"
}
fields := map[string]any{
"prompt": req.Prompt,
stepsKey: req.NumSteps,
}
if req.Width > 0 {
body["width"] = req.Width
fields["width"] = req.Width
}
if req.Height > 0 {
body["height"] = req.Height
fields["height"] = req.Height
}
if req.Guidance > 0 {
body["guidance"] = req.Guidance
fields["guidance"] = req.Guidance
}
return c.callImageAPI(ctx, req.Model, body)
return c.callImageAPI(ctx, req.Model, fields, nil)
}
// refImageMaxDim is the maximum dimension (width or height) for reference images
@@ -205,10 +236,37 @@ func (c *imageGenHTTPClient) GenerateImageFromReference(ctx context.Context, req
req = applyImageDefaults(req)
// Shrink the reference image if it exceeds the safe payload size.
// This avoids CF's 4 MB JSON body limit and reduces latency.
refImage = resizeRefImage(refImage, refImageMaxDim)
var body map[string]any
// FLUX.2 multipart models use "steps"; JSON models use "num_steps".
stepsKey := "num_steps"
if requiresMultipart(req.Model) {
stepsKey = "steps"
}
fields := map[string]any{
"prompt": req.Prompt,
stepsKey: req.NumSteps,
}
if req.Width > 0 {
fields["width"] = req.Width
}
if req.Height > 0 {
fields["height"] = req.Height
}
if req.Guidance > 0 {
fields["guidance"] = req.Guidance
}
if requiresMultipart(req.Model) {
// FLUX.2: reference image sent as base64 form field "image_b64".
fields["image_b64"] = base64.StdEncoding.EncodeToString(refImage)
if req.Strength > 0 {
fields["strength"] = req.Strength
}
return c.callImageAPI(ctx, req.Model, fields, nil)
}
if req.Model == ImageModelSD15Img2Img {
pixels, err := decodeImageToRGBA(refImage)
if err != nil {
@@ -218,33 +276,17 @@ func (c *imageGenHTTPClient) GenerateImageFromReference(ctx context.Context, req
if strength <= 0 {
strength = 0.75
}
body = map[string]any{
"prompt": req.Prompt,
"image": pixels,
"strength": strength,
"num_steps": req.NumSteps,
}
} else {
b64 := base64.StdEncoding.EncodeToString(refImage)
body = map[string]any{
"prompt": req.Prompt,
"image_b64": b64,
"num_steps": req.NumSteps,
}
if req.Strength > 0 {
body["strength"] = req.Strength
}
fields["image"] = pixels
fields["strength"] = strength
return c.callImageAPI(ctx, req.Model, fields, nil)
}
if req.Width > 0 {
body["width"] = req.Width
// Other FLUX models: image_b64 JSON field.
fields["image_b64"] = base64.StdEncoding.EncodeToString(refImage)
if req.Strength > 0 {
fields["strength"] = req.Strength
}
if req.Height > 0 {
body["height"] = req.Height
}
if req.Guidance > 0 {
body["guidance"] = req.Guidance
}
return c.callImageAPI(ctx, req.Model, body)
return c.callImageAPI(ctx, req.Model, fields, nil)
}
// Models returns all supported image model metadata.
@@ -252,19 +294,56 @@ func (c *imageGenHTTPClient) Models() []ImageModelInfo {
return AllImageModels()
}
func (c *imageGenHTTPClient) callImageAPI(ctx context.Context, model ImageModel, body map[string]any) ([]byte, error) {
encoded, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("cfai/image: marshal: %w", err)
}
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/ai/run/%s",
func (c *imageGenHTTPClient) callImageAPI(ctx context.Context, model ImageModel, fields map[string]any, _ []byte) ([]byte, error) {
cfURL := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/ai/run/%s",
c.accountID, string(model))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(encoded))
var (
bodyReader io.Reader
contentType string
)
if requiresMultipart(model) {
// Build a multipart/form-data body from the fields map.
// All values are serialised to their string representation.
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
for k, v := range fields {
var strVal string
switch tv := v.(type) {
case string:
strVal = tv
default:
encoded, merr := json.Marshal(tv)
if merr != nil {
return nil, fmt.Errorf("cfai/image: marshal field %q: %w", k, merr)
}
strVal = strings.Trim(string(encoded), `"`)
}
if werr := mw.WriteField(k, strVal); werr != nil {
return nil, fmt.Errorf("cfai/image: write field %q: %w", k, werr)
}
}
if cerr := mw.Close(); cerr != nil {
return nil, fmt.Errorf("cfai/image: close multipart writer: %w", cerr)
}
bodyReader = &buf
contentType = mw.FormDataContentType()
} else {
encoded, merr := json.Marshal(fields)
if merr != nil {
return nil, fmt.Errorf("cfai/image: marshal: %w", merr)
}
bodyReader = bytes.NewReader(encoded)
contentType = "application/json"
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfURL, bodyReader)
if err != nil {
return nil, fmt.Errorf("cfai/image: build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.apiToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Content-Type", contentType)
resp, err := c.http.Do(req)
if err != nil {
@@ -272,20 +351,38 @@ func (c *imageGenHTTPClient) callImageAPI(ctx context.Context, model ImageModel,
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("cfai/image: read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
errBody, _ := io.ReadAll(resp.Body)
msg := string(errBody)
msg := string(respBody)
if len(msg) > 300 {
msg = msg[:300]
}
return nil, fmt.Errorf("cfai/image: model %s returned %d: %s", model, resp.StatusCode, msg)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("cfai/image: read response: %w", err)
// Try to parse as {"image": "<base64>"} first (FLUX.2 and newer models).
// Fall back to treating the body as raw image bytes for legacy models.
var jsonResp struct {
Image string `json:"image"`
}
return data, nil
if jerr := json.Unmarshal(respBody, &jsonResp); jerr == nil && jsonResp.Image != "" {
imgBytes, decErr := base64.StdEncoding.DecodeString(jsonResp.Image)
if decErr != nil {
// Try raw (no padding) base64
imgBytes, decErr = base64.RawStdEncoding.DecodeString(jsonResp.Image)
if decErr != nil {
return nil, fmt.Errorf("cfai/image: decode base64 response: %w", decErr)
}
}
return imgBytes, nil
}
// Legacy: model returned raw image bytes directly.
return respBody, nil
}
func applyImageDefaults(req ImageRequest) ImageRequest {

View File

@@ -92,6 +92,10 @@ type LibreTranslate struct {
type HTTP struct {
// Addr is the listen address, e.g. ":8080"
Addr string
// AdminToken is the bearer token required for all /api/admin/* endpoints.
// Set via BACKEND_ADMIN_TOKEN. When empty, admin endpoints are unprotected —
// only acceptable when the backend is unreachable from the public internet.
AdminToken string
}
// Meilisearch holds connection settings for the Meilisearch full-text search service.
@@ -123,6 +127,19 @@ type Redis struct {
Password string
}
// VAPID holds Web Push VAPID key pair for browser push notifications.
// Generate a pair once with: go run ./cmd/genkeys (or use the web-push CLI).
// The public key is exposed via GET /api/push-subscriptions/vapid-public-key
// and embedded in the SvelteKit app via PUBLIC_VAPID_PUBLIC_KEY.
type VAPID struct {
// PublicKey is the base64url-encoded VAPID public key (65 bytes, uncompressed EC P-256).
PublicKey string
// PrivateKey is the base64url-encoded VAPID private key (32 bytes).
PrivateKey string
// Subject is the mailto: or https: URL used as the VAPID subscriber contact.
Subject string
}
// Runner holds settings specific to the runner/worker binary.
type Runner struct {
// PollInterval is how often the runner checks PocketBase for pending tasks.
@@ -172,6 +189,7 @@ type Config struct {
Meilisearch Meilisearch
Valkey Valkey
Redis Redis
VAPID VAPID
// LogLevel is one of "debug", "info", "warn", "error".
LogLevel string
}
@@ -228,7 +246,8 @@ func Load() Config {
},
HTTP: HTTP{
Addr: envOr("BACKEND_HTTP_ADDR", ":8080"),
Addr: envOr("BACKEND_HTTP_ADDR", ":8080"),
AdminToken: envOr("BACKEND_ADMIN_TOKEN", ""),
},
Runner: Runner{
@@ -258,6 +277,12 @@ func Load() Config {
Addr: envOr("REDIS_ADDR", ""),
Password: envOr("REDIS_PASSWORD", ""),
},
VAPID: VAPID{
PublicKey: envOr("VAPID_PUBLIC_KEY", ""),
PrivateKey: envOr("VAPID_PRIVATE_KEY", ""),
Subject: envOr("VAPID_SUBJECT", "mailto:admin@libnovel.cc"),
},
}
}

View File

@@ -7,6 +7,12 @@ import "time"
// ── Book types ────────────────────────────────────────────────────────────────
// Visibility values for BookMeta.Visibility.
const (
VisibilityPublic = "public" // visible to all users
VisibilityAdminOnly = "admin_only" // visible only to admin users (e.g. scraped content)
)
// BookMeta carries all bibliographic information about a novel.
type BookMeta struct {
Slug string `json:"slug"`
@@ -24,6 +30,15 @@ type BookMeta struct {
// updated in PocketBase. Populated on read; not sent on write (PocketBase
// manages its own updated field).
MetaUpdated int64 `json:"meta_updated,omitempty"`
// Archived is true when the book has been soft-deleted by an admin.
// Archived books are excluded from all public search and catalogue responses.
Archived bool `json:"archived,omitempty"`
// Visibility controls who can see this book.
// "public" = all users; "admin_only" = admin only (default for scraped content).
Visibility string `json:"visibility,omitempty"`
// SubmittedBy is the app_users ID of the author who submitted this book,
// or empty for scraped books.
SubmittedBy string `json:"submitted_by,omitempty"`
}
// CatalogueEntry is a lightweight book reference returned by catalogue pages.
@@ -123,6 +138,8 @@ type ScrapeTask struct {
// ScrapeResult is the outcome reported by the runner after finishing a ScrapeTask.
type ScrapeResult struct {
// Slug is the book slug that was scraped. Empty for catalogue tasks.
Slug string `json:"slug,omitempty"`
BooksFound int `json:"books_found"`
ChaptersScraped int `json:"chapters_scraped"`
ChaptersSkipped int `json:"chapters_skipped"`
@@ -170,6 +187,37 @@ type TranslationResult struct {
ErrorMessage string `json:"error_message,omitempty"`
}
// ImportTask represents a PDF/EPUB import job stored in PocketBase.
type ImportTask struct {
ID string `json:"id"`
Slug string `json:"slug"` // derived from filename
Title string `json:"title"`
FileName string `json:"file_name"`
FileType string `json:"file_type"` // "pdf" or "epub"
ObjectKey string `json:"object_key,omitempty"` // MinIO path to uploaded file
ChaptersKey string `json:"chapters_key,omitempty"` // MinIO path to pre-parsed chapters JSON
Author string `json:"author,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
Genres []string `json:"genres,omitempty"`
Summary string `json:"summary,omitempty"`
BookStatus string `json:"book_status,omitempty"` // "ongoing" | "completed" | "hiatus"
WorkerID string `json:"worker_id,omitempty"`
InitiatorUserID string `json:"initiator_user_id,omitempty"` // PocketBase user ID who submitted the import
Status TaskStatus `json:"status"`
ChaptersDone int `json:"chapters_done"`
ChaptersTotal int `json:"chapters_total"`
ErrorMessage string `json:"error_message,omitempty"`
Started time.Time `json:"started"`
Finished time.Time `json:"finished,omitempty"`
}
// ImportResult is the outcome reported by the runner after finishing an ImportTask.
type ImportResult struct {
Slug string `json:"slug,omitempty"`
ChaptersImported int `json:"chapters_imported"`
ErrorMessage string `json:"error_message,omitempty"`
}
// AIJob represents an AI generation task tracked in PocketBase (ai_jobs collection).
type AIJob struct {
ID string `json:"id"`

View File

@@ -32,11 +32,15 @@ type Client interface {
// BookExists reports whether a book with the given slug is already in the
// index. Used by the catalogue refresh to skip re-indexing known books.
BookExists(ctx context.Context, slug string) bool
// DeleteBook removes a book document from the search index by slug.
DeleteBook(ctx context.Context, slug string) error
// Search returns up to limit books matching query.
// Archived books are always excluded.
Search(ctx context.Context, query string, limit int) ([]domain.BookMeta, error)
// Catalogue queries books with optional filters, sort, and pagination.
// Returns books, the total hit count for pagination, and a FacetResult
// with available genre and status values from the index.
// Archived books are always excluded.
Catalogue(ctx context.Context, q CatalogueQuery) ([]domain.BookMeta, int64, FacetResult, error)
}
@@ -48,6 +52,9 @@ type CatalogueQuery struct {
Sort string // sort field: "popular", "new", "update", "top-rated", "rank", ""
Page int // 1-indexed
Limit int // items per page, default 20
// AdminAll disables the visibility filter so admin users see all non-archived
// books including those marked admin_only.
AdminAll bool
}
// FacetResult holds the available filter values discovered from the index.
@@ -99,7 +106,7 @@ func Configure(host, apiKey string) error {
return fmt.Errorf("meili: update searchable attributes: %w", err)
}
filterable := []interface{}{"status", "genres"}
filterable := []interface{}{"status", "genres", "archived", "visibility"}
if _, err := idx.UpdateFilterableAttributes(&filterable); err != nil {
return fmt.Errorf("meili: update filterable attributes: %w", err)
}
@@ -128,6 +135,12 @@ type bookDoc struct {
// MetaUpdated is the Unix timestamp (seconds) of the last PocketBase update.
// Used for sort=update ("recently updated" ordering).
MetaUpdated int64 `json:"meta_updated"`
// Archived is true when the book has been soft-deleted by an admin.
// Used as a filter to exclude archived books from all search results.
Archived bool `json:"archived"`
// Visibility is "public" or "admin_only". Only public books are shown to
// non-admin users. Empty string is treated as admin_only for safety.
Visibility string `json:"visibility"`
}
func toDoc(b domain.BookMeta) bookDoc {
@@ -144,6 +157,8 @@ func toDoc(b domain.BookMeta) bookDoc {
Rank: b.Ranking,
Rating: b.Rating,
MetaUpdated: b.MetaUpdated,
Archived: b.Archived,
Visibility: b.Visibility,
}
}
@@ -161,6 +176,8 @@ func fromDoc(d bookDoc) domain.BookMeta {
Ranking: d.Rank,
Rating: d.Rating,
MetaUpdated: d.MetaUpdated,
Archived: d.Archived,
Visibility: d.Visibility,
}
}
@@ -184,13 +201,24 @@ func (c *MeiliClient) BookExists(_ context.Context, slug string) bool {
return err == nil && doc.Slug != ""
}
// DeleteBook removes a book document from the index by slug.
// The operation is fire-and-forget (Meilisearch processes tasks asynchronously).
func (c *MeiliClient) DeleteBook(_ context.Context, slug string) error {
if _, err := c.idx.DeleteDocument(slug, nil); err != nil {
return fmt.Errorf("meili: delete book %q: %w", slug, err)
}
return nil
}
// Search returns books matching query, up to limit results.
// Archived books are always excluded.
func (c *MeiliClient) Search(_ context.Context, query string, limit int) ([]domain.BookMeta, error) {
if limit <= 0 {
limit = 20
}
res, err := c.idx.Search(query, &meilisearch.SearchRequest{
Limit: int64(limit),
Limit: int64(limit),
Filter: `archived = false AND visibility = "public"`,
})
if err != nil {
return nil, fmt.Errorf("meili: search %q: %w", query, err)
@@ -231,17 +259,18 @@ func (c *MeiliClient) Catalogue(_ context.Context, q CatalogueQuery) ([]domain.B
Facets: []string{"genres", "status"},
}
// Build filter
var filters []string
// Build filter — always exclude archived books; restrict to public unless admin.
filters := []string{"archived = false"}
if !q.AdminAll {
filters = append(filters, `visibility = "public"`)
}
if q.Genre != "" && q.Genre != "all" {
filters = append(filters, fmt.Sprintf("genres = %q", q.Genre))
}
if q.Status != "" && q.Status != "all" {
filters = append(filters, fmt.Sprintf("status = %q", q.Status))
}
if len(filters) > 0 {
req.Filter = strings.Join(filters, " AND ")
}
req.Filter = strings.Join(filters, " AND ")
// Map UI sort tokens to Meilisearch sort expressions.
switch q.Sort {
@@ -318,7 +347,8 @@ func sortStrings(s []string) {
type NoopClient struct{}
func (NoopClient) UpsertBook(_ context.Context, _ domain.BookMeta) error { return nil }
func (NoopClient) BookExists(_ context.Context, _ string) bool { return false }
func (NoopClient) BookExists(_ context.Context, _ string) bool { return false }
func (NoopClient) DeleteBook(_ context.Context, _ string) error { return nil }
func (NoopClient) Search(_ context.Context, _ string, _ int) ([]domain.BookMeta, error) {
return nil, nil
}

View File

@@ -241,7 +241,7 @@ func (s *Scraper) ScrapeChapterList(ctx context.Context, bookURL string, upTo in
}
pageURL := fmt.Sprintf("%s?page=%d", baseChapterURL, page)
s.log.Info("scraping chapter list", "page", page, "url", pageURL)
s.log.Debug("scraping chapter list", "page", page, "url", pageURL)
raw, err := retryGet(ctx, s.log, s.client, pageURL, 9, 6*time.Second)
if err != nil {

View File

@@ -68,7 +68,7 @@ func New(cfg Config, novel scraper.NovelScraper, store bookstore.BookWriter, log
// Returns a ScrapeResult with counters. The result's ErrorMessage is non-empty
// if the run failed at the metadata or chapter-list level.
func (o *Orchestrator) RunBook(ctx context.Context, task domain.ScrapeTask) domain.ScrapeResult {
o.log.Info("orchestrator: RunBook starting",
o.log.Debug("orchestrator: RunBook starting",
"task_id", task.ID,
"kind", task.Kind,
"url", task.TargetURL,
@@ -90,6 +90,7 @@ func (o *Orchestrator) RunBook(ctx context.Context, task domain.ScrapeTask) doma
result.Errors++
return result
}
result.Slug = meta.Slug
if err := o.store.WriteMetadata(ctx, meta); err != nil {
o.log.Error("metadata write failed", "slug", meta.Slug, "err", err)
@@ -97,13 +98,14 @@ func (o *Orchestrator) RunBook(ctx context.Context, task domain.ScrapeTask) doma
result.Errors++
} else {
result.BooksFound = 1
result.Slug = meta.Slug
// Fire optional post-metadata hook (e.g. Meilisearch indexing).
if o.postMetadata != nil {
o.postMetadata(ctx, meta)
}
}
o.log.Info("metadata saved", "slug", meta.Slug, "title", meta.Title)
o.log.Debug("metadata saved", "slug", meta.Slug, "title", meta.Title)
// ── Step 2: Chapter list ──────────────────────────────────────────────────
refs, err := o.novel.ScrapeChapterList(ctx, task.TargetURL, task.ToChapter)
@@ -114,7 +116,7 @@ func (o *Orchestrator) RunBook(ctx context.Context, task domain.ScrapeTask) doma
return result
}
o.log.Info("chapter list fetched", "slug", meta.Slug, "chapters", len(refs))
o.log.Debug("chapter list fetched", "slug", meta.Slug, "chapters", len(refs))
// Persist chapter refs (without text) so the index exists early.
if wErr := o.store.WriteChapterRefs(ctx, meta.Slug, refs); wErr != nil {

View File

@@ -54,6 +54,7 @@ func (r *Runner) runAsynq(ctx context.Context) error {
mux.HandleFunc(asynqqueue.TypeAudioGenerate, r.handleAudioTask)
mux.HandleFunc(asynqqueue.TypeScrapeBook, r.handleScrapeTask)
mux.HandleFunc(asynqqueue.TypeScrapeCatalogue, r.handleScrapeTask)
mux.HandleFunc(asynqqueue.TypeImportBook, r.handleImportTask)
// Register Asynq queue metrics with the default Prometheus registry so
// the /metrics endpoint (metrics.go) can expose them.
@@ -191,6 +192,25 @@ func (r *Runner) handleAudioTask(ctx context.Context, t *asynq.Task) error {
return nil
}
// handleImportTask is the Asynq handler for TypeImportBook (PDF/EPUB import).
func (r *Runner) handleImportTask(ctx context.Context, t *asynq.Task) error {
var p asynqqueue.ImportPayload
if err := json.Unmarshal(t.Payload(), &p); err != nil {
return fmt.Errorf("unmarshal import payload: %w", err)
}
task := domain.ImportTask{
ID: p.PBTaskID,
Slug: p.Slug,
Title: p.Title,
FileType: p.FileType,
ChaptersKey: p.ChaptersKey,
}
r.tasksRunning.Add(1)
defer r.tasksRunning.Add(-1)
r.runImportTask(ctx, task, p.ObjectKey)
return nil
}
// pollTranslationTasks claims all available translation tasks from PocketBase
// and dispatches them to goroutines. Translation tasks don't go through Redis/Asynq
// because they're stored in PocketBase, so we need this separate poll loop.

View File

@@ -19,3 +19,53 @@ func stripMarkdown(src string) string {
src = regexp.MustCompile(`\n{3,}`).ReplaceAllString(src, "\n\n")
return strings.TrimSpace(src)
}
// chunkText splits text into chunks of at most maxChars characters, breaking
// at sentence boundaries (". ", "! ", "? ", "\n") so that the TTS service
// receives natural prose fragments rather than mid-sentence cuts.
//
// If a single sentence exceeds maxChars it is included as its own chunk —
// never silently truncated.
func chunkText(text string, maxChars int) []string {
if len(text) <= maxChars {
return []string{text}
}
// Sentence-boundary delimiters — we split AFTER these sequences.
// Order matters: longer sequences first.
delimiters := []string{".\n", "!\n", "?\n", ". ", "! ", "? ", "\n\n", "\n"}
var chunks []string
remaining := text
for len(remaining) > 0 {
if len(remaining) <= maxChars {
chunks = append(chunks, strings.TrimSpace(remaining))
break
}
// Find the last sentence boundary within the maxChars window.
window := remaining[:maxChars]
cutAt := -1
for _, delim := range delimiters {
idx := strings.LastIndex(window, delim)
if idx > 0 && idx+len(delim) > cutAt {
cutAt = idx + len(delim)
}
}
if cutAt <= 0 {
// No boundary found — hard-break at maxChars to avoid infinite loop.
cutAt = maxChars
}
chunk := strings.TrimSpace(remaining[:cutAt])
if chunk != "" {
chunks = append(chunks, chunk)
}
remaining = strings.TrimSpace(remaining[cutAt:])
}
return chunks
}

View File

@@ -15,6 +15,7 @@ package runner
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
@@ -35,10 +36,27 @@ import (
"github.com/libnovel/backend/internal/orchestrator"
"github.com/libnovel/backend/internal/pockettts"
"github.com/libnovel/backend/internal/scraper"
"github.com/libnovel/backend/internal/storage"
"github.com/libnovel/backend/internal/taskqueue"
"github.com/libnovel/backend/internal/webpush"
"github.com/prometheus/client_golang/prometheus"
)
// Notifier creates notifications for users.
type Notifier interface {
CreateNotification(ctx context.Context, userID, title, message, link string) error
}
// ChapterIngester persists imported chapters for a book.
type ChapterIngester interface {
IngestChapters(ctx context.Context, slug string, chapters []bookstore.Chapter) error
}
// ImportChapterStore retrieves pre-parsed chapter JSON blobs from object storage.
type ImportChapterStore interface {
GetImportChapters(ctx context.Context, key string) ([]byte, error)
}
// Config tunes the runner behaviour.
type Config struct {
// WorkerID uniquely identifies this runner instance in PocketBase records.
@@ -103,6 +121,23 @@ type Dependencies struct {
TranslationStore bookstore.TranslationStore
// CoverStore stores book cover images in MinIO.
CoverStore bookstore.CoverStore
// BookImport handles PDF/EPUB file parsing and chapter extraction.
// Kept for backward compatibility when ChaptersKey is not set.
BookImport bookstore.BookImporter
// ImportChapterStore retrieves pre-parsed chapter JSON blobs from MinIO.
// When set and the task has a ChaptersKey, the runner reads from here
// instead of calling BookImport.Import() (the new preferred path).
ImportChapterStore ImportChapterStore
// ChapterIngester persists extracted chapters into MinIO/PocketBase.
ChapterIngester ChapterIngester
// Notifier creates notifications for users.
Notifier Notifier
// WebPush sends browser push notifications to subscribed users.
// If nil, push notifications are disabled.
WebPush *webpush.Sender
// Store is the underlying *storage.Store; used for push subscription lookups.
// Only needed when WebPush is non-nil.
Store *storage.Store
// SearchIndex indexes books in Meilisearch after scraping.
// If nil a no-op is used.
SearchIndex meili.Client
@@ -225,6 +260,7 @@ func (r *Runner) runPoll(ctx context.Context) error {
scrapeSem := make(chan struct{}, r.cfg.MaxConcurrentScrape)
audioSem := make(chan struct{}, r.cfg.MaxConcurrentAudio)
translationSem := make(chan struct{}, r.cfg.MaxConcurrentTranslation)
importSem := make(chan struct{}, 1) // Limit concurrent imports
var wg sync.WaitGroup
tick := time.NewTicker(r.cfg.PollInterval)
@@ -244,7 +280,7 @@ func (r *Runner) runPoll(ctx context.Context) error {
// Run one poll immediately on startup, then on each tick.
for {
r.poll(ctx, scrapeSem, audioSem, translationSem, &wg)
r.poll(ctx, scrapeSem, audioSem, translationSem, importSem, &wg)
select {
case <-ctx.Done():
@@ -269,7 +305,7 @@ func (r *Runner) runPoll(ctx context.Context) error {
}
// poll claims all available pending tasks and dispatches them to goroutines.
func (r *Runner) poll(ctx context.Context, scrapeSem, audioSem, translationSem chan struct{}, wg *sync.WaitGroup) {
func (r *Runner) poll(ctx context.Context, scrapeSem, audioSem, translationSem, importSem chan struct{}, wg *sync.WaitGroup) {
// ── Heartbeat file ────────────────────────────────────────────────────
// Touch /tmp/runner.alive so the Docker health check can confirm the
// runner is actively polling. Failure is non-fatal — just log it.
@@ -385,6 +421,39 @@ translationLoop:
r.runTranslationTask(ctx, t)
}(task)
}
// ── Import tasks ─────────────────────────────────────────────────────
importLoop:
for {
if ctx.Err() != nil {
return
}
select {
case importSem <- struct{}{}:
// Slot acquired — proceed to claim a task.
default:
// All slots busy; leave remaining pending tasks for next tick.
break importLoop
}
task, ok, err := r.deps.Consumer.ClaimNextImportTask(ctx, r.cfg.WorkerID)
if err != nil {
<-importSem
r.deps.Log.Error("runner: ClaimNextImportTask failed", "err", err)
break
}
if !ok {
<-importSem
break
}
r.tasksRunning.Add(1)
wg.Add(1)
go func(t domain.ImportTask) {
defer wg.Done()
defer func() { <-importSem }()
defer r.tasksRunning.Add(-1)
r.runImportTask(ctx, t, t.ObjectKey)
}(task)
}
}
// newOrchestrator builds an orchestrator with the Meilisearch post-hook wired in.
@@ -444,16 +513,56 @@ func (r *Runner) runScrapeTask(ctx context.Context, task domain.ScrapeTask) {
log.Warn("runner: unknown task kind")
}
if err := r.deps.Consumer.FinishScrapeTask(ctx, task.ID, result); err != nil {
// Use a fresh context for the final write so a cancelled task context doesn't
// prevent the result counters from being persisted to PocketBase.
finishCtx, finishCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer finishCancel()
if err := r.deps.Consumer.FinishScrapeTask(finishCtx, task.ID, result); err != nil {
log.Error("runner: FinishScrapeTask failed", "err", err)
}
if result.ErrorMessage != "" {
r.tasksFailed.Add(1)
span.SetStatus(codes.Error, result.ErrorMessage)
if r.deps.Notifier != nil {
_ = r.deps.Notifier.CreateNotification(ctx, "admin",
"Scrape Failed",
fmt.Sprintf("Scrape task (%s) failed: %s", task.Kind, result.ErrorMessage),
"/admin/tasks")
}
} else {
r.tasksCompleted.Add(1)
span.SetStatus(codes.Ok, "")
if r.deps.Notifier != nil {
_ = r.deps.Notifier.CreateNotification(ctx, "admin",
"Scrape Complete",
fmt.Sprintf("Scraped %d chapters, skipped %d (%s)", result.ChaptersScraped, result.ChaptersSkipped, task.Kind),
"/admin/tasks")
}
// Fan-out in-app new-chapter notification to all users who have this book
// in their library. Runs in background so it doesn't block the task loop.
if r.deps.Store != nil && result.ChaptersScraped > 0 &&
result.Slug != "" && task.Kind != "catalogue" {
go func() {
notifyCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
title := result.Slug
_ = r.deps.Store.NotifyUsersWithBook(notifyCtx, result.Slug,
"New chapters available",
fmt.Sprintf("%d new chapter(s) added to %s", result.ChaptersScraped, title),
"/books/"+result.Slug)
}()
}
// Send Web Push notifications to subscribed browsers.
if r.deps.WebPush != nil && r.deps.Store != nil &&
result.ChaptersScraped > 0 && result.Slug != "" && task.Kind != "catalogue" {
go r.deps.WebPush.SendToBook(context.Background(), r.deps.Store, result.Slug, webpush.Payload{
Title: "New chapter available",
Body: fmt.Sprintf("%d new chapter(s) added", result.ChaptersScraped),
URL: "/books/" + result.Slug,
Icon: "/icon-192.png",
})
}
}
log.Info("runner: scrape task finished",
@@ -478,7 +587,7 @@ func (r *Runner) runCatalogueTask(ctx context.Context, task domain.ScrapeTask, o
TargetURL: entry.URL,
}
bookResult := o.RunBook(ctx, bookTask)
result.BooksFound += bookResult.BooksFound + 1
result.BooksFound += bookResult.BooksFound
result.ChaptersScraped += bookResult.ChaptersScraped
result.ChaptersSkipped += bookResult.ChaptersSkipped
result.Errors += bookResult.Errors
@@ -533,6 +642,12 @@ func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) {
if err := r.deps.Consumer.FinishAudioTask(ctx, task.ID, result); err != nil {
log.Error("runner: FinishAudioTask failed", "err", err)
}
if r.deps.Notifier != nil {
_ = r.deps.Notifier.CreateNotification(ctx, "admin",
"Audio Failed",
fmt.Sprintf("Ch.%d of %s (%s): %s", task.Chapter, task.Slug, task.Voice, msg),
fmt.Sprintf("/books/%s", task.Slug))
}
}
raw, err := r.deps.BookReader.ReadChapter(ctx, task.Slug, task.Chapter)
@@ -577,7 +692,7 @@ func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) {
return
}
var genErr error
audioData, genErr = r.deps.Kokoro.GenerateAudio(ctx, text, task.Voice)
audioData, genErr = kokoroGenerateChunked(ctx, r.deps.Kokoro, text, task.Voice, log)
if genErr != nil {
fail(fmt.Sprintf("kokoro generate: %v", genErr))
return
@@ -597,5 +712,173 @@ func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) {
if err := r.deps.Consumer.FinishAudioTask(ctx, task.ID, result); err != nil {
log.Error("runner: FinishAudioTask failed", "err", err)
}
if r.deps.Notifier != nil {
_ = r.deps.Notifier.CreateNotification(ctx, "admin",
"Audio Ready",
fmt.Sprintf("Ch.%d of %s (%s) is ready", task.Chapter, task.Slug, task.Voice),
fmt.Sprintf("/books/%s", task.Slug))
}
log.Info("runner: audio task finished", "key", key)
}
// kokoroGenerateChunked splits text into ~1 000-character sentence-boundary
// chunks, calls Kokoro.GenerateAudio for each, and concatenates the raw MP3
// bytes. This avoids EOF / timeout failures that occur when the Kokoro
// FastAPI server receives very large inputs (e.g. a full imported PDF chapter).
//
// Concatenating raw MP3 frames is valid — MP3 is a frame-based format and
// standard players handle multi-segment files correctly.
func kokoroGenerateChunked(ctx context.Context, k kokoro.Client, text, voice string, log *slog.Logger) ([]byte, error) {
const chunkSize = 1000
chunks := chunkText(text, chunkSize)
log.Info("runner: kokoro chunked generation", "chunks", len(chunks), "total_chars", len(text))
var combined []byte
for i, chunk := range chunks {
data, err := k.GenerateAudio(ctx, chunk, voice)
if err != nil {
return nil, fmt.Errorf("chunk %d/%d: %w", i+1, len(chunks), err)
}
combined = append(combined, data...)
log.Info("runner: kokoro chunk done", "chunk", i+1, "of", len(chunks), "bytes", len(data))
}
return combined, nil
}
// runImportTask executes one PDF/EPUB import task.
// Preferred path: when task.ChaptersKey is set, it reads pre-parsed chapters
// JSON from MinIO (written by the backend at upload time) and ingests them.
// Fallback path: when ChaptersKey is empty, calls BookImport.Import() to
// parse the raw file on the runner (legacy behaviour, not used for new tasks).
func (r *Runner) runImportTask(ctx context.Context, task domain.ImportTask, objectKey string) {
ctx, span := otel.Tracer("runner").Start(ctx, "runner.import_task")
defer span.End()
span.SetAttributes(
attribute.String("task.id", task.ID),
attribute.String("book.slug", task.Slug),
attribute.String("file.type", task.FileType),
attribute.String("chapters_key", task.ChaptersKey),
)
log := r.deps.Log.With("task_id", task.ID, "slug", task.Slug, "file_type", task.FileType)
log.Info("runner: import task starting", "chapters_key", task.ChaptersKey)
hbCtx, hbCancel := context.WithCancel(ctx)
defer hbCancel()
go func() {
tick := time.NewTicker(r.cfg.HeartbeatInterval)
defer tick.Stop()
for {
select {
case <-hbCtx.Done():
return
case <-tick.C:
if err := r.deps.Consumer.HeartbeatTask(ctx, task.ID); err != nil {
log.Warn("runner: heartbeat failed", "err", err)
}
}
}
}()
fail := func(msg string) {
log.Error("runner: import task failed", "reason", msg)
r.tasksFailed.Add(1)
span.SetStatus(codes.Error, msg)
result := domain.ImportResult{ErrorMessage: msg}
if err := r.deps.Consumer.FinishImportTask(ctx, task.ID, result); err != nil {
log.Error("runner: FinishImportTask failed", "err", err)
}
}
var chapters []bookstore.Chapter
if task.ChaptersKey != "" && r.deps.ImportChapterStore != nil {
// New path: read pre-parsed chapters JSON uploaded by the backend.
raw, err := r.deps.ImportChapterStore.GetImportChapters(ctx, task.ChaptersKey)
if err != nil {
fail(fmt.Sprintf("get chapters JSON: %v", err))
return
}
if err := json.Unmarshal(raw, &chapters); err != nil {
fail(fmt.Sprintf("unmarshal chapters JSON: %v", err))
return
}
log.Info("runner: loaded pre-parsed chapters", "count", len(chapters))
} else {
// Legacy path: parse the raw file on the runner.
if r.deps.BookImport == nil {
fail("book import not configured (BookImport dependency missing)")
return
}
var err error
chapters, err = r.deps.BookImport.Import(ctx, objectKey, task.FileType)
if err != nil {
fail(fmt.Sprintf("import file: %v", err))
return
}
log.Info("runner: parsed chapters from file (legacy path)", "count", len(chapters))
}
if len(chapters) == 0 {
fail("no chapters extracted from file")
return
}
// Persist chapters via ChapterIngester.
if r.deps.ChapterIngester == nil {
fail("chapter ingester not configured")
return
}
if err := r.deps.ChapterIngester.IngestChapters(ctx, task.Slug, chapters); err != nil {
fail(fmt.Sprintf("store chapters: %v", err))
return
}
// Write book metadata so the book appears in PocketBase catalogue.
if r.deps.BookWriter != nil {
meta := domain.BookMeta{
Slug: task.Slug,
Title: task.Title,
Author: task.Author,
Cover: task.CoverURL,
Status: task.BookStatus,
Genres: task.Genres,
Summary: task.Summary,
TotalChapters: len(chapters),
}
if meta.Status == "" {
meta.Status = "completed"
}
if err := r.deps.BookWriter.WriteMetadata(ctx, meta); err != nil {
log.Warn("runner: import task WriteMetadata failed (non-fatal)", "err", err)
} else {
// Index in Meilisearch so the book is searchable.
if err := r.deps.SearchIndex.UpsertBook(ctx, meta); err != nil {
log.Warn("runner: import task meilisearch upsert failed (non-fatal)", "err", err)
}
}
}
r.tasksCompleted.Add(1)
span.SetStatus(codes.Ok, "")
result := domain.ImportResult{
Slug: task.Slug,
ChaptersImported: len(chapters),
}
if err := r.deps.Consumer.FinishImportTask(ctx, task.ID, result); err != nil {
log.Error("runner: FinishImportTask failed", "err", err)
}
// Notify the user who initiated the import.
if r.deps.Notifier != nil {
msg := fmt.Sprintf("Import completed: %d chapters from %s", len(chapters), task.Title)
targetUser := task.InitiatorUserID
if targetUser == "" {
targetUser = "admin"
}
_ = r.deps.Notifier.CreateNotification(ctx, targetUser, "Import Complete", msg, "/admin/import")
}
log.Info("runner: import task finished", "chapters", len(chapters))
}

View File

@@ -54,6 +54,10 @@ func (s *stubConsumer) ClaimNextTranslationTask(_ context.Context, _ string) (do
return domain.TranslationTask{}, false, nil
}
func (s *stubConsumer) ClaimNextImportTask(_ context.Context, _ string) (domain.ImportTask, bool, error) {
return domain.ImportTask{}, false, nil
}
func (s *stubConsumer) FinishScrapeTask(_ context.Context, id string, _ domain.ScrapeResult) error {
s.finished = append(s.finished, id)
return nil
@@ -69,6 +73,11 @@ func (s *stubConsumer) FinishTranslationTask(_ context.Context, id string, _ dom
return nil
}
func (s *stubConsumer) FinishImportTask(_ context.Context, id string, _ domain.ImportResult) error {
s.finished = append(s.finished, id)
return nil
}
func (s *stubConsumer) FailTask(_ context.Context, id, _ string) error {
s.failCalled = append(s.failCalled, id)
return nil

View File

@@ -53,6 +53,12 @@ func (r *Runner) runTranslationTask(ctx context.Context, task domain.Translation
if err := r.deps.Consumer.FinishTranslationTask(ctx, task.ID, result); err != nil {
log.Error("runner: FinishTranslationTask failed", "err", err)
}
if r.deps.Notifier != nil {
_ = r.deps.Notifier.CreateNotification(ctx, "admin",
"Translation Failed",
fmt.Sprintf("Ch.%d of %s (%s): %s", task.Chapter, task.Slug, task.Lang, msg),
fmt.Sprintf("/books/%s", task.Slug))
}
}
// Guard: LibreTranslate must be configured.
@@ -93,5 +99,11 @@ func (r *Runner) runTranslationTask(ctx context.Context, task domain.Translation
if err := r.deps.Consumer.FinishTranslationTask(ctx, task.ID, result); err != nil {
log.Error("runner: FinishTranslationTask failed", "err", err)
}
if r.deps.Notifier != nil {
_ = r.deps.Notifier.CreateNotification(ctx, "admin",
"Translation Ready",
fmt.Sprintf("Ch.%d of %s translated to %s", task.Chapter, task.Slug, task.Lang),
fmt.Sprintf("/books/%s", task.Slug))
}
log.Info("runner: translation task finished", "key", key)
}

View File

@@ -0,0 +1,857 @@
package storage
import (
"archive/zip"
"bytes"
"context"
"fmt"
"io"
"os"
"sort"
"strconv"
"strings"
"github.com/libnovel/backend/internal/bookstore"
"github.com/libnovel/backend/internal/domain"
minio "github.com/minio/minio-go/v7"
"github.com/pdfcpu/pdfcpu/pkg/api"
"github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model"
"golang.org/x/net/html"
)
type importer struct {
mc *minioClient
}
// NewBookImporter creates a BookImporter that reads files from MinIO.
func NewBookImporter(s *Store) bookstore.BookImporter {
return &importer{mc: s.mc}
}
func (i *importer) Import(ctx context.Context, objectKey, fileType string) ([]bookstore.Chapter, error) {
if fileType != "pdf" && fileType != "epub" {
return nil, fmt.Errorf("unsupported file type: %s", fileType)
}
obj, err := i.mc.client.GetObject(ctx, "imports", objectKey, minio.GetObjectOptions{})
if err != nil {
return nil, fmt.Errorf("get object from minio: %w", err)
}
defer obj.Close()
data, err := io.ReadAll(obj)
if err != nil {
return nil, fmt.Errorf("read object: %w", err)
}
if fileType == "pdf" {
return parsePDF(data)
}
return parseEPUB(data)
}
// AnalyzeFile parses the given PDF or EPUB data and returns the detected
// chapter count and up to 3 preview lines (first non-empty line of each of
// the first 3 chapters). It is used by the analyze-only endpoint so users
// can preview chapter count before committing the import.
// Note: uses parsePDF which is backed by pdfcpu ExtractContent — fast, no hang risk.
func AnalyzeFile(data []byte, fileType string) (chapterCount int, firstLines []string, err error) {
var chapters []bookstore.Chapter
switch fileType {
case "pdf":
chapters, err = parsePDF(data)
case "epub":
chapters, err = parseEPUB(data)
default:
return 0, nil, fmt.Errorf("unsupported file type: %s", fileType)
}
if err != nil {
return 0, nil, err
}
chapterCount = len(chapters)
for i, ch := range chapters {
if i >= 3 {
break
}
line := strings.TrimSpace(ch.Content)
if nl := strings.Index(line, "\n"); nl > 0 {
line = line[:nl]
}
if len(line) > 120 {
line = line[:120] + "…"
}
firstLines = append(firstLines, line)
}
return chapterCount, firstLines, nil
}
// decryptPDF strips encryption from a PDF using an empty user password.
// Returns the decrypted bytes, or an error if decryption is not possible.
// This handles the common case of "owner-only" encrypted PDFs (copy/print
// restrictions) which use an empty user password and open normally in readers.
func decryptPDF(data []byte) ([]byte, error) {
conf := model.NewDefaultConfiguration()
conf.UserPW = ""
conf.OwnerPW = ""
var out bytes.Buffer
err := api.Decrypt(bytes.NewReader(data), &out, conf)
if err != nil {
return nil, err
}
return out.Bytes(), nil
}
// ParseImportFile parses a PDF or EPUB and returns chapters.
// Unlike AnalyzeFile it respects ctx cancellation so callers can apply a timeout.
// For PDFs it first attempts to strip encryption with an empty password.
func ParseImportFile(ctx context.Context, data []byte, fileType string) ([]bookstore.Chapter, error) {
type result struct {
chapters []bookstore.Chapter
err error
}
ch := make(chan result, 1)
go func() {
var chapters []bookstore.Chapter
var err error
switch fileType {
case "pdf":
chapters, err = parsePDF(data)
case "epub":
chapters, err = parseEPUB(data)
default:
err = fmt.Errorf("unsupported file type: %s", fileType)
}
ch <- result{chapters, err}
}()
select {
case <-ctx.Done():
return nil, fmt.Errorf("parse timed out: %w", ctx.Err())
case r := <-ch:
return r.chapters, r.err
}
}
// pdfSkipBookmarks lists bookmark titles that are front/back matter, not story chapters.
// These are skipped when building the chapter list.
var pdfSkipBookmarks = map[string]bool{
"cover": true, "insert": true, "title page": true, "copyright": true,
"appendix": true, "color insert": true, "color illustrations": true,
}
// parsePDF extracts text from PDF bytes and returns it as a single chapter.
//
// The full readable text is returned as one chapter so the admin can manually
// split it into chapters via the UI using --- markers.
//
// Strategy:
// 1. Decrypt owner-protected PDFs (empty user password).
// 2. Extract raw content streams for every page using pdfcpu ExtractContent.
// 3. Concatenate text from all pages in order, skipping front matter
// (cover, title page, copyright — typically the first 10 pages).
func parsePDF(data []byte) ([]bookstore.Chapter, error) {
// Decrypt owner-protected PDFs (empty user password).
decrypted, err := decryptPDF(data)
if err == nil {
data = decrypted
}
conf := model.NewDefaultConfiguration()
conf.UserPW = ""
conf.OwnerPW = ""
// Extract all page content streams to a temp directory.
tmpDir, err := os.MkdirTemp("", "pdf-extract-*")
if err != nil {
return nil, fmt.Errorf("create temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)
if err := api.ExtractContent(bytes.NewReader(data), tmpDir, "out", nil, conf); err != nil {
return nil, fmt.Errorf("extract PDF content: %w", err)
}
entries, err := os.ReadDir(tmpDir)
if err != nil || len(entries) == 0 {
return nil, fmt.Errorf("PDF has no content pages")
}
// Parse page number from filename and build ordered text map.
pageTexts := make(map[int]string, len(entries))
maxPage := 0
for _, e := range entries {
pageNum := pageNumFromFilename(e.Name())
if pageNum <= 0 {
continue
}
raw, readErr := os.ReadFile(tmpDir + "/" + e.Name())
if readErr != nil {
continue
}
pageTexts[pageNum] = fixWin1252(extractTextFromContentStream(raw))
if pageNum > maxPage {
maxPage = pageNum
}
}
// Determine front-matter cutoff using bookmarks if available,
// otherwise skip the first 10 pages (cover/title/copyright).
bodyStart := 1
bookmarks, bmErr := api.Bookmarks(bytes.NewReader(data), conf)
if bmErr == nil {
for _, bm := range bookmarks {
title := strings.ToLower(strings.TrimSpace(bm.Title))
if !pdfSkipBookmarks[title] && bm.PageFrom > 0 {
// First non-front-matter bookmark — body starts here.
bodyStart = bm.PageFrom
break
}
}
} else if maxPage > 10 {
bodyStart = 11
}
// Concatenate all body pages.
var sb strings.Builder
for p := bodyStart; p <= maxPage; p++ {
t := strings.TrimSpace(pageTexts[p])
if t == "" {
continue
}
sb.WriteString(t)
sb.WriteString("\n\n")
}
text := strings.TrimSpace(sb.String())
if text == "" {
return nil, fmt.Errorf("could not extract any text from PDF")
}
return []bookstore.Chapter{{
Number: 1,
Title: "Full Text",
Content: text,
}}, nil
}
// pageNumFromFilename extracts the page number from a pdfcpu content-stream
// filename like "out_Content_page_42.txt". Returns 0 if not parseable.
func pageNumFromFilename(name string) int {
// Strip directory prefix and extension.
base := name
if idx := strings.LastIndex(base, "/"); idx >= 0 {
base = base[idx+1:]
}
if idx := strings.LastIndex(base, "."); idx >= 0 {
base = base[:idx]
}
// Find last "_" and parse the number after it.
if idx := strings.LastIndex(base, "_"); idx >= 0 {
n, err := strconv.Atoi(base[idx+1:])
if err == nil && n > 0 {
return n
}
}
return 0
}
// win1252ToUnicode maps the Windows-1252 control range 0x800x9F to the
// Unicode characters they actually represent in that encoding.
// Standard Latin-1 maps these bytes to control characters; Win-1252 maps
// them to typographic symbols that appear in publisher PDFs.
var win1252ToUnicode = map[byte]rune{
0x80: '\u20AC', // €
0x82: '\u201A', //
0x83: '\u0192', // ƒ
0x84: '\u201E', // „
0x85: '\u2026', // …
0x86: '\u2020', // †
0x87: '\u2021', // ‡
0x88: '\u02C6', // ˆ
0x89: '\u2030', // ‰
0x8A: '\u0160', // Š
0x8B: '\u2039', //
0x8C: '\u0152', // Œ
0x8E: '\u017D', // Ž
0x91: '\u2018', // ' (left single quotation mark)
0x92: '\u2019', // ' (right single quotation mark / apostrophe)
0x93: '\u201C', // " (left double quotation mark)
0x94: '\u201D', // " (right double quotation mark)
0x95: '\u2022', // • (bullet)
0x96: '\u2013', // (en dash)
0x97: '\u2014', // — (em dash)
0x98: '\u02DC', // ˜
0x99: '\u2122', // ™
0x9A: '\u0161', // š
0x9B: '\u203A', //
0x9C: '\u0153', // œ
0x9E: '\u017E', // ž
0x9F: '\u0178', // Ÿ
}
// fixWin1252 replaces Windows-1252 specific bytes (0x800x9F) in a string
// that was decoded as raw Latin-1 bytes with their proper Unicode equivalents.
func fixWin1252(s string) string {
// Fast path: if no bytes in 0x800x9F range, return unchanged.
needsFix := false
for i := 0; i < len(s); i++ {
b := s[i]
if b >= 0x80 && b <= 0x9F {
needsFix = true
break
}
}
if !needsFix {
return s
}
var sb strings.Builder
sb.Grow(len(s))
for i := 0; i < len(s); i++ {
b := s[i]
if b >= 0x80 && b <= 0x9F {
if r, ok := win1252ToUnicode[b]; ok {
sb.WriteRune(r)
continue
}
}
sb.WriteByte(b)
}
return sb.String()
}
// extractTextFromContentStream parses a raw PDF content stream and extracts
// readable text from Tj and TJ operators.
//
// TJ arrays may contain a mix of literal strings (parenthesised) and hex glyph
// arrays. Only the literal strings are decoded — hex arrays require per-font
// ToUnicode CMaps and are skipped. Kerning adjustment numbers inside TJ arrays
// are also ignored (they're just spacing hints).
//
// Line breaks are inserted on ET / Td / TD / T* operators.
func extractTextFromContentStream(stream []byte) string {
s := string(stream)
var sb strings.Builder
i := 0
n := len(s)
for i < n {
// TJ array: [ ... ]TJ — collect all literal strings, skip hex & numbers.
if s[i] == '[' {
j := i + 1
for j < n && s[j] != ']' {
if s[j] == '(' {
// Literal string inside TJ array.
k := j + 1
depth := 1
for k < n && depth > 0 {
if s[k] == '\\' {
k += 2
continue
}
if s[k] == '(' {
depth++
} else if s[k] == ')' {
depth--
}
k++
}
lit := pdfUnescapeString(s[j+1 : k-1])
if hasPrintableASCII(lit) {
sb.WriteString(lit)
}
j = k
continue
}
j++
}
// Check if this is a TJ operator (skip whitespace after ']').
end := j + 1
for end < n && (s[end] == ' ' || s[end] == '\t' || s[end] == '\r' || s[end] == '\n') {
end++
}
if end+2 <= n && s[end:end+2] == "TJ" && (end+2 == n || !isAlphaNum(s[end+2])) {
i = end + 2
continue
}
i = j + 1
continue
}
// Single string: (string) Tj
if s[i] == '(' {
j := i + 1
depth := 1
for j < n && depth > 0 {
if s[j] == '\\' {
j += 2
continue
}
if s[j] == '(' {
depth++
} else if s[j] == ')' {
depth--
}
j++
}
lit := pdfUnescapeString(s[i+1 : j-1])
if hasPrintableASCII(lit) {
// Check for Tj operator.
end := j
for end < n && (s[end] == ' ' || s[end] == '\t') {
end++
}
if end+2 <= n && s[end:end+2] == "Tj" && (end+2 == n || !isAlphaNum(s[end+2])) {
sb.WriteString(lit)
i = end + 2
continue
}
}
i = j
continue
}
// Detect end of text object (ET) — add a newline.
if i+2 <= n && s[i:i+2] == "ET" && (i+2 == n || !isAlphaNum(s[i+2])) {
sb.WriteByte('\n')
i += 2
continue
}
// Detect Td / TD / T* — newline within text block.
if i+2 <= n && (s[i:i+2] == "Td" || s[i:i+2] == "TD" || s[i:i+2] == "T*") &&
(i+2 == n || !isAlphaNum(s[i+2])) {
sb.WriteByte('\n')
i += 2
continue
}
i++
}
return sb.String()
}
func isAlphaNum(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_'
}
func hasPrintableASCII(s string) bool {
for _, c := range s {
if c >= 0x20 && c < 0x7F {
return true
}
}
return false
}
// pdfUnescapeString handles PDF string escape sequences.
func pdfUnescapeString(s string) string {
if !strings.ContainsRune(s, '\\') {
return s
}
var sb strings.Builder
i := 0
for i < len(s) {
if s[i] == '\\' && i+1 < len(s) {
switch s[i+1] {
case 'n':
sb.WriteByte('\n')
case 'r':
sb.WriteByte('\r')
case 't':
sb.WriteByte('\t')
case '(', ')', '\\':
sb.WriteByte(s[i+1])
default:
// Octal escape \ddd
if s[i+1] >= '0' && s[i+1] <= '7' {
end := i + 2
for end < i+5 && end < len(s) && s[end] >= '0' && s[end] <= '7' {
end++
}
val, _ := strconv.ParseInt(s[i+1:end], 8, 16)
sb.WriteByte(byte(val))
i = end
continue
}
sb.WriteByte(s[i+1])
}
i += 2
} else {
sb.WriteByte(s[i])
i++
}
}
return sb.String()
}
// ── EPUB parsing ──────────────────────────────────────────────────────────────
func parseEPUB(data []byte) ([]bookstore.Chapter, error) {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, fmt.Errorf("open EPUB zip: %w", err)
}
// 1. Read META-INF/container.xml → find rootfile (content.opf path).
opfPath, err := epubRootfilePath(zr)
if err != nil {
return nil, fmt.Errorf("epub container: %w", err)
}
// 2. Parse content.opf → spine order of chapter files.
spineFiles, titleMap, err := epubSpine(zr, opfPath)
if err != nil {
return nil, fmt.Errorf("epub spine: %w", err)
}
if len(spineFiles) == 0 {
return nil, fmt.Errorf("EPUB spine is empty")
}
// Base directory of the OPF file for resolving relative hrefs.
opfDir := ""
if idx := strings.LastIndex(opfPath, "/"); idx >= 0 {
opfDir = opfPath[:idx+1]
}
var chapters []bookstore.Chapter
chNum := 0
for i, href := range spineFiles {
fullPath := opfDir + href
content, err := epubFileContent(zr, fullPath)
if err != nil {
continue
}
text := htmlToText(content)
if strings.TrimSpace(text) == "" {
continue
}
chNum++
title := titleMap[href]
if title == "" {
title = fmt.Sprintf("Chapter %d", chNum)
}
_ = i // spine index unused for numbering
chapters = append(chapters, bookstore.Chapter{
Number: chNum,
Title: title,
Content: text,
})
}
if len(chapters) == 0 {
return nil, fmt.Errorf("no readable chapters found in EPUB")
}
return chapters, nil
}
// epubRootfilePath parses META-INF/container.xml and returns the full-path
// of the OPF package document.
func epubRootfilePath(zr *zip.Reader) (string, error) {
f := zipFile(zr, "META-INF/container.xml")
if f == nil {
return "", fmt.Errorf("META-INF/container.xml not found")
}
rc, err := f.Open()
if err != nil {
return "", err
}
defer rc.Close()
doc, err := html.Parse(rc)
if err != nil {
return "", err
}
var path string
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.ElementNode && strings.EqualFold(n.Data, "rootfile") {
for _, a := range n.Attr {
if strings.EqualFold(a.Key, "full-path") {
path = a.Val
return
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(doc)
if path == "" {
return "", fmt.Errorf("rootfile full-path not found in container.xml")
}
return path, nil
}
// epubSpine parses the OPF document and returns the spine item hrefs in order,
// plus a map from href → nav title (if available from NCX/NAV).
func epubSpine(zr *zip.Reader, opfPath string) ([]string, map[string]string, error) {
f := zipFile(zr, opfPath)
if f == nil {
return nil, nil, fmt.Errorf("OPF file %q not found in EPUB", opfPath)
}
rc, err := f.Open()
if err != nil {
return nil, nil, err
}
defer rc.Close()
opfData, err := io.ReadAll(rc)
if err != nil {
return nil, nil, err
}
// Build id→href map from <manifest>.
idToHref := make(map[string]string)
// Also keep a href→navTitle map (populated from NCX later).
hrefTitle := make(map[string]string)
// Parse OPF XML with html.Parse (handles malformed XML too).
doc, _ := html.Parse(bytes.NewReader(opfData))
var manifestItems []struct{ id, href, mediaType string }
var spineIdrefs []string
var ncxID string
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.ElementNode {
tag := strings.ToLower(n.Data)
switch tag {
case "item":
var id, href, mt string
for _, a := range n.Attr {
switch strings.ToLower(a.Key) {
case "id":
id = a.Val
case "href":
href = a.Val
case "media-type":
mt = a.Val
}
}
if id != "" && href != "" {
manifestItems = append(manifestItems, struct{ id, href, mediaType string }{id, href, mt})
idToHref[id] = href
}
case "itemref":
for _, a := range n.Attr {
if strings.ToLower(a.Key) == "idref" {
spineIdrefs = append(spineIdrefs, a.Val)
}
}
case "spine":
for _, a := range n.Attr {
if strings.ToLower(a.Key) == "toc" {
ncxID = a.Val
}
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(doc)
// Build ordered spine href list.
var spineHrefs []string
for _, idref := range spineIdrefs {
if href, ok := idToHref[idref]; ok {
spineHrefs = append(spineHrefs, href)
}
}
// If no explicit spine, fall back to all XHTML items in manifest order.
if len(spineHrefs) == 0 {
sort.Slice(manifestItems, func(i, j int) bool {
return manifestItems[i].href < manifestItems[j].href
})
for _, it := range manifestItems {
mt := strings.ToLower(it.mediaType)
if strings.Contains(mt, "html") || strings.HasSuffix(strings.ToLower(it.href), ".html") || strings.HasSuffix(strings.ToLower(it.href), ".xhtml") {
spineHrefs = append(spineHrefs, it.href)
}
}
}
// Try to get chapter titles from NCX (toc.ncx).
opfDir := ""
if idx := strings.LastIndex(opfPath, "/"); idx >= 0 {
opfDir = opfPath[:idx+1]
}
if ncxHref, ok := idToHref[ncxID]; ok {
ncxPath := opfDir + ncxHref
if ncxFile := zipFile(zr, ncxPath); ncxFile != nil {
if ncxRC, err := ncxFile.Open(); err == nil {
defer ncxRC.Close()
parseNCXTitles(ncxRC, hrefTitle)
}
}
}
return spineHrefs, hrefTitle, nil
}
// parseNCXTitles extracts navPoint label→src mappings from a toc.ncx.
func parseNCXTitles(r io.Reader, out map[string]string) {
doc, err := html.Parse(r)
if err != nil {
return
}
// Collect navPoints: each has a <navLabel><text>…</text></navLabel> and
// a <content src="…"/> child.
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.ElementNode && strings.EqualFold(n.Data, "navpoint") {
var label, src string
var inner func(*html.Node)
inner = func(c *html.Node) {
if c.Type == html.ElementNode {
if strings.EqualFold(c.Data, "text") && label == "" {
if c.FirstChild != nil && c.FirstChild.Type == html.TextNode {
label = strings.TrimSpace(c.FirstChild.Data)
}
}
if strings.EqualFold(c.Data, "content") {
for _, a := range c.Attr {
if strings.EqualFold(a.Key, "src") {
// Strip fragment identifier (#...).
src = strings.SplitN(a.Val, "#", 2)[0]
}
}
}
}
for child := c.FirstChild; child != nil; child = child.NextSibling {
inner(child)
}
}
inner(n)
if label != "" && src != "" {
out[src] = label
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(doc)
}
// epubFileContent returns the raw bytes of a file inside the EPUB zip.
func epubFileContent(zr *zip.Reader, path string) ([]byte, error) {
f := zipFile(zr, path)
if f == nil {
return nil, fmt.Errorf("file %q not in EPUB", path)
}
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
return io.ReadAll(rc)
}
// zipFile finds a file by name (case-insensitive) in a zip.Reader.
func zipFile(zr *zip.Reader, name string) *zip.File {
nameLower := strings.ToLower(name)
for _, f := range zr.File {
if strings.ToLower(f.Name) == nameLower {
return f
}
}
return nil
}
// htmlToText converts HTML/XHTML content to plain text suitable for storage.
func htmlToText(data []byte) string {
doc, err := html.Parse(bytes.NewReader(data))
if err != nil {
return string(data)
}
var sb strings.Builder
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.TextNode {
text := strings.TrimSpace(n.Data)
if text != "" {
sb.WriteString(text)
sb.WriteByte(' ')
}
}
if n.Type == html.ElementNode {
switch strings.ToLower(n.Data) {
case "p", "div", "br", "h1", "h2", "h3", "h4", "h5", "h6", "li", "tr":
// Block-level: ensure newline before content.
if sb.Len() > 0 {
s := sb.String()
if s[len(s)-1] != '\n' {
sb.WriteByte('\n')
}
}
case "script", "style", "head":
// Skip entirely.
return
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
if n.Type == html.ElementNode {
switch strings.ToLower(n.Data) {
case "p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "tr":
sb.WriteByte('\n')
}
}
}
walk(doc)
// Collapse multiple blank lines.
lines := strings.Split(sb.String(), "\n")
var out []string
blanks := 0
for _, l := range lines {
l = strings.TrimSpace(l)
if l == "" {
blanks++
if blanks <= 1 {
out = append(out, "")
}
} else {
blanks = 0
out = append(out, l)
}
}
return strings.TrimSpace(strings.Join(out, "\n"))
}
// ── Chapter ingestion ─────────────────────────────────────────────────────────
// IngestChapters stores extracted chapters for a book.
// Each chapter is written as a markdown file in the chapters MinIO bucket
// and its index record is upserted in PocketBase via WriteChapter.
func (s *Store) IngestChapters(ctx context.Context, slug string, chapters []bookstore.Chapter) error {
for _, ch := range chapters {
var mdContent string
if ch.Title != "" && ch.Title != fmt.Sprintf("Chapter %d", ch.Number) {
mdContent = fmt.Sprintf("# %s\n\n%s", ch.Title, ch.Content)
} else {
mdContent = fmt.Sprintf("# Chapter %d\n\n%s", ch.Number, ch.Content)
}
domainCh := domain.Chapter{
Ref: domain.ChapterRef{Number: ch.Number, Title: ch.Title},
Text: mdContent,
}
if err := s.WriteChapter(ctx, slug, domainCh); err != nil {
return fmt.Errorf("ingest chapter %d: %w", ch.Number, err)
}
}
return nil
}
// GetImportObjectKey returns the MinIO object key for an uploaded import file.
func GetImportObjectKey(filename string) string {
return fmt.Sprintf("imports/%s", filename)
}

View File

@@ -55,6 +55,7 @@ var _ bookstore.CoverStore = (*Store)(nil)
var _ bookstore.TranslationStore = (*Store)(nil)
var _ bookstore.AIJobStore = (*Store)(nil)
var _ bookstore.ChapterImageStore = (*Store)(nil)
var _ bookstore.BookAdminStore = (*Store)(nil)
var _ taskqueue.Producer = (*Store)(nil)
var _ taskqueue.Consumer = (*Store)(nil)
var _ taskqueue.Reader = (*Store)(nil)
@@ -62,7 +63,8 @@ var _ taskqueue.Reader = (*Store)(nil)
// ── BookWriter ────────────────────────────────────────────────────────────────
func (s *Store) WriteMetadata(ctx context.Context, meta domain.BookMeta) error {
payload := map[string]any{
// patchPayload does NOT include visibility or submitted_by — preserve existing values.
patchPayload := map[string]any{
"slug": meta.Slug,
"title": meta.Title,
"author": meta.Author,
@@ -84,7 +86,13 @@ func (s *Store) WriteMetadata(ctx context.Context, meta domain.BookMeta) error {
return fmt.Errorf("WriteMetadata: %w", err)
}
if err == ErrNotFound {
postErr := s.pb.post(ctx, "/api/collections/books/records", payload, nil)
// New scraped book — default to admin_only visibility.
postPayload := make(map[string]any, len(patchPayload)+1)
for k, v := range patchPayload {
postPayload[k] = v
}
postPayload["visibility"] = domain.VisibilityAdminOnly
postErr := s.pb.post(ctx, "/api/collections/books/records", postPayload, nil)
if postErr == nil {
return nil
}
@@ -95,7 +103,28 @@ func (s *Store) WriteMetadata(ctx context.Context, meta domain.BookMeta) error {
return postErr // original POST error is more informative
}
}
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", existing.ID), payload)
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", existing.ID), patchPayload)
}
// CreateSubmittedBook creates a new author-submitted book with visibility=public.
// Unlike WriteMetadata this always POSTs (no upsert) and sets the submitted_by field.
func (s *Store) CreateSubmittedBook(ctx context.Context, meta domain.BookMeta) error {
payload := map[string]any{
"slug": meta.Slug,
"title": meta.Title,
"author": meta.Author,
"cover": meta.Cover,
"status": meta.Status,
"genres": meta.Genres,
"summary": meta.Summary,
"total_chapters": 0,
"source_url": "",
"ranking": 0,
"rating": 0,
"visibility": domain.VisibilityPublic,
"submitted_by": meta.SubmittedBy,
}
return s.pb.post(ctx, "/api/collections/books/records", payload, nil)
}
func (s *Store) WriteChapter(ctx context.Context, slug string, chapter domain.Chapter) error {
@@ -226,6 +255,9 @@ type pbBook struct {
Ranking int `json:"ranking"`
Rating float64 `json:"rating"`
Updated string `json:"updated"`
Archived bool `json:"archived"`
Visibility string `json:"visibility"`
SubmittedBy string `json:"submitted_by"`
}
func (b pbBook) toDomain() domain.BookMeta {
@@ -246,6 +278,9 @@ func (b pbBook) toDomain() domain.BookMeta {
Ranking: b.Ranking,
Rating: b.Rating,
MetaUpdated: metaUpdated,
Archived: b.Archived,
Visibility: b.Visibility,
SubmittedBy: b.SubmittedBy,
}
}
@@ -275,7 +310,7 @@ func (s *Store) ReadMetadata(ctx context.Context, slug string) (domain.BookMeta,
}
func (s *Store) ListBooks(ctx context.Context) ([]domain.BookMeta, error) {
items, err := s.pb.listAll(ctx, "books", "", "title")
items, err := s.pb.listAll(ctx, "books", "archived=false", "title")
if err != nil {
return nil, err
}
@@ -376,6 +411,110 @@ func (s *Store) ReindexChapters(ctx context.Context, slug string) (int, error) {
return count, nil
}
// ── BookAdminStore ────────────────────────────────────────────────────────────
// ArchiveBook sets archived=true on the book record for slug.
func (s *Store) ArchiveBook(ctx context.Context, slug string) error {
book, err := s.getBookBySlug(ctx, slug)
if err == ErrNotFound {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("ArchiveBook: %w", err)
}
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", book.ID),
map[string]any{"archived": true})
}
// UnarchiveBook clears archived on the book record for slug.
func (s *Store) UnarchiveBook(ctx context.Context, slug string) error {
book, err := s.getBookBySlug(ctx, slug)
if err == ErrNotFound {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("UnarchiveBook: %w", err)
}
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", book.ID),
map[string]any{"archived": false})
}
// PublishBook sets visibility=public on the book record for slug.
func (s *Store) PublishBook(ctx context.Context, slug string) error {
book, err := s.getBookBySlug(ctx, slug)
if err == ErrNotFound {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("PublishBook: %w", err)
}
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", book.ID),
map[string]any{"visibility": domain.VisibilityPublic})
}
// UnpublishBook sets visibility=admin_only on the book record for slug.
func (s *Store) UnpublishBook(ctx context.Context, slug string) error {
book, err := s.getBookBySlug(ctx, slug)
if err == ErrNotFound {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("UnpublishBook: %w", err)
}
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", book.ID),
map[string]any{"visibility": domain.VisibilityAdminOnly})
}
// DeleteBook permanently removes all data for a book:
// - PocketBase books record
// - All PocketBase chapters_idx records for the slug
// - All MinIO chapter markdown objects ({slug}/chapter-*.md)
// - MinIO cover image (covers/{slug}.jpg)
func (s *Store) DeleteBook(ctx context.Context, slug string) error {
// 1. Fetch the book record to get its PocketBase ID.
book, err := s.getBookBySlug(ctx, slug)
if err == ErrNotFound {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("DeleteBook: fetch: %w", err)
}
// 2. Delete all chapters_idx records.
filter := fmt.Sprintf(`slug=%q`, slug)
items, err := s.pb.listAll(ctx, "chapters_idx", filter, "")
if err != nil && err != ErrNotFound {
return fmt.Errorf("DeleteBook: list chapters_idx: %w", err)
}
for _, raw := range items {
var rec struct {
ID string `json:"id"`
}
if json.Unmarshal(raw, &rec) == nil && rec.ID != "" {
if delErr := s.pb.delete(ctx, fmt.Sprintf("/api/collections/chapters_idx/records/%s", rec.ID)); delErr != nil {
s.log.Warn("DeleteBook: delete chapters_idx record failed", "slug", slug, "id", rec.ID, "err", delErr)
}
}
}
// 3. Delete MinIO chapter objects.
if err := s.mc.deleteObjects(ctx, s.mc.bucketChapters, slug+"/"); err != nil {
s.log.Warn("DeleteBook: delete chapter objects failed", "slug", slug, "err", err)
}
// 4. Delete MinIO cover image.
if err := s.mc.deleteObjects(ctx, s.mc.bucketBrowse, CoverObjectKey(slug)); err != nil {
s.log.Warn("DeleteBook: delete cover failed", "slug", slug, "err", err)
}
// 5. Delete the PocketBase books record.
if err := s.pb.delete(ctx, fmt.Sprintf("/api/collections/books/records/%s", book.ID)); err != nil {
return fmt.Errorf("DeleteBook: delete books record: %w", err)
}
return nil
}
// ── RankingStore ──────────────────────────────────────────────────────────────
func (s *Store) WriteRankingItem(ctx context.Context, item domain.RankingItem) error {
@@ -647,6 +786,118 @@ func (s *Store) CreateTranslationTask(ctx context.Context, slug string, chapter
return rec.ID, nil
}
func (s *Store) CreateImportTask(ctx context.Context, task domain.ImportTask) (string, error) {
payload := map[string]any{
"slug": task.Slug,
"title": task.Title,
"file_name": task.Slug + "." + task.FileType,
"file_type": task.FileType,
"object_key": task.ObjectKey,
"chapters_key": task.ChaptersKey,
"author": task.Author,
"cover_url": task.CoverURL,
"summary": task.Summary,
"book_status": task.BookStatus,
"status": string(domain.TaskStatusPending),
"chapters_done": 0,
"chapters_total": task.ChaptersTotal,
"started": time.Now().UTC().Format(time.RFC3339),
"initiator_user_id": task.InitiatorUserID,
}
if len(task.Genres) > 0 {
payload["genres"] = strings.Join(task.Genres, ",")
}
var rec struct {
ID string `json:"id"`
}
if err := s.pb.post(ctx, "/api/collections/import_tasks/records", payload, &rec); err != nil {
return "", err
}
return rec.ID, nil
}
// CreateNotification creates a notification record in PocketBase.
func (s *Store) CreateNotification(ctx context.Context, userID, title, message, link string) error {
payload := map[string]any{
"user_id": userID,
"title": title,
"message": message,
"link": link,
"read": false,
"created": time.Now().UTC().Format(time.RFC3339),
}
return s.pb.post(ctx, "/api/collections/notifications/records", payload, nil)
}
// ListNotifications returns notifications for a user.
func (s *Store) ListNotifications(ctx context.Context, userID string, limit int) ([]map[string]any, error) {
filter := fmt.Sprintf(`user_id=%q`, userID)
items, err := s.pb.listAll(ctx, "notifications", filter, "-created")
if err != nil {
return nil, err
}
// Parse each json.RawMessage into a map
results := make([]map[string]any, 0, len(items))
for _, raw := range items {
var m map[string]any
if json.Unmarshal(raw, &m) == nil {
results = append(results, m)
}
}
if limit > 0 && len(results) > limit {
results = results[:limit]
}
return results, nil
}
// MarkNotificationRead marks a notification as read.
func (s *Store) MarkNotificationRead(ctx context.Context, id string) error {
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/notifications/records/%s", id),
map[string]any{"read": true})
}
// DeleteNotification deletes a single notification by ID.
func (s *Store) DeleteNotification(ctx context.Context, id string) error {
return s.pb.delete(ctx, fmt.Sprintf("/api/collections/notifications/records/%s", id))
}
// ClearAllNotifications deletes all notifications for a user.
func (s *Store) ClearAllNotifications(ctx context.Context, userID string) error {
filter := fmt.Sprintf(`user_id=%q`, userID)
items, err := s.pb.listAll(ctx, "notifications", filter, "")
if err != nil {
return fmt.Errorf("ClearAllNotifications list: %w", err)
}
for _, raw := range items {
var rec struct {
ID string `json:"id"`
}
if json.Unmarshal(raw, &rec) == nil && rec.ID != "" {
_ = s.pb.delete(ctx, fmt.Sprintf("/api/collections/notifications/records/%s", rec.ID))
}
}
return nil
}
// MarkAllNotificationsRead marks all notifications for a user as read.
func (s *Store) MarkAllNotificationsRead(ctx context.Context, userID string) error {
filter := fmt.Sprintf(`user_id=%q&&read=false`, userID)
items, err := s.pb.listAll(ctx, "notifications", filter, "")
if err != nil {
return fmt.Errorf("MarkAllNotificationsRead list: %w", err)
}
for _, raw := range items {
var rec struct {
ID string `json:"id"`
}
if json.Unmarshal(raw, &rec) == nil && rec.ID != "" {
_ = s.pb.patch(ctx, fmt.Sprintf("/api/collections/notifications/records/%s", rec.ID),
map[string]any{"read": true})
}
}
return nil
}
func (s *Store) CancelTask(ctx context.Context, id string) error {
// Try scraping_tasks first, then audio_jobs, then translation_jobs.
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id),
@@ -721,6 +972,18 @@ func (s *Store) ClaimNextTranslationTask(ctx context.Context, workerID string) (
return task, err == nil, err
}
func (s *Store) ClaimNextImportTask(ctx context.Context, workerID string) (domain.ImportTask, bool, error) {
raw, err := s.pb.claimRecord(ctx, "import_tasks", workerID, nil)
if err != nil {
return domain.ImportTask{}, false, err
}
if raw == nil {
return domain.ImportTask{}, false, nil
}
task, err := parseImportTask(raw)
return task, err == nil, err
}
func (s *Store) FinishScrapeTask(ctx context.Context, id string, result domain.ScrapeResult) error {
status := string(domain.TaskStatusDone)
if result.ErrorMessage != "" {
@@ -761,6 +1024,20 @@ func (s *Store) FinishTranslationTask(ctx context.Context, id string, result dom
})
}
func (s *Store) FinishImportTask(ctx context.Context, id string, result domain.ImportResult) error {
status := string(domain.TaskStatusDone)
if result.ErrorMessage != "" {
status = string(domain.TaskStatusFailed)
}
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/import_tasks/records/%s", id), map[string]any{
"status": status,
"chapters_done": result.ChaptersImported,
"chapters_total": result.ChaptersImported,
"error_message": result.ErrorMessage,
"finished": time.Now().UTC().Format(time.RFC3339),
})
}
func (s *Store) FailTask(ctx context.Context, id, errMsg string) error {
payload := map[string]any{
"status": string(domain.TaskStatusFailed),
@@ -777,7 +1054,7 @@ func (s *Store) FailTask(ctx context.Context, id, errMsg string) error {
}
// HeartbeatTask updates the heartbeat_at field on a running task.
// Tries scraping_tasks first, then audio_jobs, then translation_jobs.
// Tries scraping_tasks, audio_jobs, translation_jobs, then import_tasks.
func (s *Store) HeartbeatTask(ctx context.Context, id string) error {
payload := map[string]any{
"heartbeat_at": time.Now().UTC().Format(time.RFC3339),
@@ -788,7 +1065,10 @@ func (s *Store) HeartbeatTask(ctx context.Context, id string) error {
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/audio_jobs/records/%s", id), payload); err == nil {
return nil
}
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/translation_jobs/records/%s", id), payload)
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/translation_jobs/records/%s", id), payload); err == nil {
return nil
}
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/import_tasks/records/%s", id), payload)
}
// ReapStaleTasks finds all running tasks whose heartbeat_at is either missing
@@ -806,7 +1086,7 @@ func (s *Store) ReapStaleTasks(ctx context.Context, staleAfter time.Duration) (i
}
total := 0
for _, collection := range []string{"scraping_tasks", "audio_jobs", "translation_jobs"} {
for _, collection := range []string{"scraping_tasks", "audio_jobs", "translation_jobs", "import_tasks"} {
items, err := s.pb.listAll(ctx, collection, filter, "")
if err != nil {
return total, fmt.Errorf("ReapStaleTasks list %s: %w", collection, err)
@@ -899,8 +1179,7 @@ func (s *Store) ListTranslationTasks(ctx context.Context) ([]domain.TranslationT
}
func (s *Store) GetTranslationTask(ctx context.Context, cacheKey string) (domain.TranslationTask, bool, error) {
filter := fmt.Sprintf(`cache_key='%s'`, cacheKey)
items, err := s.pb.listAll(ctx, "translation_jobs", filter, "-started")
items, err := s.pb.listAll(ctx, "translation_jobs", fmt.Sprintf("cache_key=%q", cacheKey), "-started")
if err != nil || len(items) == 0 {
return domain.TranslationTask{}, false, err
}
@@ -908,6 +1187,33 @@ func (s *Store) GetTranslationTask(ctx context.Context, cacheKey string) (domain
return t, err == nil, err
}
func (s *Store) ListImportTasks(ctx context.Context) ([]domain.ImportTask, error) {
items, err := s.pb.listAll(ctx, "import_tasks", "", "-started")
if err != nil {
return nil, err
}
tasks := make([]domain.ImportTask, 0, len(items))
for _, raw := range items {
t, err := parseImportTask(raw)
if err == nil {
tasks = append(tasks, t)
}
}
return tasks, nil
}
func (s *Store) GetImportTask(ctx context.Context, id string) (domain.ImportTask, bool, error) {
var raw json.RawMessage
if err := s.pb.get(ctx, fmt.Sprintf("/api/collections/import_tasks/records/%s", id), &raw); err != nil {
if err == ErrNotFound {
return domain.ImportTask{}, false, nil
}
return domain.ImportTask{}, false, err
}
t, err := parseImportTask(raw)
return t, err == nil, err
}
// ── Parsers ───────────────────────────────────────────────────────────────────
func parseScrapeTask(raw json.RawMessage) (domain.ScrapeTask, error) {
@@ -1014,6 +1320,66 @@ func parseTranslationTask(raw json.RawMessage) (domain.TranslationTask, error) {
}, nil
}
func parseImportTask(raw json.RawMessage) (domain.ImportTask, error) {
var rec struct {
ID string `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
FileName string `json:"file_name"`
FileType string `json:"file_type"`
ObjectKey string `json:"object_key"`
ChaptersKey string `json:"chapters_key"`
Author string `json:"author"`
CoverURL string `json:"cover_url"`
Genres string `json:"genres"` // stored as comma-separated
Summary string `json:"summary"`
BookStatus string `json:"book_status"`
WorkerID string `json:"worker_id"`
InitiatorUserID string `json:"initiator_user_id"`
Status string `json:"status"`
ChaptersDone int `json:"chapters_done"`
ChaptersTotal int `json:"chapters_total"`
ErrorMessage string `json:"error_message"`
Started string `json:"started"`
Finished string `json:"finished"`
}
if err := json.Unmarshal(raw, &rec); err != nil {
return domain.ImportTask{}, err
}
started, _ := time.Parse(time.RFC3339, rec.Started)
finished, _ := time.Parse(time.RFC3339, rec.Finished)
var genres []string
if rec.Genres != "" {
for _, g := range strings.Split(rec.Genres, ",") {
if g = strings.TrimSpace(g); g != "" {
genres = append(genres, g)
}
}
}
return domain.ImportTask{
ID: rec.ID,
Slug: rec.Slug,
Title: rec.Title,
FileName: rec.FileName,
FileType: rec.FileType,
ObjectKey: rec.ObjectKey,
ChaptersKey: rec.ChaptersKey,
Author: rec.Author,
CoverURL: rec.CoverURL,
Genres: genres,
Summary: rec.Summary,
BookStatus: rec.BookStatus,
WorkerID: rec.WorkerID,
InitiatorUserID: rec.InitiatorUserID,
Status: domain.TaskStatus(rec.Status),
ChaptersDone: rec.ChaptersDone,
ChaptersTotal: rec.ChaptersTotal,
ErrorMessage: rec.ErrorMessage,
Started: started,
Finished: finished,
}, nil
}
// ── CoverStore ─────────────────────────────────────────────────────────────────
func (s *Store) PutCover(ctx context.Context, slug string, data []byte, contentType string) error {
@@ -1040,6 +1406,25 @@ func (s *Store) GetCover(ctx context.Context, slug string) ([]byte, string, bool
return data, ct, true, nil
}
// PutImportFile stores an uploaded import file (PDF/EPUB) in MinIO.
func (s *Store) PutImportFile(ctx context.Context, key string, data []byte) error {
return s.mc.putObject(ctx, "imports", key, "application/octet-stream", data)
}
// PutImportChapters stores a pre-parsed chapters JSON blob in MinIO.
func (s *Store) PutImportChapters(ctx context.Context, key string, data []byte) error {
return s.mc.putObject(ctx, "imports", key, "application/json", data)
}
// GetImportChapters retrieves the pre-parsed chapters JSON from MinIO.
func (s *Store) GetImportChapters(ctx context.Context, key string) ([]byte, error) {
data, err := s.mc.getObject(ctx, "imports", key)
if err != nil {
return nil, fmt.Errorf("get chapters object: %w", err)
}
return data, nil
}
func (s *Store) CoverExists(ctx context.Context, slug string) bool {
return s.mc.coverExists(ctx, CoverObjectKey(slug))
}
@@ -1199,3 +1584,169 @@ func parseAIJob(raw json.RawMessage) (domain.AIJob, error) {
HeartbeatAt: parseT(r.HeartbeatAt),
}, nil
}
// ── Push subscriptions ────────────────────────────────────────────────────────
// PushSubscription holds the Web Push subscription data for a single browser.
type PushSubscription struct {
ID string
UserID string
Endpoint string
P256DH string
Auth string
}
// SavePushSubscription upserts a Web Push subscription for a user.
// If a record with the same endpoint already exists it is updated in place.
func (s *Store) SavePushSubscription(ctx context.Context, sub PushSubscription) error {
filter := fmt.Sprintf("endpoint=%q", sub.Endpoint)
existing, err := s.pb.listAll(ctx, "push_subscriptions", filter, "")
if err != nil {
return fmt.Errorf("SavePushSubscription list: %w", err)
}
payload := map[string]any{
"user_id": sub.UserID,
"endpoint": sub.Endpoint,
"p256dh": sub.P256DH,
"auth": sub.Auth,
}
if len(existing) > 0 {
var rec struct {
ID string `json:"id"`
}
if json.Unmarshal(existing[0], &rec) == nil && rec.ID != "" {
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/push_subscriptions/records/%s", rec.ID), payload)
}
}
return s.pb.post(ctx, "/api/collections/push_subscriptions/records", payload, nil)
}
// DeletePushSubscription removes a Web Push subscription by endpoint.
func (s *Store) DeletePushSubscription(ctx context.Context, userID, endpoint string) error {
filter := fmt.Sprintf("user_id=%q&&endpoint=%q", userID, endpoint)
items, err := s.pb.listAll(ctx, "push_subscriptions", filter, "")
if err != nil {
return fmt.Errorf("DeletePushSubscription list: %w", err)
}
for _, raw := range items {
var rec struct {
ID string `json:"id"`
}
if json.Unmarshal(raw, &rec) == nil && rec.ID != "" {
_ = s.pb.delete(ctx, fmt.Sprintf("/api/collections/push_subscriptions/records/%s", rec.ID))
}
}
return nil
}
// ListPushSubscriptionsByBook returns all push subscriptions belonging to users
// who have the given book slug in their library (user_library collection).
func (s *Store) ListPushSubscriptionsByBook(ctx context.Context, slug string) ([]PushSubscription, error) {
// Find all users who have this book in their library
libFilter := fmt.Sprintf("slug=%q&&user_id!=''", slug)
libItems, err := s.pb.listAll(ctx, "user_library", libFilter, "")
if err != nil {
return nil, fmt.Errorf("ListPushSubscriptionsByBook list library: %w", err)
}
// Collect unique user IDs
seen := make(map[string]bool)
var userIDs []string
for _, raw := range libItems {
var rec struct {
UserID string `json:"user_id"`
}
if json.Unmarshal(raw, &rec) == nil && rec.UserID != "" && !seen[rec.UserID] {
seen[rec.UserID] = true
userIDs = append(userIDs, rec.UserID)
}
}
if len(userIDs) == 0 {
return nil, nil
}
// Build OR filter for push_subscriptions
parts := make([]string, len(userIDs))
for i, uid := range userIDs {
parts[i] = fmt.Sprintf("user_id=%q", uid)
}
subFilter := strings.Join(parts, "||")
subItems, err := s.pb.listAll(ctx, "push_subscriptions", subFilter, "")
if err != nil {
return nil, fmt.Errorf("ListPushSubscriptionsByBook list subs: %w", err)
}
subs := make([]PushSubscription, 0, len(subItems))
for _, raw := range subItems {
var rec struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Endpoint string `json:"endpoint"`
P256DH string `json:"p256dh"`
Auth string `json:"auth"`
}
if json.Unmarshal(raw, &rec) == nil && rec.Endpoint != "" {
subs = append(subs, PushSubscription{
ID: rec.ID,
UserID: rec.UserID,
Endpoint: rec.Endpoint,
P256DH: rec.P256DH,
Auth: rec.Auth,
})
}
}
return subs, nil
}
// NotifyUsersWithBook creates an in-app notification for every logged-in user
// who has slug in their library. Errors for individual users are logged but
// do not abort the loop. Returns the number of notifications created.
func (s *Store) NotifyUsersWithBook(ctx context.Context, slug, title, message, link string) int {
userIDs, err := s.ListUserIDsWithBook(ctx, slug)
if err != nil || len(userIDs) == 0 {
return 0
}
var n int
for _, uid := range userIDs {
if createErr := s.CreateNotification(ctx, uid, title, message, link); createErr == nil {
n++
}
}
return n
}
// who have slug in their user_library. Used to fan-out new-chapter notifications.
// Admin users and users who have opted out of in-app new-chapter notifications
// (notify_new_chapters=false on app_users) are excluded.
func (s *Store) ListUserIDsWithBook(ctx context.Context, slug string) ([]string, error) {
// Collect user IDs to skip: admins + opted-out users.
skipIDs := make(map[string]bool)
excludedItems, err := s.pb.listAll(ctx, "app_users", `role="admin"||notify_new_chapters=false`, "")
if err == nil {
for _, raw := range excludedItems {
var rec struct {
ID string `json:"id"`
}
if json.Unmarshal(raw, &rec) == nil && rec.ID != "" {
skipIDs[rec.ID] = true
}
}
}
filter := fmt.Sprintf("slug=%q&&user_id!=''", slug)
items, err := s.pb.listAll(ctx, "user_library", filter, "")
if err != nil {
return nil, fmt.Errorf("ListUserIDsWithBook: %w", err)
}
seen := make(map[string]bool)
var ids []string
for _, raw := range items {
var rec struct {
UserID string `json:"user_id"`
}
if json.Unmarshal(raw, &rec) == nil && rec.UserID != "" && !seen[rec.UserID] && !skipIDs[rec.UserID] {
seen[rec.UserID] = true
ids = append(ids, rec.UserID)
}
}
return ids, nil
}

View File

@@ -33,6 +33,11 @@ type Producer interface {
// returns the assigned PocketBase record ID.
CreateTranslationTask(ctx context.Context, slug string, chapter int, lang string) (string, error)
// CreateImportTask inserts a new import task with status=pending and
// returns the assigned PocketBase record ID.
// The task struct must have at minimum Slug, Title, FileType, and ObjectKey set.
CreateImportTask(ctx context.Context, task domain.ImportTask) (string, error)
// CancelTask transitions a pending task to status=cancelled.
// Returns ErrNotFound if the task does not exist.
CancelTask(ctx context.Context, id string) error
@@ -59,6 +64,11 @@ type Consumer interface {
// Returns (zero, false, nil) when the queue is empty.
ClaimNextTranslationTask(ctx context.Context, workerID string) (domain.TranslationTask, bool, error)
// ClaimNextImportTask atomically finds the oldest pending import task,
// sets its status=running and worker_id=workerID, and returns it.
// Returns (zero, false, nil) when the queue is empty.
ClaimNextImportTask(ctx context.Context, workerID string) (domain.ImportTask, bool, error)
// FinishScrapeTask marks a running scrape task as done and records the result.
FinishScrapeTask(ctx context.Context, id string, result domain.ScrapeResult) error
@@ -68,6 +78,9 @@ type Consumer interface {
// FinishTranslationTask marks a running translation task as done and records the result.
FinishTranslationTask(ctx context.Context, id string, result domain.TranslationResult) error
// FinishImportTask marks a running import task as done and records the result.
FinishImportTask(ctx context.Context, id string, result domain.ImportResult) error
// FailTask marks a task (scrape, audio, or translation) as failed with an error message.
FailTask(ctx context.Context, id, errMsg string) error
@@ -104,4 +117,11 @@ type Reader interface {
// GetTranslationTask returns the most recent translation task for cacheKey.
// Returns (zero, false, nil) if not found.
GetTranslationTask(ctx context.Context, cacheKey string) (domain.TranslationTask, bool, error)
// ListImportTasks returns all import tasks sorted by started descending.
ListImportTasks(ctx context.Context) ([]domain.ImportTask, error)
// GetImportTask returns a single import task by ID.
// Returns (zero, false, nil) if not found.
GetImportTask(ctx context.Context, id string) (domain.ImportTask, bool, error)
}

View File

@@ -26,6 +26,9 @@ func (s *stubStore) CreateAudioTask(_ context.Context, _ string, _ int, _ string
func (s *stubStore) CreateTranslationTask(_ context.Context, _ string, _ int, _ string) (string, error) {
return "translation-1", nil
}
func (s *stubStore) CreateImportTask(_ context.Context, _ domain.ImportTask) (string, error) {
return "import-1", nil
}
func (s *stubStore) CancelTask(_ context.Context, _ string) error { return nil }
func (s *stubStore) CancelAudioTasksBySlug(_ context.Context, _ string) (int, error) { return 0, nil }
@@ -38,6 +41,9 @@ func (s *stubStore) ClaimNextAudioTask(_ context.Context, _ string) (domain.Audi
func (s *stubStore) ClaimNextTranslationTask(_ context.Context, _ string) (domain.TranslationTask, bool, error) {
return domain.TranslationTask{ID: "translation-1", Status: domain.TaskStatusRunning}, true, nil
}
func (s *stubStore) ClaimNextImportTask(_ context.Context, _ string) (domain.ImportTask, bool, error) {
return domain.ImportTask{ID: "import-1", Status: domain.TaskStatusRunning}, true, nil
}
func (s *stubStore) FinishScrapeTask(_ context.Context, _ string, _ domain.ScrapeResult) error {
return nil
}
@@ -47,6 +53,9 @@ func (s *stubStore) FinishAudioTask(_ context.Context, _ string, _ domain.AudioR
func (s *stubStore) FinishTranslationTask(_ context.Context, _ string, _ domain.TranslationResult) error {
return nil
}
func (s *stubStore) FinishImportTask(_ context.Context, _ string, _ domain.ImportResult) error {
return nil
}
func (s *stubStore) FailTask(_ context.Context, _, _ string) error { return nil }
func (s *stubStore) HeartbeatTask(_ context.Context, _ string) error { return nil }
@@ -69,6 +78,10 @@ func (s *stubStore) ListTranslationTasks(_ context.Context) ([]domain.Translatio
func (s *stubStore) GetTranslationTask(_ context.Context, _ string) (domain.TranslationTask, bool, error) {
return domain.TranslationTask{}, false, nil
}
func (s *stubStore) ListImportTasks(_ context.Context) ([]domain.ImportTask, error) { return nil, nil }
func (s *stubStore) GetImportTask(_ context.Context, _ string) (domain.ImportTask, bool, error) {
return domain.ImportTask{}, false, nil
}
// Verify the stub satisfies all three interfaces at compile time.
var _ taskqueue.Producer = (*stubStore)(nil)

View File

@@ -0,0 +1,147 @@
// Package webpush sends Web Push notifications using the VAPID protocol.
package webpush
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"sync"
webpushgo "github.com/SherClockHolmes/webpush-go"
"github.com/libnovel/backend/internal/storage"
)
// Payload is the JSON body delivered to the browser service worker.
type Payload struct {
Title string `json:"title"`
Body string `json:"body"`
URL string `json:"url,omitempty"`
Icon string `json:"icon,omitempty"`
}
// Sender sends Web Push notifications to subscribed browsers.
type Sender struct {
vapidPublic string
vapidPrivate string
subject string
log *slog.Logger
}
// New returns a Sender configured with the given VAPID key pair.
// subject should be a mailto: or https: contact URL per the VAPID spec.
func New(vapidPublic, vapidPrivate, subject string, log *slog.Logger) *Sender {
if log == nil {
log = slog.Default()
}
return &Sender{
vapidPublic: vapidPublic,
vapidPrivate: vapidPrivate,
subject: subject,
log: log,
}
}
// Enabled returns true when VAPID keys are configured.
func (s *Sender) Enabled() bool {
return s.vapidPublic != "" && s.vapidPrivate != ""
}
// Send delivers payload to all provided subscriptions concurrently.
// Errors for individual subscriptions are logged but do not abort other sends.
// Returns the number of successful sends.
func (s *Sender) Send(ctx context.Context, subs []storage.PushSubscription, p Payload) int {
if !s.Enabled() || len(subs) == 0 {
return 0
}
body, err := json.Marshal(p)
if err != nil {
s.log.Error("webpush: marshal payload", "err", err)
return 0
}
var (
wg sync.WaitGroup
mu sync.Mutex
success int
)
for _, sub := range subs {
sub := sub
wg.Add(1)
go func() {
defer wg.Done()
resp, err := webpushgo.SendNotificationWithContext(ctx, body, &webpushgo.Subscription{
Endpoint: sub.Endpoint,
Keys: webpushgo.Keys{
P256dh: sub.P256DH,
Auth: sub.Auth,
},
}, &webpushgo.Options{
VAPIDPublicKey: s.vapidPublic,
VAPIDPrivateKey: s.vapidPrivate,
Subscriber: s.subject,
TTL: 86400,
})
if err != nil {
s.log.Warn("webpush: send failed", "endpoint", truncate(sub.Endpoint, 60), "err", err)
return
}
defer resp.Body.Close() //nolint:errcheck
if resp.StatusCode >= 400 {
s.log.Warn("webpush: push service returned error",
"endpoint", truncate(sub.Endpoint, 60),
"status", resp.StatusCode)
return
}
mu.Lock()
success++
mu.Unlock()
}()
}
wg.Wait()
return success
}
// SendToBook sends a push notification to all subscribers of the given book.
// store is used to list subscriptions for the book's library followers.
func (s *Sender) SendToBook(ctx context.Context, store *storage.Store, slug string, p Payload) {
if !s.Enabled() {
return
}
subs, err := store.ListPushSubscriptionsByBook(ctx, slug)
if err != nil {
s.log.Warn("webpush: list push subscriptions", "slug", slug, "err", err)
return
}
if len(subs) == 0 {
return
}
n := s.Send(ctx, subs, p)
s.log.Info("webpush: sent chapter notification",
"slug", slug,
"recipients", n,
"total_subs", len(subs),
)
}
// GenerateVAPIDKeys generates a new VAPID key pair and prints them.
// Useful for one-off key generation during setup.
func GenerateVAPIDKeys() (public, private string, err error) {
private, public, err = webpushgo.GenerateVAPIDKeys()
if err != nil {
return "", "", fmt.Errorf("generate VAPID keys: %w", err)
}
return public, private, nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}

View File

@@ -0,0 +1,431 @@
// Migration 1 — full schema baseline.
//
// Creates all 21 collections that were previously bootstrapped by
// scripts/pb-init-v3.sh. Also creates the initial superuser from the
// POCKETBASE_ADMIN_EMAIL / POCKETBASE_ADMIN_PASSWORD env vars (first run only).
//
// This migration is intentionally idempotent: each collection is skipped if it
// already exists. This makes it safe to apply on an existing install without
// running `migrate history-sync` first — existing collections are left untouched
// and migration 2 still runs to add the three fields that were missing.
package migrations
import (
"os"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
steps := []func(core.App) error{
createBooks,
createChaptersIdx,
createRanking,
createProgress,
createScrapingTasks,
createAudioJobs,
createAppUsers,
createUserSessions,
createUserLibrary,
createUserSettings,
createUserSubscriptions,
createBookComments,
createCommentVotes,
createTranslationJobs,
createImportTasks,
createNotifications,
createPushSubscriptions,
createAIJobs,
createDiscoveryVotes,
createBookRatings,
createSiteConfig,
createInitialSuperuser,
}
for _, step := range steps {
if err := step(app); err != nil {
return err
}
}
return nil
}, func(app core.App) error {
// Down: drop all collections in safe reverse order.
names := []string{
"site_config", "book_ratings", "discovery_votes", "ai_jobs",
"push_subscriptions", "notifications", "import_tasks",
"translation_jobs", "comment_votes", "book_comments",
"user_subscriptions", "user_settings", "user_library",
"user_sessions", "app_users", "audio_jobs", "scraping_tasks",
"progress", "ranking", "chapters_idx", "books",
}
for _, name := range names {
coll, err := app.FindCollectionByNameOrId(name)
if err != nil {
continue // already absent — safe to skip
}
if err := app.Delete(coll); err != nil {
return err
}
}
return nil
})
}
// ── Helpers ───────────────────────────────────────────────────────────────────
// saveIfAbsent saves the collection only when no collection with that name
// exists yet. This makes the migration safe to run on an existing install
// without history-sync — already-created collections are simply skipped.
func saveIfAbsent(app core.App, c *core.Collection) error {
if _, err := app.FindCollectionByNameOrId(c.Name); err == nil {
return nil // already exists — skip
}
return app.Save(c)
}
// ── Collection creators ───────────────────────────────────────────────────────
func createBooks(app core.App) error {
c := core.NewBaseCollection("books")
c.Fields.Add(
&core.TextField{Name: "slug", Required: true},
&core.TextField{Name: "title", Required: true},
&core.TextField{Name: "author"},
&core.TextField{Name: "cover"},
&core.TextField{Name: "status"},
&core.JSONField{Name: "genres"},
&core.TextField{Name: "summary"},
&core.NumberField{Name: "total_chapters"},
&core.TextField{Name: "source_url"},
&core.NumberField{Name: "ranking"},
&core.TextField{Name: "meta_updated"},
&core.BoolField{Name: "archived"},
)
return saveIfAbsent(app, c)
}
func createChaptersIdx(app core.App) error {
c := core.NewBaseCollection("chapters_idx")
c.Fields.Add(
&core.TextField{Name: "slug", Required: true},
&core.NumberField{Name: "number", Required: true},
&core.TextField{Name: "title"},
)
// Enforce uniqueness on (slug, number) — prevents duplicate chapter entries.
c.AddIndex("idx_chapters_idx_slug_number", true, "slug, number", "")
// Allow fast "recently updated books" queries.
c.AddIndex("idx_chapters_idx_created", false, "created", "")
return saveIfAbsent(app, c)
}
func createRanking(app core.App) error {
c := core.NewBaseCollection("ranking")
c.Fields.Add(
&core.NumberField{Name: "rank", Required: true},
&core.TextField{Name: "slug", Required: true},
&core.TextField{Name: "title"},
&core.TextField{Name: "author"},
&core.TextField{Name: "cover"},
&core.TextField{Name: "status"},
&core.JSONField{Name: "genres"},
&core.TextField{Name: "source_url"},
)
return saveIfAbsent(app, c)
}
func createProgress(app core.App) error {
c := core.NewBaseCollection("progress")
c.Fields.Add(
&core.TextField{Name: "session_id", Required: true},
&core.TextField{Name: "slug", Required: true},
&core.NumberField{Name: "chapter"},
&core.TextField{Name: "user_id"},
&core.NumberField{Name: "audio_time"},
)
return saveIfAbsent(app, c)
}
func createScrapingTasks(app core.App) error {
c := core.NewBaseCollection("scraping_tasks")
c.Fields.Add(
&core.TextField{Name: "kind"},
&core.TextField{Name: "target_url"},
&core.NumberField{Name: "from_chapter"},
&core.NumberField{Name: "to_chapter"},
&core.TextField{Name: "worker_id"},
&core.TextField{Name: "status", Required: true},
&core.NumberField{Name: "books_found"},
&core.NumberField{Name: "chapters_scraped"},
&core.NumberField{Name: "chapters_skipped"},
&core.NumberField{Name: "errors"},
&core.TextField{Name: "error_message"},
&core.DateField{Name: "started"},
&core.DateField{Name: "finished"},
&core.DateField{Name: "heartbeat_at"},
)
return saveIfAbsent(app, c)
}
func createAudioJobs(app core.App) error {
c := core.NewBaseCollection("audio_jobs")
c.Fields.Add(
&core.TextField{Name: "cache_key", Required: true},
&core.TextField{Name: "slug", Required: true},
&core.NumberField{Name: "chapter", Required: true},
&core.TextField{Name: "voice"},
&core.TextField{Name: "worker_id"},
&core.TextField{Name: "status", Required: true},
&core.TextField{Name: "error_message"},
&core.DateField{Name: "started"},
&core.DateField{Name: "finished"},
&core.DateField{Name: "heartbeat_at"},
)
return saveIfAbsent(app, c)
}
func createAppUsers(app core.App) error {
c := core.NewBaseCollection("app_users")
c.Fields.Add(
&core.TextField{Name: "username", Required: true},
&core.TextField{Name: "password_hash"},
&core.TextField{Name: "role"},
&core.TextField{Name: "avatar_url"},
&core.TextField{Name: "email"},
&core.BoolField{Name: "email_verified"},
&core.TextField{Name: "verification_token"},
&core.TextField{Name: "verification_token_exp"},
&core.TextField{Name: "oauth_provider"},
&core.TextField{Name: "oauth_id"},
&core.TextField{Name: "polar_customer_id"},
&core.TextField{Name: "polar_subscription_id"},
&core.BoolField{Name: "notify_new_chapters"},
)
return saveIfAbsent(app, c)
}
func createUserSessions(app core.App) error {
c := core.NewBaseCollection("user_sessions")
c.Fields.Add(
&core.TextField{Name: "user_id", Required: true},
&core.TextField{Name: "session_id", Required: true},
&core.TextField{Name: "user_agent"},
&core.TextField{Name: "ip"},
&core.TextField{Name: "device_fingerprint"},
// created_at is a custom text field (not the system `created` date field).
&core.TextField{Name: "created_at"},
&core.TextField{Name: "last_seen"},
)
return saveIfAbsent(app, c)
}
func createUserLibrary(app core.App) error {
c := core.NewBaseCollection("user_library")
c.Fields.Add(
&core.TextField{Name: "session_id", Required: true},
&core.TextField{Name: "user_id"},
&core.TextField{Name: "slug", Required: true},
&core.TextField{Name: "saved_at"},
&core.TextField{Name: "shelf"},
)
return saveIfAbsent(app, c)
}
func createUserSettings(app core.App) error {
c := core.NewBaseCollection("user_settings")
c.Fields.Add(
&core.TextField{Name: "session_id", Required: true},
&core.TextField{Name: "user_id"},
&core.BoolField{Name: "auto_next"},
&core.TextField{Name: "voice"},
&core.NumberField{Name: "speed"},
&core.TextField{Name: "theme"},
&core.TextField{Name: "locale"},
&core.TextField{Name: "font_family"},
&core.NumberField{Name: "font_size"},
&core.BoolField{Name: "announce_chapter"},
&core.TextField{Name: "audio_mode"},
)
return saveIfAbsent(app, c)
}
func createUserSubscriptions(app core.App) error {
c := core.NewBaseCollection("user_subscriptions")
c.Fields.Add(
&core.TextField{Name: "follower_id", Required: true},
&core.TextField{Name: "followee_id", Required: true},
)
return saveIfAbsent(app, c)
}
func createBookComments(app core.App) error {
c := core.NewBaseCollection("book_comments")
c.Fields.Add(
&core.TextField{Name: "slug", Required: true},
&core.TextField{Name: "user_id"},
&core.TextField{Name: "username"},
&core.TextField{Name: "body"},
&core.NumberField{Name: "upvotes"},
&core.NumberField{Name: "downvotes"},
&core.TextField{Name: "parent_id"},
)
return saveIfAbsent(app, c)
}
func createCommentVotes(app core.App) error {
c := core.NewBaseCollection("comment_votes")
c.Fields.Add(
&core.TextField{Name: "comment_id", Required: true},
&core.TextField{Name: "user_id"},
&core.TextField{Name: "session_id"},
&core.TextField{Name: "vote"},
)
return saveIfAbsent(app, c)
}
func createTranslationJobs(app core.App) error {
c := core.NewBaseCollection("translation_jobs")
c.Fields.Add(
&core.TextField{Name: "cache_key", Required: true},
&core.TextField{Name: "slug", Required: true},
&core.NumberField{Name: "chapter", Required: true},
&core.TextField{Name: "lang", Required: true},
&core.TextField{Name: "worker_id"},
&core.TextField{Name: "status", Required: true},
&core.TextField{Name: "error_message"},
&core.DateField{Name: "started"},
&core.DateField{Name: "finished"},
&core.DateField{Name: "heartbeat_at"},
)
return saveIfAbsent(app, c)
}
func createImportTasks(app core.App) error {
c := core.NewBaseCollection("import_tasks")
c.Fields.Add(
&core.TextField{Name: "slug", Required: true},
&core.TextField{Name: "title", Required: true},
&core.TextField{Name: "file_name"},
&core.TextField{Name: "file_type"},
&core.TextField{Name: "object_key"},
&core.TextField{Name: "chapters_key"},
&core.TextField{Name: "author"},
&core.TextField{Name: "cover_url"},
&core.TextField{Name: "genres"},
&core.TextField{Name: "summary"},
&core.TextField{Name: "book_status"},
&core.TextField{Name: "worker_id"},
&core.TextField{Name: "initiator_user_id"},
&core.TextField{Name: "status", Required: true},
&core.NumberField{Name: "chapters_done"},
&core.NumberField{Name: "chapters_total"},
&core.TextField{Name: "error_message"},
&core.DateField{Name: "started"},
&core.DateField{Name: "finished"},
&core.DateField{Name: "heartbeat_at"},
)
return saveIfAbsent(app, c)
}
func createNotifications(app core.App) error {
c := core.NewBaseCollection("notifications")
c.Fields.Add(
&core.TextField{Name: "user_id", Required: true},
&core.TextField{Name: "title", Required: true},
&core.TextField{Name: "message"},
&core.TextField{Name: "link"},
&core.BoolField{Name: "read"},
)
return saveIfAbsent(app, c)
}
func createPushSubscriptions(app core.App) error {
c := core.NewBaseCollection("push_subscriptions")
c.Fields.Add(
&core.TextField{Name: "user_id", Required: true},
&core.TextField{Name: "endpoint", Required: true},
&core.TextField{Name: "p256dh", Required: true},
&core.TextField{Name: "auth", Required: true},
)
return saveIfAbsent(app, c)
}
func createAIJobs(app core.App) error {
c := core.NewBaseCollection("ai_jobs")
c.Fields.Add(
&core.TextField{Name: "kind", Required: true},
&core.TextField{Name: "slug"},
&core.TextField{Name: "status", Required: true},
&core.NumberField{Name: "from_item"},
&core.NumberField{Name: "to_item"},
&core.NumberField{Name: "items_done"},
&core.NumberField{Name: "items_total"},
&core.TextField{Name: "model"},
&core.TextField{Name: "payload"},
&core.TextField{Name: "error_message"},
&core.DateField{Name: "started"},
&core.DateField{Name: "finished"},
&core.DateField{Name: "heartbeat_at"},
)
return saveIfAbsent(app, c)
}
func createDiscoveryVotes(app core.App) error {
c := core.NewBaseCollection("discovery_votes")
c.Fields.Add(
&core.TextField{Name: "session_id", Required: true},
&core.TextField{Name: "user_id"},
&core.TextField{Name: "slug", Required: true},
&core.TextField{Name: "action", Required: true},
)
return saveIfAbsent(app, c)
}
func createBookRatings(app core.App) error {
c := core.NewBaseCollection("book_ratings")
c.Fields.Add(
&core.TextField{Name: "session_id", Required: true},
&core.TextField{Name: "user_id"},
&core.TextField{Name: "slug", Required: true},
&core.NumberField{Name: "rating", Required: true},
)
return saveIfAbsent(app, c)
}
func createSiteConfig(app core.App) error {
c := core.NewBaseCollection("site_config")
c.Fields.Add(
&core.TextField{Name: "decoration"},
&core.TextField{Name: "logoAnimation"},
&core.TextField{Name: "eventLabel"},
)
return saveIfAbsent(app, c)
}
// createInitialSuperuser creates the first PocketBase superuser from env vars.
// It is a no-op if a superuser with that email already exists, or if the env
// vars are not set. This replaces the superuser bootstrap block in
// scripts/pb-init-v3.sh.
func createInitialSuperuser(app core.App) error {
email := os.Getenv("POCKETBASE_ADMIN_EMAIL")
password := os.Getenv("POCKETBASE_ADMIN_PASSWORD")
if email == "" || password == "" {
return nil
}
existing, _ := app.FindFirstRecordByData("_superusers", "email", email)
if existing != nil {
return nil // superuser already exists
}
superusers, err := app.FindCollectionByNameOrId("_superusers")
if err != nil {
return err
}
record := core.NewRecord(superusers)
record.Set("email", email)
record.Set("password", password)
record.Set("passwordConfirm", password)
return app.Save(record)
}

View File

@@ -0,0 +1,71 @@
// Migration 2 — add fields present in code but absent from pb-init-v3.sh.
//
// Discovered by auditing every PocketBase field access in the Go backend
// and SvelteKit UI against the collection definitions in pb-init-v3.sh:
//
// books.rating (number) — written by WriteMetadata but never defined.
// app_users.notify_new_chapters_push (bool) — used in UI push-notification opt-in.
// book_comments.chapter (number) — used to scope comments to a chapter (0 = book-level).
//
// The check for field existence makes this migration safe to re-apply on
// a fresh install where migration 1 already created the collections without
// these fields.
package migrations
import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
type addition struct {
collection string
field core.Field
}
additions := []addition{
{"books", &core.NumberField{Name: "rating"}},
{"app_users", &core.BoolField{Name: "notify_new_chapters_push"}},
{"book_comments", &core.NumberField{Name: "chapter"}},
}
for _, a := range additions {
coll, err := app.FindCollectionByNameOrId(a.collection)
if err != nil {
return err
}
if coll.Fields.GetByName(a.field.GetName()) != nil {
continue // already present — idempotent
}
coll.Fields.Add(a.field)
if err := app.Save(coll); err != nil {
return err
}
}
return nil
}, func(app core.App) error {
type removal struct {
collection string
field string
}
removals := []removal{
{"books", "rating"},
{"app_users", "notify_new_chapters_push"},
{"book_comments", "chapter"},
}
for _, r := range removals {
coll, err := app.FindCollectionByNameOrId(r.collection)
if err != nil {
continue
}
f := coll.Fields.GetByName(r.field)
if f == nil {
continue
}
coll.Fields.RemoveById(f.GetId())
if err := app.Save(coll); err != nil {
return err
}
}
return nil
})
}

View File

@@ -0,0 +1,75 @@
// Migration 3 — add visibility + submitted_by fields to books.
//
// visibility: "public" | "admin_only"
// All existing (scraped) books are backfilled to "admin_only".
// New author-submitted books are created with "public".
//
// submitted_by: optional app_users ID for books submitted by a registered author.
// Empty for scraped books.
//
// The backfill iterates books in pages of 200. It is idempotent: books whose
// visibility is already set are skipped.
package migrations
import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
coll, err := app.FindCollectionByNameOrId("books")
if err != nil {
return err
}
changed := false
if coll.Fields.GetByName("visibility") == nil {
coll.Fields.Add(&core.TextField{Name: "visibility"})
changed = true
}
if coll.Fields.GetByName("submitted_by") == nil {
coll.Fields.Add(&core.TextField{Name: "submitted_by"})
changed = true
}
if changed {
if err := app.Save(coll); err != nil {
return err
}
}
// Backfill: mark all existing books as admin_only where visibility is empty.
// These are scraped books that pre-date this migration.
const perPage = 200
for page := 1; ; page++ {
records, err := app.FindRecordsByFilter(
"books", `visibility=""`, "+id", perPage, (page-1)*perPage, nil,
)
if err != nil || len(records) == 0 {
break
}
for _, rec := range records {
rec.Set("visibility", "admin_only")
// Best-effort: ignore individual save errors (don't abort migration).
_ = app.Save(rec)
}
if len(records) < perPage {
break
}
}
return nil
}, func(app core.App) error {
coll, err := app.FindCollectionByNameOrId("books")
if err != nil {
return nil
}
for _, name := range []string{"visibility", "submitted_by"} {
f := coll.Fields.GetByName(name)
if f == nil {
continue
}
coll.Fields.RemoveById(f.GetId())
}
return app.Save(coll)
})
}

106
docker-bake.hcl Normal file
View File

@@ -0,0 +1,106 @@
# docker-bake.hcl — defines all five production images.
#
# CI passes version info as environment variables; locally everything gets :dev tags.
#
# Local build (no push):
# docker buildx bake
#
# CI environment variables: VERSION, MAJOR_MINOR, COMMIT, BUILD_TIME
variable "DOCKER_USER" { default = "kalekber" }
variable "VERSION" { default = "dev" } # e.g. "4.1.6" (no leading v)
variable "MAJOR_MINOR" { default = "dev" } # e.g. "4.1"
variable "COMMIT" { default = "unknown" }
variable "BUILD_TIME" { default = "" }
# ── Shared defaults ───────────────────────────────────────────────────────────
target "_defaults" {
pull = true
# CI overrides to push=true via --set *.output=type=image,push=true
output = ["type=image,push=false"]
cache-to = ["type=inline"]
}
# ── Go targets (share the backend/ build context + builder stage) ─────────────
target "backend" {
inherits = ["_defaults"]
context = "backend"
target = "backend"
tags = [
"${DOCKER_USER}/libnovel-backend:${VERSION}",
"${DOCKER_USER}/libnovel-backend:${MAJOR_MINOR}",
"${DOCKER_USER}/libnovel-backend:latest",
]
cache-from = ["type=registry,ref=${DOCKER_USER}/libnovel-backend:latest"]
args = {
VERSION = VERSION
COMMIT = COMMIT
}
}
target "runner" {
inherits = ["_defaults"]
context = "backend"
target = "runner"
tags = [
"${DOCKER_USER}/libnovel-runner:${VERSION}",
"${DOCKER_USER}/libnovel-runner:${MAJOR_MINOR}",
"${DOCKER_USER}/libnovel-runner:latest",
]
cache-from = ["type=registry,ref=${DOCKER_USER}/libnovel-runner:latest"]
args = {
VERSION = VERSION
COMMIT = COMMIT
}
}
target "pocketbase" {
inherits = ["_defaults"]
context = "backend"
target = "pocketbase"
tags = [
"${DOCKER_USER}/libnovel-pocketbase:${VERSION}",
"${DOCKER_USER}/libnovel-pocketbase:${MAJOR_MINOR}",
"${DOCKER_USER}/libnovel-pocketbase:latest",
]
cache-from = ["type=registry,ref=${DOCKER_USER}/libnovel-pocketbase:latest"]
}
# ── UI (SvelteKit — separate context) ────────────────────────────────────────
target "ui" {
inherits = ["_defaults"]
context = "ui"
tags = [
"${DOCKER_USER}/libnovel-ui:${VERSION}",
"${DOCKER_USER}/libnovel-ui:${MAJOR_MINOR}",
"${DOCKER_USER}/libnovel-ui:latest",
]
cache-from = ["type=registry,ref=${DOCKER_USER}/libnovel-ui:latest"]
args = {
BUILD_VERSION = VERSION
BUILD_COMMIT = COMMIT
BUILD_TIME = BUILD_TIME
}
}
# ── Caddy (custom plugins — separate context) ─────────────────────────────────
target "caddy" {
inherits = ["_defaults"]
context = "caddy"
tags = [
"${DOCKER_USER}/libnovel-caddy:${VERSION}",
"${DOCKER_USER}/libnovel-caddy:${MAJOR_MINOR}",
"${DOCKER_USER}/libnovel-caddy:latest",
]
cache-from = ["type=registry,ref=${DOCKER_USER}/libnovel-caddy:latest"]
}
# ── Default group: all five images ────────────────────────────────────────────
group "default" {
targets = ["backend", "runner", "pocketbase", "ui", "caddy"]
}

View File

@@ -58,6 +58,8 @@ services:
mc mb --ignore-existing local/audio;
mc mb --ignore-existing local/avatars;
mc mb --ignore-existing local/catalogue;
mc mb --ignore-existing local/translations;
mc mb --ignore-existing local/imports;
echo 'buckets ready';
"
environment:
@@ -65,12 +67,21 @@ services:
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD}"
# ─── PocketBase (auth + structured data) ─────────────────────────────────────
# Custom binary built from backend/cmd/pocketbase — runs Go migrations on every
# startup before accepting traffic, replacing the old pb-init-v3.sh script.
pocketbase:
image: ghcr.io/muchobien/pocketbase:latest
image: kalekber/libnovel-pocketbase:${GIT_TAG:-latest}
build:
context: ./backend
dockerfile: Dockerfile
target: pocketbase
labels:
com.centurylinklabs.watchtower.enable: "true"
restart: unless-stopped
environment:
PB_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
PB_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
# Used by migration 1 to create the initial superuser on a fresh install.
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
# No public port — accessed only by backend/runner on the internal network.
expose:
- "8090"
@@ -80,25 +91,12 @@ services:
test: ["CMD", "wget", "-qO-", "http://localhost:8090/api/health"]
interval: 10s
timeout: 5s
retries: 5
# ─── PocketBase collection bootstrap ─────────────────────────────────────────
pb-init:
image: alpine:3.19
depends_on:
pocketbase:
condition: service_healthy
environment:
POCKETBASE_URL: "http://pocketbase:8090"
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
volumes:
- ./scripts/pb-init-v3.sh:/pb-init.sh:ro
entrypoint: ["sh", "/pb-init.sh"]
retries: 10
start_period: 30s
# ─── Meilisearch (full-text search) ──────────────────────────────────────────
meilisearch:
image: getmeili/meilisearch:latest
image: getmeili/meilisearch:v1.40.0
restart: unless-stopped
environment:
MEILI_MASTER_KEY: "${MEILI_MASTER_KEY}"
@@ -164,8 +162,6 @@ services:
restart: unless-stopped
stop_grace_period: 35s
depends_on:
pb-init:
condition: service_completed_successfully
pocketbase:
condition: service_healthy
minio:
@@ -182,6 +178,7 @@ services:
environment:
<<: *infra-env
BACKEND_HTTP_ADDR: ":8080"
BACKEND_ADMIN_TOKEN: "${BACKEND_ADMIN_TOKEN}"
LOG_LEVEL: "${LOG_LEVEL}"
KOKORO_URL: "${KOKORO_URL}"
KOKORO_VOICE: "${KOKORO_VOICE}"
@@ -218,8 +215,6 @@ services:
restart: unless-stopped
stop_grace_period: 135s
depends_on:
pb-init:
condition: service_completed_successfully
pocketbase:
condition: service_healthy
minio:
@@ -273,8 +268,6 @@ services:
restart: unless-stopped
stop_grace_period: 35s
depends_on:
pb-init:
condition: service_completed_successfully
backend:
condition: service_healthy
pocketbase:
@@ -293,6 +286,7 @@ services:
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
AUTH_SECRET: "${AUTH_SECRET}"
BACKEND_ADMIN_TOKEN: "${BACKEND_ADMIN_TOKEN}"
DEBUG_LOGIN_TOKEN: "${DEBUG_LOGIN_TOKEN}"
PUBLIC_MINIO_PUBLIC_URL: "${MINIO_PUBLIC_ENDPOINT}"
# Valkey
@@ -307,6 +301,8 @@ services:
# OpenTelemetry tracing
OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}"
OTEL_SERVICE_NAME: "ui"
# Allow large PDF/EPUB uploads (adapter-node default is 512KB)
BODY_SIZE_LIMIT: "52428800"
# OAuth2 providers
GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID}"
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET}"

View File

@@ -1,115 +0,0 @@
# LibNovel homelab runner
#
# Connects to production PocketBase and MinIO via public subdomains.
# All secrets come from Doppler (project=libnovel, config=prd_homelab).
# Run with: doppler run -- docker compose up -d
#
# Differs from prod runner:
# - RUNNER_WORKER_ID=homelab-runner-1 (unique, avoids task claiming conflicts)
# - MINIO_ENDPOINT/USE_SSL → storage.libnovel.cc over HTTPS
# - POCKETBASE_URL → https://pb.libnovel.cc
# - MEILI_URL → https://search.libnovel.cc (Caddy-proxied)
# - VALKEY_ADDR → unset (not exposed publicly)
# - RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true
# - REDIS_ADDR → rediss://redis.libnovel.cc:6380 (prod Redis via Caddy TLS proxy)
# - LibreTranslate service for machine translation (internal network only)
#
# extra_hosts pins storage.libnovel.cc and pb.libnovel.cc to the prod server IP
# (165.22.70.138) so that large PutObject uploads and PocketBase writes bypass
# Cloudflare's 100-second proxy timeout entirely. TLS still terminates at Caddy
# on prod; the TLS certificate is valid for the domain names so SNI works fine.
services:
libretranslate:
image: libretranslate/libretranslate:latest
restart: unless-stopped
environment:
LT_API_KEYS: "true"
LT_API_KEYS_DB_PATH: "/app/db/api_keys.db"
# Limit to source→target pairs the runner actually uses
LT_LOAD_ONLY: "en,ru,id,pt,fr"
LT_DISABLE_WEB_UI: "true"
LT_UPDATE_MODELS: "false"
volumes:
- libretranslate_models:/home/libretranslate/.local/share/argos-translate
- libretranslate_db:/app/db
runner:
image: kalekber/libnovel-runner:latest
restart: unless-stopped
stop_grace_period: 135s
labels:
- "com.centurylinklabs.watchtower.enable=true"
depends_on:
- libretranslate
# Pin prod subdomains to the prod server IP to bypass Cloudflare's 100s
# proxy timeout. Large MP3 PutObject uploads and PocketBase writes go
# directly to Caddy on prod; TLS and SNI still work normally.
extra_hosts:
- "storage.libnovel.cc:165.22.70.138"
- "pb.libnovel.cc:165.22.70.138"
environment:
# ── PocketBase ──────────────────────────────────────────────────────────
POCKETBASE_URL: "https://pb.libnovel.cc"
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
# ── MinIO (S3 API via public subdomain) ─────────────────────────────────
MINIO_ENDPOINT: "storage.libnovel.cc"
MINIO_ACCESS_KEY: "${MINIO_ROOT_USER}"
MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD}"
MINIO_USE_SSL: "true"
MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT}"
MINIO_PUBLIC_USE_SSL: "${MINIO_PUBLIC_USE_SSL}"
# ── Meilisearch (via search.libnovel.cc Caddy proxy) ────────────────────
MEILI_URL: "${MEILI_URL}"
MEILI_API_KEY: "${MEILI_API_KEY}"
VALKEY_ADDR: ""
# Force IPv4 DNS resolution — homelab has no IPv6 route to search.libnovel.cc
GODEBUG: "preferIPv4=1"
# ── Kokoro TTS ──────────────────────────────────────────────────────────
KOKORO_URL: "${KOKORO_URL}"
KOKORO_VOICE: "${KOKORO_VOICE}"
# ── Pocket TTS ──────────────────────────────────────────────────────────
POCKET_TTS_URL: "${POCKET_TTS_URL}"
# ── Cloudflare Workers AI TTS ────────────────────────────────────────────
CFAI_ACCOUNT_ID: "${CFAI_ACCOUNT_ID}"
CFAI_API_TOKEN: "${CFAI_API_TOKEN}"
# ── LibreTranslate (internal Docker network) ────────────────────────────
LIBRETRANSLATE_URL: "http://libretranslate:5000"
LIBRETRANSLATE_API_KEY: "${LIBRETRANSLATE_API_KEY}"
# ── Asynq / Redis (prod Redis via Caddy TLS proxy) ──────────────────────
# The runner connects to prod Redis over TLS: rediss://redis.libnovel.cc:6380.
# Caddy on prod terminates TLS and proxies to the local redis:6379 sidecar.
REDIS_ADDR: "${REDIS_ADDR}"
REDIS_PASSWORD: "${REDIS_PASSWORD}"
# ── Runner tuning ───────────────────────────────────────────────────────
RUNNER_WORKER_ID: "${RUNNER_WORKER_ID}"
RUNNER_POLL_INTERVAL: "${RUNNER_POLL_INTERVAL}"
RUNNER_MAX_CONCURRENT_SCRAPE: "${RUNNER_MAX_CONCURRENT_SCRAPE}"
RUNNER_MAX_CONCURRENT_AUDIO: "${RUNNER_MAX_CONCURRENT_AUDIO}"
RUNNER_MAX_CONCURRENT_TRANSLATION: "${RUNNER_MAX_CONCURRENT_TRANSLATION}"
RUNNER_TIMEOUT: "${RUNNER_TIMEOUT}"
RUNNER_METRICS_ADDR: "${RUNNER_METRICS_ADDR}"
RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH: "true"
# ── Observability ───────────────────────────────────────────────────────
LOG_LEVEL: "${LOG_LEVEL}"
GLITCHTIP_DSN: "${GLITCHTIP_DSN_RUNNER}"
healthcheck:
test: ["CMD", "/healthcheck", "file", "/tmp/runner.alive", "120"]
interval: 60s
timeout: 5s
retries: 3
volumes:
libretranslate_models:
libretranslate_db:

View File

@@ -56,14 +56,14 @@ build-svc svc:
# Push all custom images to Docker Hub (requires docker login)
push:
{{doppler}} docker compose push backend runner ui caddy
{{doppler}} docker compose push backend runner ui caddy pocketbase
# Build then push all custom images
build-push: build push
# Pull all images from Docker Hub (uses GIT_TAG from Doppler)
pull-images:
{{doppler}} docker compose pull backend runner ui caddy
{{doppler}} docker compose pull backend runner ui caddy pocketbase
# Pull all third-party base images (minio, pocketbase, etc.)
pull-infra:

View File

@@ -144,7 +144,8 @@ create "books" '{
{"name":"total_chapters","type":"number"},
{"name":"source_url", "type":"text"},
{"name":"ranking", "type":"number"},
{"name":"meta_updated", "type":"text"}
{"name":"meta_updated", "type":"text"},
{"name":"archived", "type":"bool"}
]}'
create "chapters_idx" '{
@@ -255,6 +256,7 @@ create "user_settings" '{
{"name":"font_family", "type":"text"},
{"name":"font_size", "type":"number"},
{"name":"announce_chapter","type":"bool"},
{"name":"audio_mode", "type":"text"},
{"name":"updated", "type":"text"}
]}'
@@ -299,6 +301,48 @@ create "translation_jobs" '{
{"name":"heartbeat_at", "type":"date"}
]}'
create "import_tasks" '{
"name":"import_tasks","type":"base","fields":[
{"name":"slug", "type":"text", "required":true},
{"name":"title", "type":"text", "required":true},
{"name":"file_name", "type":"text"},
{"name":"file_type", "type":"text"},
{"name":"object_key", "type":"text"},
{"name":"chapters_key", "type":"text"},
{"name":"author", "type":"text"},
{"name":"cover_url", "type":"text"},
{"name":"genres", "type":"text"},
{"name":"summary", "type":"text"},
{"name":"book_status", "type":"text"},
{"name":"worker_id", "type":"text"},
{"name":"initiator_user_id", "type":"text"},
{"name":"status", "type":"text", "required":true},
{"name":"chapters_done", "type":"number"},
{"name":"chapters_total", "type":"number"},
{"name":"error_message", "type":"text"},
{"name":"started", "type":"date"},
{"name":"finished", "type":"date"},
{"name":"heartbeat_at", "type":"date"}
]}'
create "notifications" '{
"name":"notifications","type":"base","fields":[
{"name":"user_id", "type":"text","required":true},
{"name":"title", "type":"text","required":true},
{"name":"message", "type":"text"},
{"name":"link", "type":"text"},
{"name":"read", "type":"bool"},
{"name":"created", "type":"date"}
]}'
create "push_subscriptions" '{
"name":"push_subscriptions","type":"base","fields":[
{"name":"user_id", "type":"text","required":true},
{"name":"endpoint", "type":"text","required":true},
{"name":"p256dh", "type":"text","required":true},
{"name":"auth", "type":"text","required":true}
]}'
create "ai_jobs" '{
"name":"ai_jobs","type":"base","fields":[
{"name":"kind", "type":"text", "required":true},
@@ -332,6 +376,13 @@ create "book_ratings" '{
{"name":"rating", "type":"number", "required":true}
]}'
create "site_config" '{
"name":"site_config","type":"base","fields":[
{"name":"decoration", "type":"text"},
{"name":"logoAnimation", "type":"text"},
{"name":"eventLabel", "type":"text"}
]}'
# ── 5. Field migrations (idempotent — adds fields missing from older installs) ─
add_field "scraping_tasks" "heartbeat_at" "date"
add_field "audio_jobs" "heartbeat_at" "date"
@@ -355,6 +406,9 @@ add_field "user_settings" "locale" "text"
add_field "user_settings" "font_family" "text"
add_field "user_settings" "font_size" "number"
add_field "user_settings" "announce_chapter" "bool"
add_field "user_settings" "audio_mode" "text"
add_field "books" "archived" "bool"
add_field "app_users" "notify_new_chapters" "bool"
# ── 6. Indexes ────────────────────────────────────────────────────────────────
add_index "chapters_idx" "idx_chapters_idx_slug_number" \

View File

@@ -21,11 +21,7 @@ ENV PUBLIC_BUILD_VERSION=$BUILD_VERSION
ENV PUBLIC_BUILD_COMMIT=$BUILD_COMMIT
ENV PUBLIC_BUILD_TIME=$BUILD_TIME
# PREBUILT=1 skips npm run build — used in CI when the build/ directory has
# already been compiled (and debug IDs injected) by a prior job. The caller
# must copy the pre-built build/ into the Docker context before building.
ARG PREBUILT=0
RUN [ "$PREBUILT" = "1" ] || npm run build
RUN npm run build
# ── Runtime image ──────────────────────────────────────────────────────────────
# adapter-node bundles most server-side code, but packages with dynamic

View File

@@ -402,11 +402,13 @@
"admin_nav_scrape": "Scrape",
"admin_nav_audio": "Audio",
"admin_nav_translation": "Translation",
"admin_nav_import": "Import",
"admin_nav_changelog": "Changelog",
"admin_nav_image_gen": "Image Gen",
"admin_nav_text_gen": "Text Gen",
"admin_nav_catalogue_tools": "Catalogue Tools",
"admin_nav_ai_jobs": "AI Jobs",
"admin_nav_notifications": "Notifications",
"admin_nav_feedback": "Feedback",
"admin_nav_errors": "Errors",
"admin_nav_analytics": "Analytics",

View File

@@ -1,6 +1,5 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_library": "Bibliothèque",
"nav_catalogue": "Catalogue",
"nav_feed": "Fil",
@@ -11,7 +10,6 @@
"nav_sign_out": "Déconnexion",
"nav_toggle_menu": "Menu",
"nav_admin_panel": "Panneau admin",
"footer_library": "Bibliothèque",
"footer_catalogue": "Catalogue",
"footer_feedback": "Retour",
@@ -20,7 +18,6 @@
"footer_dmca": "DMCA",
"footer_copyright": "© {year} libnovel",
"footer_dev": "dev",
"home_title": "libnovel",
"home_stat_books": "Livres",
"home_stat_chapters": "Chapitres",
@@ -34,7 +31,6 @@
"home_discover_novels": "Découvrir des romans",
"home_via_reader": "via {username}",
"home_chapter_badge": "ch.{n}",
"player_generating": "Génération… {percent}%",
"player_loading": "Chargement…",
"player_chapters": "Chapitres",
@@ -58,7 +54,6 @@
"player_auto_next_aria": "Suivant auto {state}",
"player_go_to_chapter": "Aller au chapitre",
"player_close": "Fermer le lecteur",
"login_page_title": "Connexion — libnovel",
"login_heading": "Se connecter à libnovel",
"login_subheading": "Choisissez un fournisseur pour continuer",
@@ -68,7 +63,6 @@
"login_error_oauth_state": "Connexion annulée ou expirée. Veuillez réessayer.",
"login_error_oauth_failed": "Impossible de se connecter au fournisseur. Veuillez réessayer.",
"login_error_oauth_no_email": "Votre compte n'a pas d'adresse e-mail vérifiée. Ajoutez-en une et réessayez.",
"books_page_title": "Bibliothèque — libnovel",
"books_heading": "Votre bibliothèque",
"books_empty_title": "Aucun livre pour l'instant",
@@ -78,7 +72,6 @@
"books_last_read": "Dernier lu : Ch.{n}",
"books_reading_progress": "Ch.{current} / {total}",
"books_remove": "Supprimer",
"catalogue_page_title": "Catalogue — libnovel",
"catalogue_heading": "Catalogue",
"catalogue_search_placeholder": "Rechercher des romans…",
@@ -99,7 +92,6 @@
"catalogue_loading": "Chargement…",
"catalogue_load_more": "Charger plus",
"catalogue_results_count": "{n} résultats",
"book_detail_page_title": "{title} — libnovel",
"book_detail_signin_to_save": "Connectez-vous pour sauvegarder",
"book_detail_add_to_library": "Ajouter à la bibliothèque",
@@ -116,13 +108,11 @@
"book_detail_rescrape": "Réextraire",
"book_detail_scraping": "Extraction en cours…",
"book_detail_in_library": "Dans la bibliothèque",
"chapters_page_title": "Chapitres — {title}",
"chapters_heading": "Chapitres",
"chapters_back_to_book": "Retour au livre",
"chapters_reading_now": "En cours de lecture",
"chapters_empty": "Aucun chapitre extrait pour l'instant.",
"reader_page_title": "{title} — Ch.{n} — libnovel",
"reader_play_narration": "Lire la narration",
"reader_generating_audio": "Génération audio…",
@@ -144,7 +134,6 @@
"reader_auto_next": "Suivant auto",
"reader_speed": "Vitesse",
"reader_preview_notice": "Aperçu — ce chapitre n'a pas été entièrement extrait.",
"profile_page_title": "Profil — libnovel",
"profile_heading": "Profil",
"profile_avatar_label": "Avatar",
@@ -179,7 +168,6 @@
"profile_sessions_heading": "Sessions actives",
"profile_sign_out_all": "Se déconnecter de tous les autres appareils",
"profile_joined": "Inscrit le {date}",
"user_page_title": "{username} — libnovel",
"user_library_heading": "Bibliothèque de {username}",
"user_follow": "Suivre",
@@ -187,13 +175,11 @@
"user_followers": "{n} abonnés",
"user_following": "{n} abonnements",
"user_library_empty": "Aucun livre dans la bibliothèque.",
"error_not_found_title": "Page introuvable",
"error_not_found_body": "La page que vous cherchez n'existe pas.",
"error_generic_title": "Une erreur s'est produite",
"error_go_home": "Accueil",
"error_status": "Erreur {status}",
"admin_scrape_page_title": "Extraction — Admin",
"admin_scrape_heading": "Extraction",
"admin_scrape_catalogue": "Extraire le catalogue",
@@ -211,14 +197,11 @@
"admin_scrape_status_cancelled": "Annulé",
"admin_tasks_heading": "Tâches récentes",
"admin_tasks_empty": "Aucune tâche pour l'instant.",
"admin_audio_page_title": "Audio — Admin",
"admin_audio_heading": "Tâches audio",
"admin_audio_empty": "Aucune tâche audio.",
"admin_changelog_page_title": "Changelog — Admin",
"admin_changelog_heading": "Changelog",
"comments_heading": "Commentaires",
"comments_empty": "Aucun commentaire pour l'instant. Soyez le premier !",
"comments_placeholder": "Écrire un commentaire…",
@@ -232,12 +215,10 @@
"comments_hide_replies": "Masquer les réponses",
"comments_edited": "modifié",
"comments_deleted": "[supprimé]",
"disclaimer_page_title": "Avertissement — libnovel",
"privacy_page_title": "Politique de confidentialité — libnovel",
"dmca_page_title": "DMCA — libnovel",
"terms_page_title": "Conditions d'utilisation — libnovel",
"common_loading": "Chargement…",
"common_error": "Erreur",
"common_save": "Enregistrer",
@@ -251,15 +232,12 @@
"common_no": "Non",
"common_on": "activé",
"common_off": "désactivé",
"locale_switcher_label": "Langue",
"books_empty_library": "Votre bibliothèque est vide.",
"books_empty_discover": "Les livres que vous commencez à lire ou enregistrez depuis",
"books_empty_discover_link": "Découvrir",
"books_empty_discover_suffix": "apparaîtront ici.",
"books_count": "{n} livre{s}",
"catalogue_sort_updated": "Mis à jour",
"catalogue_search_button": "Rechercher",
"catalogue_refresh": "Actualiser",
@@ -292,7 +270,6 @@
"catalogue_scrape_forbidden_badge": "Interdit",
"catalogue_scrape_novel_button": "Extraire",
"catalogue_scraping_novel": "Extraction…",
"book_detail_not_in_library": "pas dans la bibliothèque",
"book_detail_continue_ch": "Continuer ch.{n}",
"book_detail_start_ch1": "Commencer au ch.1",
@@ -328,18 +305,15 @@
"book_detail_rescrape_book": "Réextraire le livre",
"book_detail_less": "Moins",
"book_detail_more": "Plus",
"chapters_search_placeholder": "Rechercher des chapitres…",
"chapters_jump_to": "Aller au Ch.{n}",
"chapters_no_match": "Aucun chapitre ne correspond à « {q} »",
"chapters_none_available": "Aucun chapitre disponible pour l'instant.",
"chapters_reading_indicator": "en cours",
"chapters_result_count": "{n} résultats",
"reader_fetching_chapter": "Récupération du chapitre…",
"reader_words": "{n} mots",
"reader_preview_audio_notice": "Aperçu — audio non disponible pour les livres hors bibliothèque.",
"profile_click_to_change": "Cliquez sur l'avatar pour changer la photo",
"profile_tts_voice": "Voix TTS",
"profile_auto_advance": "Avancer automatiquement au chapitre suivant",
@@ -357,7 +331,6 @@
"profile_updating": "Mise à jour…",
"profile_password_changed_ok": "Mot de passe modifié avec succès.",
"profile_playback_speed": "Vitesse de lecture — {speed}x",
"profile_subscription_heading": "Abonnement",
"profile_plan_pro": "Pro",
"profile_plan_free": "Gratuit",
@@ -369,7 +342,7 @@
"profile_upgrade_monthly": "Mensuel — 6 $ / mois",
"profile_upgrade_annual": "Annuel — 48 $ / an",
"profile_free_limits": "Plan gratuit : 3 chapitres audio par jour, lecture en anglais uniquement.",
"subscribe_page_title": "Passer Pro \u2014 libnovel",
"subscribe_page_title": "Passer Pro libnovel",
"subscribe_heading": "Lisez plus. Écoutez plus.",
"subscribe_subheading": "Passez Pro et débloquez l'expérience libnovel complète.",
"subscribe_monthly_label": "Mensuel",
@@ -389,14 +362,12 @@
"subscribe_benefit_downloads": "Télécharger des chapitres pour une écoute hors ligne",
"subscribe_login_prompt": "Connectez-vous pour vous abonner",
"subscribe_login_cta": "Se connecter",
"user_currently_reading": "En cours de lecture",
"user_library_count": "Bibliothèque ({n})",
"user_joined": "Inscrit le {date}",
"user_followers_label": "abonnés",
"user_following_label": "abonnements",
"user_no_books": "Aucun livre dans la bibliothèque pour l'instant.",
"admin_pages_label": "Pages",
"admin_tools_label": "Outils",
"admin_nav_scrape": "Scrape",
@@ -407,12 +378,12 @@
"admin_nav_text_gen": "Text Gen",
"admin_nav_catalogue_tools": "Catalogue Tools",
"admin_nav_ai_jobs": "Tâches IA",
"admin_nav_notifications": "Notifications",
"admin_nav_errors": "Erreurs",
"admin_nav_analytics": "Analytique",
"admin_nav_logs": "Journaux",
"admin_nav_uptime": "Disponibilité",
"admin_nav_push": "Notifications",
"admin_scrape_status_idle": "Inactif",
"admin_scrape_full_catalogue": "Catalogue complet",
"admin_scrape_single_book": "Livre unique",
@@ -423,25 +394,21 @@
"admin_scrape_start": "Démarrer l'extraction",
"admin_scrape_queuing": "En file d'attente…",
"admin_scrape_running": "En cours…",
"admin_audio_filter_jobs": "Filtrer par slug, voix ou statut…",
"admin_audio_filter_cache": "Filtrer par slug, chapitre ou voix…",
"admin_audio_no_matching_jobs": "Aucun job correspondant.",
"admin_audio_no_jobs": "Aucun job audio pour l'instant.",
"admin_audio_cache_empty": "Cache audio vide.",
"admin_audio_no_cache_results": "Aucun résultat.",
"admin_changelog_gitea": "Releases Gitea",
"admin_changelog_no_releases": "Aucune release trouvée.",
"admin_changelog_load_error": "Impossible de charger les releases : {error}",
"comments_top": "Les meilleures",
"comments_new": "Nouvelles",
"comments_posting": "Publication…",
"comments_login_link": "Connectez-vous",
"comments_login_suffix": "pour laisser un commentaire.",
"comments_anonymous": "Anonyme",
"reader_audio_narration": "Narration Audio",
"reader_playing": "Lecture en cours — contrôles ci-dessous",
"reader_paused": "En pause — contrôles ci-dessous",
@@ -454,7 +421,6 @@
"reader_voice_applies_next": "La nouvelle voix s'appliquera au prochain « Lire la narration ».",
"reader_choose_voice": "Choisir une voix",
"reader_generating_narration": "Génération de la narration…",
"profile_font_family": "Police",
"profile_font_system": "Système",
"profile_font_serif": "Serif",
@@ -464,7 +430,6 @@
"profile_text_size_md": "Normal",
"profile_text_size_lg": "Grand",
"profile_text_size_xl": "Très grand",
"feed_page_title": "Fil — LibNovel",
"feed_heading": "Fil d'abonnements",
"feed_subheading": "Livres lus par vos abonnements",
@@ -477,19 +442,17 @@
"feed_find_users_cta": "Trouver des lecteurs",
"admin_nav_gitea": "Gitea",
"admin_nav_grafana": "Grafana",
"admin_translation_page_title": "Translation \u2014 Admin",
"admin_translation_page_title": "Translation — Admin",
"admin_translation_heading": "Machine Translation",
"admin_translation_tab_enqueue": "Enqueue",
"admin_translation_tab_jobs": "Jobs",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status\u2026",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status",
"admin_translation_no_matching": "No matching jobs.",
"admin_translation_no_jobs": "No translation jobs yet.",
"admin_ai_jobs_page_title": "AI Jobs \u2014 Admin",
"admin_ai_jobs_page_title": "AI Jobs — Admin",
"admin_ai_jobs_heading": "AI Jobs",
"admin_ai_jobs_subheading": "Background AI generation tasks",
"admin_text_gen_page_title": "Text Gen \u2014 Admin",
"admin_text_gen_heading": "Text Generation"
"admin_text_gen_page_title": "Text Gen — Admin",
"admin_text_gen_heading": "Text Generation",
"admin_nav_import": "Import"
}

View File

@@ -1,6 +1,5 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_library": "Perpustakaan",
"nav_catalogue": "Katalog",
"nav_feed": "Umpan",
@@ -11,7 +10,6 @@
"nav_sign_out": "Keluar",
"nav_toggle_menu": "Menu",
"nav_admin_panel": "Panel admin",
"footer_library": "Perpustakaan",
"footer_catalogue": "Katalog",
"footer_feedback": "Masukan",
@@ -20,7 +18,6 @@
"footer_dmca": "DMCA",
"footer_copyright": "© {year} libnovel",
"footer_dev": "dev",
"home_title": "libnovel",
"home_stat_books": "Buku",
"home_stat_chapters": "Bab",
@@ -34,7 +31,6 @@
"home_discover_novels": "Temukan Novel",
"home_via_reader": "via {username}",
"home_chapter_badge": "bab.{n}",
"player_generating": "Membuat… {percent}%",
"player_loading": "Memuat…",
"player_chapters": "Bab",
@@ -58,7 +54,6 @@
"player_auto_next_aria": "Auto-lanjut {state}",
"player_go_to_chapter": "Pergi ke bab",
"player_close": "Tutup pemutar",
"login_page_title": "Masuk — libnovel",
"login_heading": "Masuk ke libnovel",
"login_subheading": "Pilih penyedia untuk melanjutkan",
@@ -68,7 +63,6 @@
"login_error_oauth_state": "Masuk dibatalkan atau kedaluwarsa. Coba lagi.",
"login_error_oauth_failed": "Tidak dapat terhubung ke penyedia. Coba lagi.",
"login_error_oauth_no_email": "Akunmu tidak memiliki alamat email terverifikasi. Tambahkan dan coba lagi.",
"books_page_title": "Perpustakaan — libnovel",
"books_heading": "Perpustakaanmu",
"books_empty_title": "Belum ada buku",
@@ -78,7 +72,6 @@
"books_last_read": "Terakhir: Bab.{n}",
"books_reading_progress": "Bab.{current} / {total}",
"books_remove": "Hapus",
"catalogue_page_title": "Katalog — libnovel",
"catalogue_heading": "Katalog",
"catalogue_search_placeholder": "Cari novel…",
@@ -99,7 +92,6 @@
"catalogue_loading": "Memuat…",
"catalogue_load_more": "Muat lebih banyak",
"catalogue_results_count": "{n} hasil",
"book_detail_page_title": "{title} — libnovel",
"book_detail_signin_to_save": "Masuk untuk menyimpan",
"book_detail_add_to_library": "Tambah ke Perpustakaan",
@@ -116,13 +108,11 @@
"book_detail_rescrape": "Perbarui",
"book_detail_scraping": "Memperbarui…",
"book_detail_in_library": "Ada di Perpustakaan",
"chapters_page_title": "Bab — {title}",
"chapters_heading": "Bab",
"chapters_back_to_book": "Kembali ke buku",
"chapters_reading_now": "Sedang dibaca",
"chapters_empty": "Belum ada bab yang diambil.",
"reader_page_title": "{title} — Bab.{n} — libnovel",
"reader_play_narration": "Putar narasi",
"reader_generating_audio": "Membuat audio…",
@@ -144,7 +134,6 @@
"reader_auto_next": "Auto-lanjut",
"reader_speed": "Kecepatan",
"reader_preview_notice": "Pratinjau — bab ini belum sepenuhnya diambil.",
"profile_page_title": "Profil — libnovel",
"profile_heading": "Profil",
"profile_avatar_label": "Avatar",
@@ -179,7 +168,6 @@
"profile_sessions_heading": "Sesi aktif",
"profile_sign_out_all": "Keluar dari semua perangkat lain",
"profile_joined": "Bergabung {date}",
"user_page_title": "{username} — libnovel",
"user_library_heading": "Perpustakaan {username}",
"user_follow": "Ikuti",
@@ -187,13 +175,11 @@
"user_followers": "{n} pengikut",
"user_following": "{n} mengikuti",
"user_library_empty": "Tidak ada buku di perpustakaan.",
"error_not_found_title": "Halaman tidak ditemukan",
"error_not_found_body": "Halaman yang kamu cari tidak ada.",
"error_generic_title": "Terjadi kesalahan",
"error_go_home": "Ke beranda",
"error_status": "Error {status}",
"admin_scrape_page_title": "Scrape — Admin",
"admin_scrape_heading": "Scrape",
"admin_scrape_catalogue": "Scrape Katalog",
@@ -211,14 +197,11 @@
"admin_scrape_status_cancelled": "Dibatalkan",
"admin_tasks_heading": "Tugas terbaru",
"admin_tasks_empty": "Belum ada tugas.",
"admin_audio_page_title": "Audio — Admin",
"admin_audio_heading": "Tugas Audio",
"admin_audio_empty": "Tidak ada tugas audio.",
"admin_changelog_page_title": "Changelog — Admin",
"admin_changelog_heading": "Changelog",
"comments_heading": "Komentar",
"comments_empty": "Belum ada komentar. Jadilah yang pertama!",
"comments_placeholder": "Tulis komentar…",
@@ -232,12 +215,10 @@
"comments_hide_replies": "Sembunyikan balasan",
"comments_edited": "diedit",
"comments_deleted": "[dihapus]",
"disclaimer_page_title": "Penyangkalan — libnovel",
"privacy_page_title": "Kebijakan Privasi — libnovel",
"dmca_page_title": "DMCA — libnovel",
"terms_page_title": "Syarat Layanan — libnovel",
"common_loading": "Memuat…",
"common_error": "Error",
"common_save": "Simpan",
@@ -251,15 +232,12 @@
"common_no": "Tidak",
"common_on": "aktif",
"common_off": "nonaktif",
"locale_switcher_label": "Bahasa",
"books_empty_library": "Perpustakaanmu kosong.",
"books_empty_discover": "Buku yang mulai kamu baca atau simpan dari",
"books_empty_discover_link": "Temukan",
"books_empty_discover_suffix": "akan muncul di sini.",
"books_count": "{n} buku",
"catalogue_sort_updated": "Diperbarui",
"catalogue_search_button": "Cari",
"catalogue_refresh": "Segarkan",
@@ -292,7 +270,6 @@
"catalogue_scrape_forbidden_badge": "Terlarang",
"catalogue_scrape_novel_button": "Scrape",
"catalogue_scraping_novel": "Scraping…",
"book_detail_not_in_library": "tidak di perpustakaan",
"book_detail_continue_ch": "Lanjutkan bab.{n}",
"book_detail_start_ch1": "Mulai dari bab.1",
@@ -328,18 +305,15 @@
"book_detail_rescrape_book": "Scrape ulang buku",
"book_detail_less": "Lebih sedikit",
"book_detail_more": "Selengkapnya",
"chapters_search_placeholder": "Cari bab…",
"chapters_jump_to": "Loncat ke Bab.{n}",
"chapters_no_match": "Tidak ada bab yang cocok dengan \"{q}\"",
"chapters_none_available": "Belum ada bab tersedia.",
"chapters_reading_indicator": "sedang dibaca",
"chapters_result_count": "{n} hasil",
"reader_fetching_chapter": "Mengambil bab…",
"reader_words": "{n} kata",
"reader_preview_audio_notice": "Pratinjau — audio tidak tersedia untuk buku di luar perpustakaan.",
"profile_click_to_change": "Klik avatar untuk mengganti foto",
"profile_tts_voice": "Suara TTS",
"profile_auto_advance": "Otomatis lanjut ke bab berikutnya",
@@ -357,7 +331,6 @@
"profile_updating": "Memperbarui…",
"profile_password_changed_ok": "Kata sandi berhasil diubah.",
"profile_playback_speed": "Kecepatan pemutaran — {speed}x",
"profile_subscription_heading": "Langganan",
"profile_plan_pro": "Pro",
"profile_plan_free": "Gratis",
@@ -369,7 +342,7 @@
"profile_upgrade_monthly": "Bulanan — $6 / bln",
"profile_upgrade_annual": "Tahunan — $48 / thn",
"profile_free_limits": "Paket gratis: 3 bab audio per hari, hanya bahasa Inggris.",
"subscribe_page_title": "Jadi Pro \u2014 libnovel",
"subscribe_page_title": "Jadi Pro libnovel",
"subscribe_heading": "Baca lebih. Dengarkan lebih.",
"subscribe_subheading": "Tingkatkan ke Pro dan buka pengalaman libnovel sepenuhnya.",
"subscribe_monthly_label": "Bulanan",
@@ -389,14 +362,12 @@
"subscribe_benefit_downloads": "Unduh bab untuk didengarkan secara offline",
"subscribe_login_prompt": "Masuk untuk berlangganan",
"subscribe_login_cta": "Masuk",
"user_currently_reading": "Sedang Dibaca",
"user_library_count": "Perpustakaan ({n})",
"user_joined": "Bergabung {date}",
"user_followers_label": "pengikut",
"user_following_label": "mengikuti",
"user_no_books": "Belum ada buku di perpustakaan.",
"admin_pages_label": "Halaman",
"admin_tools_label": "Alat",
"admin_nav_scrape": "Scrape",
@@ -407,12 +378,12 @@
"admin_nav_text_gen": "Text Gen",
"admin_nav_catalogue_tools": "Catalogue Tools",
"admin_nav_ai_jobs": "Tugas AI",
"admin_nav_notifications": "Notifikasi",
"admin_nav_errors": "Kesalahan",
"admin_nav_analytics": "Analitik",
"admin_nav_logs": "Log",
"admin_nav_uptime": "Uptime",
"admin_nav_push": "Notifikasi",
"admin_scrape_status_idle": "Menunggu",
"admin_scrape_full_catalogue": "Katalog penuh",
"admin_scrape_single_book": "Satu buku",
@@ -423,25 +394,21 @@
"admin_scrape_start": "Mulai scrape",
"admin_scrape_queuing": "Mengantri…",
"admin_scrape_running": "Berjalan…",
"admin_audio_filter_jobs": "Filter berdasarkan slug, suara, atau status…",
"admin_audio_filter_cache": "Filter berdasarkan slug, bab, atau suara…",
"admin_audio_no_matching_jobs": "Tidak ada pekerjaan yang cocok.",
"admin_audio_no_jobs": "Belum ada pekerjaan audio.",
"admin_audio_cache_empty": "Cache audio kosong.",
"admin_audio_no_cache_results": "Tidak ada hasil.",
"admin_changelog_gitea": "Rilis Gitea",
"admin_changelog_no_releases": "Tidak ada rilis.",
"admin_changelog_load_error": "Gagal memuat rilis: {error}",
"comments_top": "Teratas",
"comments_new": "Terbaru",
"comments_posting": "Mengirim…",
"comments_login_link": "Masuk",
"comments_login_suffix": "untuk meninggalkan komentar.",
"comments_anonymous": "Anonim",
"reader_audio_narration": "Narasi Audio",
"reader_playing": "Memutar — kontrol di bawah",
"reader_paused": "Dijeda — kontrol di bawah",
@@ -454,7 +421,6 @@
"reader_voice_applies_next": "Suara baru berlaku pada \"Putar narasi\" berikutnya.",
"reader_choose_voice": "Pilih Suara",
"reader_generating_narration": "Membuat narasi…",
"profile_font_family": "Jenis Font",
"profile_font_system": "Sistem",
"profile_font_serif": "Serif",
@@ -464,7 +430,6 @@
"profile_text_size_md": "Normal",
"profile_text_size_lg": "Besar",
"profile_text_size_xl": "Sangat Besar",
"feed_page_title": "Umpan — LibNovel",
"feed_heading": "Umpan Ikutan",
"feed_subheading": "Buku yang sedang dibaca oleh pengguna yang Anda ikuti",
@@ -477,19 +442,17 @@
"feed_find_users_cta": "Temukan pembaca",
"admin_nav_gitea": "Gitea",
"admin_nav_grafana": "Grafana",
"admin_translation_page_title": "Translation \u2014 Admin",
"admin_translation_page_title": "Translation — Admin",
"admin_translation_heading": "Machine Translation",
"admin_translation_tab_enqueue": "Enqueue",
"admin_translation_tab_jobs": "Jobs",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status\u2026",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status",
"admin_translation_no_matching": "No matching jobs.",
"admin_translation_no_jobs": "No translation jobs yet.",
"admin_ai_jobs_page_title": "AI Jobs \u2014 Admin",
"admin_ai_jobs_page_title": "AI Jobs — Admin",
"admin_ai_jobs_heading": "AI Jobs",
"admin_ai_jobs_subheading": "Background AI generation tasks",
"admin_text_gen_page_title": "Text Gen \u2014 Admin",
"admin_text_gen_heading": "Text Generation"
"admin_text_gen_page_title": "Text Gen — Admin",
"admin_text_gen_heading": "Text Generation",
"admin_nav_import": "Import"
}

View File

@@ -1,6 +1,5 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_library": "Biblioteca",
"nav_catalogue": "Catálogo",
"nav_feed": "Feed",
@@ -11,7 +10,6 @@
"nav_sign_out": "Sair",
"nav_toggle_menu": "Menu",
"nav_admin_panel": "Painel admin",
"footer_library": "Biblioteca",
"footer_catalogue": "Catálogo",
"footer_feedback": "Feedback",
@@ -20,7 +18,6 @@
"footer_dmca": "DMCA",
"footer_copyright": "© {year} libnovel",
"footer_dev": "dev",
"home_title": "libnovel",
"home_stat_books": "Livros",
"home_stat_chapters": "Capítulos",
@@ -34,7 +31,6 @@
"home_discover_novels": "Descobrir Romances",
"home_via_reader": "via {username}",
"home_chapter_badge": "cap.{n}",
"player_generating": "Gerando… {percent}%",
"player_loading": "Carregando…",
"player_chapters": "Capítulos",
@@ -58,7 +54,6 @@
"player_auto_next_aria": "Próximo automático {state}",
"player_go_to_chapter": "Ir para capítulo",
"player_close": "Fechar player",
"login_page_title": "Entrar — libnovel",
"login_heading": "Entrar no libnovel",
"login_subheading": "Escolha um provedor para continuar",
@@ -68,7 +63,6 @@
"login_error_oauth_state": "Login cancelado ou expirado. Tente novamente.",
"login_error_oauth_failed": "Não foi possível conectar ao provedor. Tente novamente.",
"login_error_oauth_no_email": "Sua conta não tem endereço de email verificado. Adicione um e tente novamente.",
"books_page_title": "Biblioteca — libnovel",
"books_heading": "Sua Biblioteca",
"books_empty_title": "Nenhum livro ainda",
@@ -78,7 +72,6 @@
"books_last_read": "Último: Cap.{n}",
"books_reading_progress": "Cap.{current} / {total}",
"books_remove": "Remover",
"catalogue_page_title": "Catálogo — libnovel",
"catalogue_heading": "Catálogo",
"catalogue_search_placeholder": "Pesquisar romances…",
@@ -99,7 +92,6 @@
"catalogue_loading": "Carregando…",
"catalogue_load_more": "Carregar mais",
"catalogue_results_count": "{n} resultados",
"book_detail_page_title": "{title} — libnovel",
"book_detail_signin_to_save": "Entre para salvar",
"book_detail_add_to_library": "Adicionar à Biblioteca",
@@ -116,13 +108,11 @@
"book_detail_rescrape": "Atualizar",
"book_detail_scraping": "Atualizando…",
"book_detail_in_library": "Na Biblioteca",
"chapters_page_title": "Capítulos — {title}",
"chapters_heading": "Capítulos",
"chapters_back_to_book": "Voltar ao livro",
"chapters_reading_now": "Lendo",
"chapters_empty": "Nenhum capítulo extraído ainda.",
"reader_page_title": "{title} — Cap.{n} — libnovel",
"reader_play_narration": "Reproduzir narração",
"reader_generating_audio": "Gerando áudio…",
@@ -144,7 +134,6 @@
"reader_auto_next": "Próximo automático",
"reader_speed": "Velocidade",
"reader_preview_notice": "Prévia — este capítulo não foi totalmente extraído.",
"profile_page_title": "Perfil — libnovel",
"profile_heading": "Perfil",
"profile_avatar_label": "Avatar",
@@ -179,7 +168,6 @@
"profile_sessions_heading": "Sessões ativas",
"profile_sign_out_all": "Sair de todos os outros dispositivos",
"profile_joined": "Entrou em {date}",
"user_page_title": "{username} — libnovel",
"user_library_heading": "Biblioteca de {username}",
"user_follow": "Seguir",
@@ -187,13 +175,11 @@
"user_followers": "{n} seguidores",
"user_following": "{n} seguindo",
"user_library_empty": "Nenhum livro na biblioteca.",
"error_not_found_title": "Página não encontrada",
"error_not_found_body": "A página que você procura não existe.",
"error_generic_title": "Algo deu errado",
"error_go_home": "Ir para início",
"error_status": "Erro {status}",
"admin_scrape_page_title": "Extração — Admin",
"admin_scrape_heading": "Extração",
"admin_scrape_catalogue": "Extrair Catálogo",
@@ -211,14 +197,11 @@
"admin_scrape_status_cancelled": "Cancelado",
"admin_tasks_heading": "Tarefas recentes",
"admin_tasks_empty": "Nenhuma tarefa ainda.",
"admin_audio_page_title": "Áudio — Admin",
"admin_audio_heading": "Tarefas de Áudio",
"admin_audio_empty": "Nenhuma tarefa de áudio.",
"admin_changelog_page_title": "Changelog — Admin",
"admin_changelog_heading": "Changelog",
"comments_heading": "Comentários",
"comments_empty": "Nenhum comentário ainda. Seja o primeiro!",
"comments_placeholder": "Escreva um comentário…",
@@ -232,12 +215,10 @@
"comments_hide_replies": "Ocultar respostas",
"comments_edited": "editado",
"comments_deleted": "[excluído]",
"disclaimer_page_title": "Aviso Legal — libnovel",
"privacy_page_title": "Política de Privacidade — libnovel",
"dmca_page_title": "DMCA — libnovel",
"terms_page_title": "Termos de Serviço — libnovel",
"common_loading": "Carregando…",
"common_error": "Erro",
"common_save": "Salvar",
@@ -251,15 +232,12 @@
"common_no": "Não",
"common_on": "ativado",
"common_off": "desativado",
"locale_switcher_label": "Idioma",
"books_empty_library": "Sua biblioteca está vazia.",
"books_empty_discover": "Livros que você começar a ler ou salvar de",
"books_empty_discover_link": "Descobrir",
"books_empty_discover_suffix": "aparecerão aqui.",
"books_count": "{n} livro{s}",
"catalogue_sort_updated": "Atualizado",
"catalogue_search_button": "Pesquisar",
"catalogue_refresh": "Atualizar",
@@ -292,7 +270,6 @@
"catalogue_scrape_forbidden_badge": "Proibido",
"catalogue_scrape_novel_button": "Extrair",
"catalogue_scraping_novel": "Extraindo…",
"book_detail_not_in_library": "não está na biblioteca",
"book_detail_continue_ch": "Continuar cap.{n}",
"book_detail_start_ch1": "Começar pelo cap.1",
@@ -328,18 +305,15 @@
"book_detail_rescrape_book": "Reextrair livro",
"book_detail_less": "Menos",
"book_detail_more": "Mais",
"chapters_search_placeholder": "Pesquisar capítulos…",
"chapters_jump_to": "Ir para Cap.{n}",
"chapters_no_match": "Nenhum capítulo encontrado para \"{q}\"",
"chapters_none_available": "Nenhum capítulo disponível ainda.",
"chapters_reading_indicator": "lendo",
"chapters_result_count": "{n} resultados",
"reader_fetching_chapter": "Buscando capítulo…",
"reader_words": "{n} palavras",
"reader_preview_audio_notice": "Prévia — áudio não disponível para livros fora da biblioteca.",
"profile_click_to_change": "Clique no avatar para mudar a foto",
"profile_tts_voice": "Voz TTS",
"profile_auto_advance": "Avançar automaticamente para o próximo capítulo",
@@ -357,7 +331,6 @@
"profile_updating": "Atualizando…",
"profile_password_changed_ok": "Senha alterada com sucesso.",
"profile_playback_speed": "Velocidade de reprodução — {speed}x",
"profile_subscription_heading": "Assinatura",
"profile_plan_pro": "Pro",
"profile_plan_free": "Gratuito",
@@ -369,7 +342,7 @@
"profile_upgrade_monthly": "Mensal — $6 / mês",
"profile_upgrade_annual": "Anual — $48 / ano",
"profile_free_limits": "Plano gratuito: 3 capítulos de áudio por dia, somente inglês.",
"subscribe_page_title": "Seja Pro \u2014 libnovel",
"subscribe_page_title": "Seja Pro libnovel",
"subscribe_heading": "Leia mais. Ouça mais.",
"subscribe_subheading": "Torne-se Pro e desbloqueie a experiência completa do libnovel.",
"subscribe_monthly_label": "Mensal",
@@ -389,14 +362,12 @@
"subscribe_benefit_downloads": "Baixe capítulos para ouvir offline",
"subscribe_login_prompt": "Entre para assinar",
"subscribe_login_cta": "Entrar",
"user_currently_reading": "Lendo Agora",
"user_library_count": "Biblioteca ({n})",
"user_joined": "Entrou em {date}",
"user_followers_label": "seguidores",
"user_following_label": "seguindo",
"user_no_books": "Nenhum livro na biblioteca ainda.",
"admin_pages_label": "Páginas",
"admin_tools_label": "Ferramentas",
"admin_nav_scrape": "Scrape",
@@ -407,12 +378,12 @@
"admin_nav_text_gen": "Text Gen",
"admin_nav_catalogue_tools": "Catalogue Tools",
"admin_nav_ai_jobs": "Tarefas de IA",
"admin_nav_notifications": "Notificações",
"admin_nav_errors": "Erros",
"admin_nav_analytics": "Análise",
"admin_nav_logs": "Logs",
"admin_nav_uptime": "Uptime",
"admin_nav_push": "Notificações",
"admin_scrape_status_idle": "Ocioso",
"admin_scrape_full_catalogue": "Catálogo completo",
"admin_scrape_single_book": "Livro único",
@@ -423,25 +394,21 @@
"admin_scrape_start": "Iniciar extração",
"admin_scrape_queuing": "Na fila…",
"admin_scrape_running": "Executando…",
"admin_audio_filter_jobs": "Filtrar por slug, voz ou status…",
"admin_audio_filter_cache": "Filtrar por slug, capítulo ou voz…",
"admin_audio_no_matching_jobs": "Nenhum job correspondente.",
"admin_audio_no_jobs": "Nenhum job de áudio ainda.",
"admin_audio_cache_empty": "Cache de áudio vazio.",
"admin_audio_no_cache_results": "Sem resultados.",
"admin_changelog_gitea": "Releases do Gitea",
"admin_changelog_no_releases": "Nenhum release encontrado.",
"admin_changelog_load_error": "Não foi possível carregar os releases: {error}",
"comments_top": "Mais votados",
"comments_new": "Novos",
"comments_posting": "Publicando…",
"comments_login_link": "Entre",
"comments_login_suffix": "para deixar um comentário.",
"comments_anonymous": "Anônimo",
"reader_audio_narration": "Narração em Áudio",
"reader_playing": "Reproduzindo — controles abaixo",
"reader_paused": "Pausado — controles abaixo",
@@ -454,7 +421,6 @@
"reader_voice_applies_next": "A nova voz será aplicada no próximo \"Reproduzir narração\".",
"reader_choose_voice": "Escolher Voz",
"reader_generating_narration": "Gerando narração…",
"profile_font_family": "Fonte",
"profile_font_system": "Sistema",
"profile_font_serif": "Serif",
@@ -464,7 +430,6 @@
"profile_text_size_md": "Normal",
"profile_text_size_lg": "Grande",
"profile_text_size_xl": "Muito grande",
"feed_page_title": "Feed — LibNovel",
"feed_heading": "Feed de seguidos",
"feed_subheading": "Livros que seus seguidos estão lendo",
@@ -477,19 +442,17 @@
"feed_find_users_cta": "Encontrar leitores",
"admin_nav_gitea": "Gitea",
"admin_nav_grafana": "Grafana",
"admin_translation_page_title": "Translation \u2014 Admin",
"admin_translation_page_title": "Translation — Admin",
"admin_translation_heading": "Machine Translation",
"admin_translation_tab_enqueue": "Enqueue",
"admin_translation_tab_jobs": "Jobs",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status\u2026",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status",
"admin_translation_no_matching": "No matching jobs.",
"admin_translation_no_jobs": "No translation jobs yet.",
"admin_ai_jobs_page_title": "AI Jobs \u2014 Admin",
"admin_ai_jobs_page_title": "AI Jobs — Admin",
"admin_ai_jobs_heading": "AI Jobs",
"admin_ai_jobs_subheading": "Background AI generation tasks",
"admin_text_gen_page_title": "Text Gen \u2014 Admin",
"admin_text_gen_heading": "Text Generation"
"admin_text_gen_page_title": "Text Gen — Admin",
"admin_text_gen_heading": "Text Generation",
"admin_nav_import": "Import"
}

View File

@@ -1,6 +1,5 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_library": "Библиотека",
"nav_catalogue": "Каталог",
"nav_feed": "Лента",
@@ -11,7 +10,6 @@
"nav_sign_out": "Выйти",
"nav_toggle_menu": "Меню",
"nav_admin_panel": "Панель администратора",
"footer_library": "Библиотека",
"footer_catalogue": "Каталог",
"footer_feedback": "Обратная связь",
@@ -20,7 +18,6 @@
"footer_dmca": "DMCA",
"footer_copyright": "© {year} libnovel",
"footer_dev": "dev",
"home_title": "libnovel",
"home_stat_books": "Книги",
"home_stat_chapters": "Главы",
@@ -34,7 +31,6 @@
"home_discover_novels": "Открыть новеллы",
"home_via_reader": "от {username}",
"home_chapter_badge": "гл.{n}",
"player_generating": "Генерация… {percent}%",
"player_loading": "Загрузка…",
"player_chapters": "Главы",
@@ -58,7 +54,6 @@
"player_auto_next_aria": "Автопереход {state}",
"player_go_to_chapter": "Перейти к главе",
"player_close": "Закрыть плеер",
"login_page_title": "Вход — libnovel",
"login_heading": "Войти в libnovel",
"login_subheading": "Выберите провайдера для входа",
@@ -68,7 +63,6 @@
"login_error_oauth_state": "Вход отменён или истёк срок действия. Попробуйте снова.",
"login_error_oauth_failed": "Не удалось подключиться к провайдеру. Попробуйте снова.",
"login_error_oauth_no_email": "У вашего аккаунта нет подтверждённого email. Добавьте его и повторите попытку.",
"books_page_title": "Библиотека — libnovel",
"books_heading": "Ваша библиотека",
"books_empty_title": "Книг пока нет",
@@ -78,7 +72,6 @@
"books_last_read": "Последнее: гл.{n}",
"books_reading_progress": "Гл.{current} / {total}",
"books_remove": "Удалить",
"catalogue_page_title": "Каталог — libnovel",
"catalogue_heading": "Каталог",
"catalogue_search_placeholder": "Поиск новелл…",
@@ -99,7 +92,6 @@
"catalogue_loading": "Загрузка…",
"catalogue_load_more": "Загрузить ещё",
"catalogue_results_count": "{n} результатов",
"book_detail_page_title": "{title} — libnovel",
"book_detail_signin_to_save": "Войдите, чтобы сохранить",
"book_detail_add_to_library": "В библиотеку",
@@ -116,13 +108,11 @@
"book_detail_rescrape": "Обновить",
"book_detail_scraping": "Обновление…",
"book_detail_in_library": "В библиотеке",
"chapters_page_title": "Главы — {title}",
"chapters_heading": "Главы",
"chapters_back_to_book": "К книге",
"chapters_reading_now": "Читается",
"chapters_empty": "Главы ещё не загружены.",
"reader_page_title": "{title} — Гл.{n} — libnovel",
"reader_play_narration": "Воспроизвести озвучку",
"reader_generating_audio": "Генерация аудио…",
@@ -144,7 +134,6 @@
"reader_auto_next": "Автопереход",
"reader_speed": "Скорость",
"reader_preview_notice": "Предпросмотр — эта глава не полностью загружена.",
"profile_page_title": "Профиль — libnovel",
"profile_heading": "Профиль",
"profile_avatar_label": "Аватар",
@@ -179,7 +168,6 @@
"profile_sessions_heading": "Активные сессии",
"profile_sign_out_all": "Выйти на всех других устройствах",
"profile_joined": "Зарегистрирован {date}",
"user_page_title": "{username} — libnovel",
"user_library_heading": "Библиотека {username}",
"user_follow": "Подписаться",
@@ -187,13 +175,11 @@
"user_followers": "{n} подписчиков",
"user_following": "{n} подписок",
"user_library_empty": "В библиотеке нет книг.",
"error_not_found_title": "Страница не найдена",
"error_not_found_body": "Запрошенная страница не существует.",
"error_generic_title": "Что-то пошло не так",
"error_go_home": "На главную",
"error_status": "Ошибка {status}",
"admin_scrape_page_title": "Парсинг — Админ",
"admin_scrape_heading": "Парсинг",
"admin_scrape_catalogue": "Парсинг каталога",
@@ -211,14 +197,11 @@
"admin_scrape_status_cancelled": "Отменено",
"admin_tasks_heading": "Последние задачи",
"admin_tasks_empty": "Задач пока нет.",
"admin_audio_page_title": "Аудио — Админ",
"admin_audio_heading": "Аудио задачи",
"admin_audio_empty": "Аудио задач нет.",
"admin_changelog_page_title": "Changelog — Админ",
"admin_changelog_heading": "Changelog",
"comments_heading": "Комментарии",
"comments_empty": "Комментариев пока нет. Будьте первым!",
"comments_placeholder": "Написать комментарий…",
@@ -232,12 +215,10 @@
"comments_hide_replies": "Скрыть ответы",
"comments_edited": "изменено",
"comments_deleted": "[удалено]",
"disclaimer_page_title": "Отказ от ответственности — libnovel",
"privacy_page_title": "Политика конфиденциальности — libnovel",
"dmca_page_title": "DMCA — libnovel",
"terms_page_title": "Условия использования — libnovel",
"common_loading": "Загрузка…",
"common_error": "Ошибка",
"common_save": "Сохранить",
@@ -251,15 +232,12 @@
"common_no": "Нет",
"common_on": "вкл.",
"common_off": "выкл.",
"locale_switcher_label": "Язык",
"books_empty_library": "Ваша библиотека пуста.",
"books_empty_discover": "Книги, которые вы начнёте читать или сохраните из",
"books_empty_discover_link": "Каталога",
"books_empty_discover_suffix": "появятся здесь.",
"books_count": "{n} книг{s}",
"catalogue_sort_updated": "По дате обновления",
"catalogue_search_button": "Поиск",
"catalogue_refresh": "Обновить",
@@ -292,7 +270,6 @@
"catalogue_scrape_forbidden_badge": "Запрещено",
"catalogue_scrape_novel_button": "Парсить",
"catalogue_scraping_novel": "Парсинг…",
"book_detail_not_in_library": "не в библиотеке",
"book_detail_continue_ch": "Продолжить гл.{n}",
"book_detail_start_ch1": "Начать с гл.1",
@@ -328,18 +305,15 @@
"book_detail_rescrape_book": "Перепарсить книгу",
"book_detail_less": "Скрыть",
"book_detail_more": "Ещё",
"chapters_search_placeholder": "Поиск глав…",
"chapters_jump_to": "Перейти к гл.{n}",
"chapters_no_match": "Главы по запросу «{q}» не найдены",
"chapters_none_available": "Глав пока нет.",
"chapters_reading_indicator": "читается",
"chapters_result_count": "{n} результатов",
"reader_fetching_chapter": "Загрузка главы…",
"reader_words": "{n} слов",
"reader_preview_audio_notice": "Предпросмотр — аудио недоступно для книг вне библиотеки.",
"profile_click_to_change": "Нажмите на аватар для смены фото",
"profile_tts_voice": "Голос TTS",
"profile_auto_advance": "Автопереход к следующей главе",
@@ -357,7 +331,6 @@
"profile_updating": "Обновление…",
"profile_password_changed_ok": "Пароль успешно изменён.",
"profile_playback_speed": "Скорость воспроизведения — {speed}x",
"profile_subscription_heading": "Подписка",
"profile_plan_pro": "Pro",
"profile_plan_free": "Бесплатно",
@@ -369,7 +342,7 @@
"profile_upgrade_monthly": "Ежемесячно — $6 / мес",
"profile_upgrade_annual": "Ежегодно — $48 / год",
"profile_free_limits": "Бесплатный план: 3 аудиоглавы в день, только английский.",
"subscribe_page_title": "Перейти на Pro \u2014 libnovel",
"subscribe_page_title": "Перейти на Pro libnovel",
"subscribe_heading": "Читайте больше. Слушайте больше.",
"subscribe_subheading": "Перейдите на Pro и откройте полный опыт libnovel.",
"subscribe_monthly_label": "Ежемесячно",
@@ -389,14 +362,12 @@
"subscribe_benefit_downloads": "Скачивайте главы для прослушивания офлайн",
"subscribe_login_prompt": "Войдите, чтобы оформить подписку",
"subscribe_login_cta": "Войти",
"user_currently_reading": "Сейчас читает",
"user_library_count": "Библиотека ({n})",
"user_joined": "Зарегистрирован {date}",
"user_followers_label": "подписчиков",
"user_following_label": "подписок",
"user_no_books": "Книг в библиотеке пока нет.",
"admin_pages_label": "Страницы",
"admin_tools_label": "Инструменты",
"admin_nav_scrape": "Скрейпинг",
@@ -407,12 +378,12 @@
"admin_nav_text_gen": "Text Gen",
"admin_nav_catalogue_tools": "Catalogue Tools",
"admin_nav_ai_jobs": "Задачи ИИ",
"admin_nav_notifications": "Уведомления",
"admin_nav_errors": "Ошибки",
"admin_nav_analytics": "Аналитика",
"admin_nav_logs": "Логи",
"admin_nav_uptime": "Мониторинг",
"admin_nav_push": "Уведомления",
"admin_scrape_status_idle": "Ожидание",
"admin_scrape_full_catalogue": "Полный каталог",
"admin_scrape_single_book": "Одна книга",
@@ -423,25 +394,21 @@
"admin_scrape_start": "Начать парсинг",
"admin_scrape_queuing": "В очереди…",
"admin_scrape_running": "Выполняется…",
"admin_audio_filter_jobs": "Фильтр по slug, голосу или статусу…",
"admin_audio_filter_cache": "Фильтр по slug, главе или голосу…",
"admin_audio_no_matching_jobs": "Заданий не найдено.",
"admin_audio_no_jobs": "Аудиозаданий пока нет.",
"admin_audio_cache_empty": "Аудиокэш пуст.",
"admin_audio_no_cache_results": "Результатов нет.",
"admin_changelog_gitea": "Релизы Gitea",
"admin_changelog_no_releases": "Релизов не найдено.",
"admin_changelog_load_error": "Не удалось загрузить релизы: {error}",
"comments_top": "Лучшие",
"comments_new": "Новые",
"comments_posting": "Отправка…",
"comments_login_link": "Войдите",
"comments_login_suffix": "чтобы оставить комментарий.",
"comments_anonymous": "Аноним",
"reader_audio_narration": "Аудионарратив",
"reader_playing": "Воспроизводится — управление ниже",
"reader_paused": "Пауза — управление ниже",
@@ -454,7 +421,6 @@
"reader_voice_applies_next": "Новый голос применится при следующем нажатии «Воспроизвести».",
"reader_choose_voice": "Выбрать голос",
"reader_generating_narration": "Генерация озвучки…",
"profile_font_family": "Шрифт",
"profile_font_system": "Системный",
"profile_font_serif": "Serif",
@@ -464,7 +430,6 @@
"profile_text_size_md": "Нормальный",
"profile_text_size_lg": "Большой",
"profile_text_size_xl": "Очень большой",
"feed_page_title": "Лента — LibNovel",
"feed_heading": "Лента подписок",
"feed_subheading": "Книги, которые читают ваши подписки",
@@ -477,19 +442,17 @@
"feed_find_users_cta": "Найти читателей",
"admin_nav_gitea": "Gitea",
"admin_nav_grafana": "Grafana",
"admin_translation_page_title": "Translation \u2014 Admin",
"admin_translation_page_title": "Translation — Admin",
"admin_translation_heading": "Machine Translation",
"admin_translation_tab_enqueue": "Enqueue",
"admin_translation_tab_jobs": "Jobs",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status\u2026",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status",
"admin_translation_no_matching": "No matching jobs.",
"admin_translation_no_jobs": "No translation jobs yet.",
"admin_ai_jobs_page_title": "AI Jobs \u2014 Admin",
"admin_ai_jobs_page_title": "AI Jobs — Admin",
"admin_ai_jobs_heading": "AI Jobs",
"admin_ai_jobs_subheading": "Background AI generation tasks",
"admin_text_gen_page_title": "Text Gen \u2014 Admin",
"admin_text_gen_heading": "Text Generation"
"admin_text_gen_page_title": "Text Gen — Admin",
"admin_text_gen_heading": "Text Generation",
"admin_nav_import": "Import"
}

3975
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -29,8 +29,6 @@
"vite": "^7.3.1"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1005.0",
"@aws-sdk/s3-request-presigner": "^3.1005.0",
"@grafana/faro-web-sdk": "^2.3.1",
"@inlang/paraglide-js": "^2.15.1",
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",

View File

@@ -100,13 +100,13 @@
/* ── Forest theme — dark green ────────────────────────────────────────── */
[data-theme="forest"] {
--color-brand: #4ade80; /* green-400 */
--color-brand-dim: #16a34a; /* green-600 */
--color-brand-dim: #22c55e; /* green-500 — brighter than green-600 for hover */
--color-surface: #0a130d; /* custom near-black green */
--color-surface-2: #111c14; /* custom dark green */
--color-surface-3: #1a2e1e; /* custom mid green */
--color-muted: #6b9a77; /* custom muted green */
--color-text: #e8f5e9; /* custom light green-tinted white */
--color-border: #1e3a24; /* custom green border */
--color-surface-2: #14201a; /* custom dark green — lifted slightly for card depth */
--color-surface-3: #1f3326; /* custom mid green — more contrast from surface-2 */
--color-muted: #a3c9a8; /* lightened: readable body text, still green-tinted */
--color-text: #eaf5eb; /* near-white with green tint */
--color-border: #2c4e34; /* more visible green border */
--color-danger: #f87171; /* red-400 */
--color-success: #4ade80; /* green-400 */
}
@@ -250,6 +250,52 @@ html {
animation: progress-bar 4s cubic-bezier(0.1, 0.05, 0.1, 1) forwards;
}
/* ── Logo animation classes (used in nav + admin preview) ───────────── */
@keyframes logo-glow-pulse {
0%, 100% { text-shadow: 0 0 6px color-mix(in srgb, var(--color-brand) 60%, transparent); }
50% { text-shadow: 0 0 18px color-mix(in srgb, var(--color-brand) 90%, transparent), 0 0 32px color-mix(in srgb, var(--color-brand) 40%, transparent); }
}
.logo-anim-glow {
animation: logo-glow-pulse 2.4s ease-in-out infinite;
}
@keyframes logo-shimmer {
0% { background-position: -200% center; }
100% { background-position: 200% center; }
}
.logo-anim-shimmer {
background: linear-gradient(
90deg,
var(--color-brand) 0%,
color-mix(in srgb, var(--color-brand) 40%, white) 40%,
var(--color-brand) 50%,
color-mix(in srgb, var(--color-brand) 40%, white) 60%,
var(--color-brand) 100%
);
background-size: 200% auto;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: logo-shimmer 2.2s linear infinite;
}
@keyframes logo-pulse-scale {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.06); }
}
.logo-anim-pulse {
display: inline-block;
animation: logo-pulse-scale 1.8s ease-in-out infinite;
}
@keyframes logo-rainbow {
0% { filter: hue-rotate(0deg); }
100% { filter: hue-rotate(360deg); }
}
.logo-anim-rainbow {
animation: logo-rainbow 4s linear infinite;
}
/* ── Respect reduced motion — disable all decorative animations ─────── */
@media (prefers-reduced-motion: reduce) {
*,

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32" />
<link rel="icon" type="image/png" href="/favicon-32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="/favicon-16.png" sizes="16x16" />

View File

@@ -36,6 +36,14 @@ import type { Voice } from '$lib/types';
export type AudioStatus = 'idle' | 'loading' | 'generating' | 'ready' | 'error';
export type NextStatus = 'none' | 'prefetching' | 'prefetched' | 'failed';
/**
* 'stream' Use /api/audio-stream: audio starts playing within seconds,
* stream is saved to MinIO concurrently. No runner task needed.
* 'generate' Legacy mode: queue a runner task, poll until done, then play
* from the presigned MinIO URL. Needed for CF AI voices which
* do not support native streaming.
*/
export type AudioMode = 'stream' | 'generate';
class AudioStore {
// ── What is loaded ──────────────────────────────────────────────────────
@@ -46,6 +54,13 @@ class AudioStore {
voice = $state('af_bella');
speed = $state(1.0);
/**
* Playback mode:
* 'stream' pipe from /api/audio-stream (low latency, saves concurrently)
* 'generate' queue runner task, poll, then play presigned URL (CF AI / legacy)
*/
audioMode = $state<AudioMode>('stream');
/** Cover image URL for the currently loaded book. */
cover = $state('');
@@ -155,6 +170,21 @@ class AudioStore {
return this.status === 'ready' || this.status === 'generating' || this.status === 'loading';
}
/**
* When true the persistent mini-bar in +layout.svelte is hidden.
* Set by the chapter reader page when playerStyle is 'float' or 'minimal'
* so the in-page player is the sole control surface.
* Cleared when leaving the chapter page (page destroy / onDestroy effect).
*/
suppressMiniBar = $state(false);
/**
* Position of the draggable float overlay (bottom-right anchor offsets).
* Stored here (module singleton) so the position survives chapter navigation.
* x > 0 = moved left; y > 0 = moved up.
*/
floatPos = $state({ x: 0, y: 0 });
/** True when the currently loaded track matches slug+chapter */
isCurrentChapter(slug: string, chapter: number): boolean {
return this.slug === slug && this.chapter === chapter;

View File

@@ -50,10 +50,12 @@
import { audioStore } from '$lib/audio.svelte';
import { goto } from '$app/navigation';
import { untrack } from 'svelte';
import { Button } from '$lib/components/ui/button';
import { cn } from '$lib/utils';
import type { Voice } from '$lib/types';
import * as m from '$lib/paraglide/messages.js';
import ChapterPickerOverlay from '$lib/components/ChapterPickerOverlay.svelte';
interface Props {
slug: string;
@@ -70,8 +72,11 @@
voices?: Voice[];
/** Called when the server returns 402 (free daily limit reached). */
onProRequired?: () => void;
/** Visual style of the player card. 'standard' = inline card; 'float' = draggable overlay. */
playerStyle?: 'standard' | 'float';
/** Visual style of the player card.
* 'standard' = full inline card with voice/chapter controls;
* 'minimal' = compact single-row bar (play + seek + time only);
* 'float' = draggable overlay anchored bottom-right. */
playerStyle?: 'standard' | 'minimal' | 'float';
/** Approximate word count for the chapter, used to show estimated listen time in the idle state. */
wordCount?: number;
}
@@ -107,22 +112,10 @@
// ── Chapter picker state ─────────────────────────────────────────────────
let showChapterPanel = $state(false);
let chapterSearch = $state('');
const filteredChapters = $derived(
chapterSearch.trim() === ''
? audioStore.chapters
: audioStore.chapters.filter((ch) =>
(ch.title || `Chapter ${ch.number}`)
.toLowerCase()
.includes(chapterSearch.toLowerCase()) ||
String(ch.number).includes(chapterSearch)
)
);
function playChapter(chapterNumber: number) {
audioStore.autoStartChapter = chapterNumber;
showChapterPanel = false;
chapterSearch = '';
goto(`/books/${slug}/chapters/${chapterNumber}`);
}
@@ -293,7 +286,7 @@
// Close panels on Escape.
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (showChapterPanel) { showChapterPanel = false; chapterSearch = ''; }
if (showChapterPanel) { showChapterPanel = false; }
else { stopSample(); showVoicePanel = false; }
}
}
@@ -613,41 +606,95 @@
return;
}
// Slow path: audio not yet in MinIO.
//
// For Kokoro / PocketTTS: always use the streaming endpoint so audio
// starts playing within seconds. The stream handler checks MinIO first
// (fast redirect if already cached) and otherwise generates + uploads
// concurrently. Even if the async runner is already working on this
// chapter, the stream will redirect to MinIO the moment the runner
// finishes — no harmful double-generation occurs because the backend
// deduplications via AudioExists on the next request.
if (!voice.startsWith('cfai:')) {
// PocketTTS outputs raw WAV — skip the ffmpeg transcode entirely.
// WAV (PCM) is natively supported on all platforms including iOS Safari.
// Kokoro and CF AI output MP3 natively, so keep mp3 for those.
const isPocketTTS = voices.some((v) => v.id === voice && v.engine === 'pocket-tts');
const format = isPocketTTS ? 'wav' : 'mp3';
const qs = new URLSearchParams({ voice, format });
const streamUrl = `/api/audio-stream/${slug}/${chapter}?${qs}`;
// HEAD probe: check paywall without triggering generation.
const headRes = await fetch(streamUrl, { method: 'HEAD' }).catch(() => null);
if (headRes?.status === 402) {
// Slow path: audio not yet in MinIO.
//
// For Kokoro / PocketTTS in 'stream' mode: use the streaming endpoint so
// audio starts playing within seconds. The stream handler checks MinIO
// first (fast redirect if already cached) and otherwise generates +
// uploads concurrently.
//
// In 'generate' mode (user preference): queue a runner task and poll,
// same as CF AI — audio plays only after the full file is ready in MinIO.
if (!voice.startsWith('cfai:') && audioStore.audioMode === 'stream') {
// PocketTTS outputs raw WAV — skip the ffmpeg transcode entirely.
// WAV (PCM) is natively supported on all platforms including iOS Safari.
// Kokoro and CF AI output MP3 natively, so keep mp3 for those.
const isPocketTTS = voices.some((v) => v.id === voice && v.engine === 'pocket-tts');
const format = isPocketTTS ? 'wav' : 'mp3';
const qs = new URLSearchParams({ voice, format });
const streamUrl = `/api/audio-stream/${slug}/${chapter}?${qs}`;
// HEAD probe: check paywall without triggering generation.
const headRes = await fetch(streamUrl, { method: 'HEAD' }).catch(() => null);
if (headRes?.status === 402) {
audioStore.status = 'idle';
onProRequired?.();
return;
}
audioStore.audioUrl = streamUrl;
audioStore.status = 'ready';
maybeStartPrefetch();
return;
}
// Non-CF AI voices in 'generate' mode: queue runner task, show progress,
// wait for full audio in MinIO before playing (same as CF AI but no preview).
if (!voice.startsWith('cfai:')) {
audioStore.status = 'generating';
audioStore.isPreview = false;
startProgress();
if (!presignResult.enqueued) {
const res = await fetch(`/api/audio/${slug}/${chapter}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice })
});
if (res.status === 402) {
audioStore.status = 'idle';
stopProgress();
onProRequired?.();
return;
}
audioStore.audioUrl = streamUrl;
audioStore.status = 'ready';
maybeStartPrefetch();
return;
if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`);
if (res.status === 200) {
await res.body?.cancel();
await finishProgress();
const doneUrl = await tryPresign(slug, chapter, voice);
if (!doneUrl.ready) throw new Error('Audio generated but presign returned 404');
audioStore.audioUrl = doneUrl.url;
audioStore.status = 'ready';
restoreSavedAudioTime();
maybeStartPrefetch();
return;
}
// 202 — runner task enqueued, fall through to poll.
}
// CF AI voices: use preview/swap strategy.
// 1. Fetch a short ~1-2 min preview clip from the first text chunk
// so playback starts immediately — no more waiting behind a spinner.
// 2. Meanwhile keep polling the full audio job; when it finishes,
// swap the <audio> src to the full URL preserving currentTime.
const final = await pollAudioStatus(slug, chapter, voice);
if (final.status === 'failed') {
throw new Error(
`Generation failed: ${(final as { error?: string }).error ?? 'unknown error'}`
);
}
await finishProgress();
const doneUrl = await tryPresign(slug, chapter, voice);
if (!doneUrl.ready) throw new Error('Audio generated but presign returned 404');
audioStore.audioUrl = doneUrl.url;
audioStore.status = 'ready';
restoreSavedAudioTime();
maybeStartPrefetch();
return;
}
// CF AI voices: use preview/swap strategy.
// 1. Fetch a short ~1-2 min preview clip from the first text chunk
// so playback starts immediately — no more waiting behind a spinner.
// 2. Meanwhile keep polling the full audio job; when it finishes,
// swap the <audio> src to the full URL preserving currentTime.
audioStore.status = 'generating';
audioStore.isPreview = false;
startProgress();
@@ -899,24 +946,86 @@
}
// ── Float player drag state ──────────────────────────────────────────────
/** Position of the floating overlay (bottom-right anchor by default). */
let floatPos = $state({ x: 0, y: 0 });
// floatPos lives on audioStore (singleton) so position survives chapter navigation.
// Coordinate system: x/y are offsets from bottom-right corner (positive = toward center).
// right = calc(1rem + {-x}px) → x=0 means right:1rem, x=-50 means right:3.125rem
// bottom = calc(1rem + {-y}px) → y=0 means bottom:1rem
//
// To keep the circle in the viewport we clamp so that the element never goes
// outside any edge. Circle size = 56px (w-14), margin = 16px (1rem).
const FLOAT_SIZE = 56; // px — must match w-14
const FLOAT_MARGIN = 16; // px — 1rem
function clampFloatPos(x: number, y: number): { x: number; y: number } {
const vw = typeof window !== 'undefined' ? window.innerWidth : 400;
const vh = typeof window !== 'undefined' ? window.innerHeight : 800;
// right edge: element right = 1rem - x ≥ 0 → x ≤ FLOAT_MARGIN
const maxX = FLOAT_MARGIN;
// left edge: element right + size ≤ vw → right = 1rem - x → 1rem - x + size ≤ vw
// x ≥ FLOAT_MARGIN + FLOAT_SIZE - vw
const minX = FLOAT_MARGIN + FLOAT_SIZE - vw;
// top edge: element bottom + size ≤ vh → bottom = 1rem - y → 1rem - y + size ≤ vh
// y ≥ FLOAT_MARGIN + FLOAT_SIZE - vh
const minY = FLOAT_MARGIN + FLOAT_SIZE - vh;
// bottom edge: element bottom = 1rem - y ≥ 0 → y ≤ FLOAT_MARGIN
const maxY = FLOAT_MARGIN;
return {
x: Math.max(minX, Math.min(maxX, x)),
y: Math.max(minY, Math.min(maxY, y)),
};
}
let floatDragging = $state(false);
let floatDragStart = $state({ mx: 0, my: 0, ox: 0, oy: 0 });
// Track total pointer movement to distinguish tap vs drag
let floatMoved = $state(false);
function onFloatPointerDown(e: PointerEvent) {
e.stopPropagation();
floatDragging = true;
floatDragStart = { mx: e.clientX, my: e.clientY, ox: floatPos.x, oy: floatPos.y };
floatMoved = false;
floatDragStart = { mx: e.clientX, my: e.clientY, ox: audioStore.floatPos.x, oy: audioStore.floatPos.y };
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
function onFloatPointerMove(e: PointerEvent) {
if (!floatDragging) return;
floatPos = {
x: floatDragStart.ox + (e.clientX - floatDragStart.mx),
y: floatDragStart.oy + (e.clientY - floatDragStart.my)
const dx = e.clientX - floatDragStart.mx;
const dy = e.clientY - floatDragStart.my;
// Only start moving if dragged > 6px to preserve tap detection
if (!floatMoved && Math.hypot(dx, dy) < 6) return;
floatMoved = true;
// right = MARGIN - x → drag right (dx>0) should decrease right → x increases → x = ox + dx
// bottom = MARGIN - y → drag down (dy>0) should decrease bottom → y increases → y = oy + dy
const raw = {
x: floatDragStart.ox + dx,
y: floatDragStart.oy + dy,
};
audioStore.floatPos = clampFloatPos(raw.x, raw.y);
}
function onFloatPointerUp() { floatDragging = false; }
function onFloatPointerUp(e: PointerEvent) {
if (!floatDragging) return;
if (floatDragging && !floatMoved) {
// Tap: toggle play/pause
audioStore.toggleRequest++;
}
floatDragging = false;
try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* ignore */ }
}
// Clamp saved position to viewport on mount and on resize.
// Use untrack() when reading floatPos to avoid a reactive loop
// (reading + writing the same state inside $effect would re-trigger forever).
$effect(() => {
if (typeof window === 'undefined') return;
const clamp = () => {
const { x, y } = untrack(() => audioStore.floatPos);
audioStore.floatPos = clampFloatPos(x, y);
};
clamp();
window.addEventListener('resize', clamp);
return () => window.removeEventListener('resize', clamp);
});
</script>
<svelte:window onkeydown={handleKeyDown} />
@@ -991,7 +1100,8 @@
</svg>
</button>
<!-- Track info -->
<!-- Track info (hidden in minimal style) -->
{#if playerStyle !== 'minimal'}
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-(--color-text) leading-tight truncate">
{m.reader_play_narration()}
@@ -1001,7 +1111,7 @@
{#if voices.length > 0}
<button
type="button"
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; chapterSearch = ''; }}
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; }}
class={cn('flex items-center gap-1 text-xs transition-colors leading-none', showVoicePanel ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
title={m.reader_change_voice()}
>
@@ -1019,11 +1129,39 @@
{#if voices.length > 0}<span class="text-(--color-border) text-xs leading-none">·</span>{/if}
<span class="text-xs text-(--color-muted) leading-none tabular-nums">~{estimatedMinutes} min</span>
{/if}
<!-- Stream / Generate mode toggle -->
{#if !audioStore.voice.startsWith('cfai:')}
<span class="text-(--color-border) text-xs leading-none">·</span>
<button
type="button"
onclick={() => { audioStore.audioMode = audioStore.audioMode === 'stream' ? 'generate' : 'stream'; }}
class={cn(
'flex items-center gap-0.5 text-xs leading-none transition-colors',
audioStore.audioMode === 'stream'
? 'text-(--color-brand)'
: 'text-(--color-muted) hover:text-(--color-text)'
)}
title={audioStore.audioMode === 'stream' ? 'Stream mode — click to switch to generate' : 'Generate mode — click to switch to stream'}
>
{#if audioStore.audioMode === 'stream'}
<svg class="w-3 h-3 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
Stream
{:else}
<svg class="w-3 h-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
Generate
{/if}
</button>
{/if}
</div>
</div>
{/if}
<!-- Chapters button (right side) -->
{#if chapters.length > 0}
<!-- Chapters button (right side, hidden in minimal style) -->
{#if chapters.length > 0 && playerStyle !== 'minimal'}
<button
type="button"
onclick={() => { showChapterPanel = !showChapterPanel; showVoicePanel = false; stopSample(); }}
@@ -1097,6 +1235,67 @@
{:else}
<!-- ── Non-idle states (loading / generating / ready / other-chapter-playing) ── -->
{#if !(playerStyle === 'float' && audioStore.isCurrentChapter(slug, chapter) && audioStore.active)}
{#if playerStyle === 'minimal' && audioStore.isCurrentChapter(slug, chapter) && audioStore.active}
<!-- ── Minimal style: compact bar — seek + play/pause + skip + time ────────── -->
<div class="px-3 py-2.5 flex items-center gap-2">
<!-- Skip back 15s -->
<button
type="button"
onclick={() => { audioStore.seekRequest = Math.max(0, audioStore.currentTime - 15); }}
class="flex-shrink-0 w-7 h-7 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
title="-15s"
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
</svg>
</button>
<!-- Play/pause -->
<button
type="button"
onclick={() => { audioStore.toggleRequest++; }}
class="flex-shrink-0 w-8 h-8 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) active:scale-95 transition-all"
>
{#if audioStore.isPlaying}
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>
{:else}
<svg class="w-3.5 h-3.5 ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
{/if}
</button>
<!-- Skip forward 30s -->
<button
type="button"
onclick={() => { audioStore.seekRequest = Math.min(audioStore.duration || 0, audioStore.currentTime + 30); }}
class="flex-shrink-0 w-7 h-7 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
title="+30s"
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z"/>
</svg>
</button>
<!-- Seek bar — proper range input so drag works on iOS too -->
<input
type="range"
aria-label="Seek"
min="0"
max={audioStore.duration || 0}
value={audioStore.currentTime}
oninput={(e) => { audioStore.seekRequest = parseFloat((e.target as HTMLInputElement).value); }}
onchange={(e) => { audioStore.seekRequest = parseFloat((e.target as HTMLInputElement).value); }}
class="flex-1 h-1.5 cursor-pointer"
style="accent-color: var(--color-brand);"
/>
<!-- Time -->
<span class="flex-shrink-0 text-[11px] tabular-nums text-(--color-muted)">
{formatTime(audioStore.currentTime)}<span class="opacity-40">/</span>{formatDuration(audioStore.duration)}
</span>
</div>
{:else}
<div class="p-4">
<div class="flex items-center justify-end gap-2 mb-3">
<!-- Chapter picker button -->
@@ -1119,7 +1318,7 @@
<Button
variant="ghost"
size="sm"
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; chapterSearch = ''; }}
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; }}
class={cn('gap-1.5 text-xs', showVoicePanel ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : '')}
title={m.reader_change_voice()}
>
@@ -1302,182 +1501,103 @@
</div>
{/if}
{/if}
{/if}
<!-- ── Chapter picker overlay ─────────────────────────────────────────────────
Rendered as a top-level sibling (outside all player containers) so that
the fixed inset-0 positioning is never clipped by overflow-hidden or
border-radius on any ancestor wrapping the AudioPlayer component. -->
{#if showChapterPanel && audioStore.chapters.length > 0}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[60] flex flex-col"
style="background: var(--color-surface);"
>
<!-- Header -->
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0">
<span class="text-sm font-semibold text-(--color-text) flex-1">Chapters</span>
<button
type="button"
onclick={() => { showChapterPanel = false; chapterSearch = ''; }}
class="w-9 h-9 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Close chapter picker"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Search -->
<div class="px-4 py-3 shrink-0 border-b border-(--color-border)">
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input
type="search"
placeholder="Search chapters…"
bind:value={chapterSearch}
class="w-full pl-9 pr-4 py-2 text-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
/>
</div>
</div>
<!-- Chapter list -->
<div class="flex-1 overflow-y-auto">
{#each filteredChapters as ch (ch.number)}
<button
type="button"
onclick={() => playChapter(ch.number)}
class={cn(
'w-full flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors text-left',
ch.number === chapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
)}
>
<!-- Chapter number badge -->
<span class={cn(
'w-8 h-8 shrink-0 rounded-full border-2 flex items-center justify-center tabular-nums text-xs font-semibold transition-colors',
ch.number === chapter
? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)'
: 'border-(--color-border) text-(--color-muted)'
)}>{ch.number}</span>
<!-- Title -->
<span class={cn(
'flex-1 text-sm truncate',
ch.number === chapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)'
)}>{ch.title || `Chapter ${ch.number}`}</span>
<!-- Now-playing indicator -->
{#if ch.number === chapter}
<svg class="w-4 h-4 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</button>
{/each}
{#if filteredChapters.length === 0}
<p class="px-4 py-8 text-sm text-(--color-muted) text-center">No chapters match "{chapterSearch}"</p>
{/if}
</div>
</div>
<ChapterPickerOverlay
chapters={audioStore.chapters}
activeChapter={audioStore.chapter}
zIndex="z-[60]"
onselect={playChapter}
onclose={() => { showChapterPanel = false; }}
/>
{/if}
<!-- ── Float player overlay ──────────────────────────────────────────────────
Rendered outside all containers so fixed positioning is never clipped.
A draggable circle anchored to the viewport.
Tap = toggle play/pause.
Drag = reposition (clamped to viewport).
Visible when playerStyle='float' and audio is active for this chapter. -->
{#if playerStyle === 'float' && audioStore.isCurrentChapter(slug, chapter) && audioStore.active}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed z-[55] select-none"
style="
bottom: calc(4.5rem + {-floatPos.y}px);
right: calc(1rem + {-floatPos.x}px);
bottom: calc({FLOAT_MARGIN}px + {-audioStore.floatPos.y}px);
right: calc({FLOAT_MARGIN}px + {-audioStore.floatPos.x}px);
touch-action: none;
width: {FLOAT_SIZE}px;
height: {FLOAT_SIZE}px;
"
onpointerdown={onFloatPointerDown}
onpointermove={onFloatPointerMove}
onpointerup={onFloatPointerUp}
onpointercancel={(e) => { floatDragging = false; try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* ignore */ } }}
>
<div class="w-64 rounded-2xl bg-(--color-surface) border border-(--color-border) shadow-2xl overflow-hidden">
<!-- Drag handle + title row -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex items-center gap-2 px-3 pt-2.5 pb-1 cursor-grab active:cursor-grabbing"
onpointerdown={onFloatPointerDown}
onpointermove={onFloatPointerMove}
onpointerup={onFloatPointerUp}
onpointercancel={onFloatPointerUp}
>
<!-- Drag grip dots -->
<svg class="w-3.5 h-3.5 text-(--color-muted)/50 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/>
<circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/>
<circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/>
<!-- Pulsing ring when playing -->
{#if audioStore.isPlaying}
<span class="absolute inset-0 rounded-full bg-(--color-brand)/30 animate-ping pointer-events-none"></span>
{/if}
<!-- Circle button -->
<div
class="absolute inset-0 rounded-full bg-(--color-brand) shadow-xl flex items-center justify-center {floatDragging ? 'cursor-grabbing' : 'cursor-grab'} transition-transform active:scale-95"
>
{#if audioStore.status === 'generating' || audioStore.status === 'loading'}
<!-- Spinner -->
<svg class="w-6 h-6 text-white animate-spin" fill="none" viewBox="0 0 24 24">
<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>
<span class="flex-1 text-xs font-medium text-(--color-muted) truncate">
{audioStore.chapterTitle || `Chapter ${audioStore.chapter}`}
</span>
<!-- Status dot -->
{#if audioStore.isPlaying}
<span class="w-1.5 h-1.5 rounded-full bg-(--color-brand) flex-shrink-0 animate-pulse"></span>
{/if}
</div>
<!-- Seek bar -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
role="none"
class="mx-3 mb-2 h-1 bg-(--color-surface-3) rounded-full overflow-hidden cursor-pointer"
onclick={seekFromBar}
>
<div class="h-full bg-(--color-brand) rounded-full transition-none" style="width: {playPct}%"></div>
</div>
<!-- Controls row -->
<div class="flex items-center gap-1 px-3 pb-2.5">
<!-- Skip back 15s -->
<button
type="button"
onclick={() => { audioStore.seekRequest = Math.max(0, audioStore.currentTime - 15); }}
class="w-8 h-8 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors flex-shrink-0"
title="-15s"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
</svg>
</button>
<!-- Play/pause -->
<button
type="button"
onclick={() => { audioStore.toggleRequest++; }}
class="w-9 h-9 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) active:scale-95 transition-all flex-shrink-0"
>
{#if audioStore.isPlaying}
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>
{:else}
<svg class="w-4 h-4 ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
{/if}
</button>
<!-- Skip forward 30s -->
<button
type="button"
onclick={() => { audioStore.seekRequest = Math.min(audioStore.duration || 0, audioStore.currentTime + 30); }}
class="w-8 h-8 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors flex-shrink-0"
title="+30s"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z"/>
</svg>
</button>
<!-- Time -->
<span class="flex-1 text-[11px] text-center tabular-nums text-(--color-muted)">
{formatTime(audioStore.currentTime)}
<span class="opacity-50">/</span>
{formatDuration(audioStore.duration)}
</span>
<!-- Speed -->
<span class="text-[11px] font-medium tabular-nums text-(--color-muted) flex-shrink-0">
{audioStore.speed}×
</span>
</div>
{:else if audioStore.isPlaying}
<!-- Pause icon -->
<svg class="w-6 h-6 text-white pointer-events-none" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
{:else}
<!-- Play icon -->
<svg class="w-6 h-6 text-white ml-0.5 pointer-events-none" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</div>
<!-- Progress arc ring (thin, overlaid on circle edge) -->
{#if audioStore.duration > 0}
{@const r = 26}
{@const circ = 2 * Math.PI * r}
{@const dash = (audioStore.currentTime / audioStore.duration) * circ}
<svg
class="absolute inset-0 pointer-events-none -rotate-90"
width={FLOAT_SIZE}
height={FLOAT_SIZE}
viewBox="0 0 {FLOAT_SIZE} {FLOAT_SIZE}"
>
<circle
cx={FLOAT_SIZE / 2}
cy={FLOAT_SIZE / 2}
r={r}
fill="none"
stroke="rgba(255,255,255,0.25)"
stroke-width="2.5"
/>
<circle
cx={FLOAT_SIZE / 2}
cy={FLOAT_SIZE / 2}
r={r}
fill="none"
stroke="white"
stroke-width="2.5"
stroke-linecap="round"
stroke-dasharray="{circ}"
stroke-dashoffset="{circ - dash}"
style="transition: stroke-dashoffset 0.5s linear;"
/>
</svg>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,132 @@
<script lang="ts">
import { cn } from '$lib/utils';
interface ChapterMeta {
number: number;
title: string;
}
interface Props {
/** Full chapter list to render and filter. */
chapters: ChapterMeta[];
/** Number of the currently-active chapter (highlighted + auto-scrolled). */
activeChapter: number;
/** z-index class, e.g. "z-[60]" or "z-[80]". Defaults to "z-[60]". */
zIndex?: string;
/** Called when a chapter row is tapped. The overlay does NOT close itself. */
onselect: (chapterNumber: number) => void;
/** Called when the close (✕) button is tapped. */
onclose: () => void;
}
let {
chapters,
activeChapter,
zIndex = 'z-[60]',
onselect,
onclose
}: Props = $props();
let search = $state('');
const filtered = $derived(
search.trim() === ''
? chapters
: chapters.filter((ch) =>
(ch.title || `Chapter ${ch.number}`)
.toLowerCase()
.includes(search.toLowerCase()) ||
String(ch.number).includes(search)
)
);
function handleClose() {
search = '';
onclose();
}
function handleSelect(n: number) {
search = '';
onselect(n);
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 flex flex-col {zIndex}"
style="background: var(--color-surface);"
>
<!-- Header -->
<div
class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0"
style="padding-top: max(0.75rem, env(safe-area-inset-top));"
>
<button
type="button"
onclick={handleClose}
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Close chapter picker"
>
<!-- close / ✕ -->
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider flex-1">Chapters</span>
</div>
<!-- Search -->
<div class="px-4 py-3 shrink-0 border-b border-(--color-border)">
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input
type="search"
placeholder="Search chapters…"
bind:value={search}
class="w-full pl-9 pr-4 py-2 text-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
/>
</div>
</div>
<!-- Chapter list -->
<div
class="flex-1 overflow-y-auto overscroll-contain"
style="padding-bottom: env(safe-area-inset-bottom);"
>
{#each filtered as ch (ch.number)}
<button
type="button"
onclick={() => handleSelect(ch.number)}
class={cn(
'w-full flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors text-left',
ch.number === activeChapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
)}
>
<span class={cn(
'w-8 h-8 shrink-0 rounded-full border-2 flex items-center justify-center tabular-nums text-xs font-semibold transition-colors',
ch.number === activeChapter
? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)'
: 'border-(--color-border) text-(--color-muted)'
)}>{ch.number}</span>
<span class={cn(
'flex-1 text-sm truncate',
ch.number === activeChapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)'
)}>{ch.title || `Chapter ${ch.number}`}</span>
{#if ch.number === activeChapter}
<!-- play icon -->
<svg class="w-4 h-4 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</button>
{/each}
{#if filtered.length === 0}
<p class="px-4 py-8 text-sm text-(--color-muted) text-center">No chapters match "{search}"</p>
{/if}
</div>
</div>

View File

@@ -0,0 +1,193 @@
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { cn } from '$lib/utils';
interface Book {
slug: string;
title: string;
cover?: string;
author?: string;
genres?: string[] | string;
}
interface ReadingEntry {
book: Book;
chapter: number;
}
interface Props {
/** The slug of the book currently being read — highlighted in the list. */
currentSlug: string;
onclose: () => void;
}
let { currentSlug, onclose }: Props = $props();
let entries = $state<ReadingEntry[]>([]);
let loading = $state(true);
let error = $state('');
// ── Fetch in-progress books on mount ──────────────────────────────────────
$effect(() => {
if (!browser) return;
(async () => {
try {
const res = await fetch('/api/home');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json() as { continue_reading: ReadingEntry[] };
entries = data.continue_reading ?? [];
} catch {
error = 'Failed to load your reading list.';
} finally {
loading = false;
}
})();
});
// ── Body scroll lock ──────────────────────────────────────────────────────
$effect(() => {
if (!browser) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
});
// ── Keyboard: Escape closes ───────────────────────────────────────────────
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onclose();
}
// ── Navigate to a book's current chapter ─────────────────────────────────
function openBook(entry: ReadingEntry) {
onclose();
goto(`/books/${entry.book.slug}/chapters/${entry.chapter}`);
}
function parseGenres(genres: string[] | string | undefined): string[] {
if (!genres) return [];
if (Array.isArray(genres)) return genres;
try { const p = JSON.parse(genres); return Array.isArray(p) ? p : []; } catch { return []; }
}
</script>
<svelte:window onkeydown={onKeydown} />
<!-- Backdrop -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[70] flex flex-col"
style="background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);"
onpointerdown={(e) => { if (e.target === e.currentTarget) onclose(); }}
>
<!-- Panel — matches SearchModal style -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="w-full max-w-2xl mx-auto mt-0 sm:mt-16 flex flex-col bg-(--color-surface) sm:rounded-2xl border-b sm:border border-(--color-border) shadow-2xl overflow-hidden"
style="max-height: 100svh;"
onpointerdown={(e) => e.stopPropagation()}
>
<!-- Header row -->
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0">
<svg class="w-5 h-5 text-(--color-muted) shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
<span class="flex-1 text-base font-semibold text-(--color-text)">Currently Reading</span>
<button
type="button"
onclick={onclose}
class="shrink-0 px-3 py-1 rounded-lg text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Close"
>
Cancel
</button>
</div>
<!-- Scrollable body -->
<div class="flex-1 overflow-y-auto overscroll-contain">
{#if loading}
<!-- Loading skeleton -->
<div class="px-4 pt-3 pb-4 space-y-1">
{#each [1, 2, 3] as _}
<div class="flex items-center gap-3 px-0 py-3 border-b border-(--color-border)/40">
<div class="shrink-0 w-10 h-14 rounded bg-(--color-surface-3) animate-pulse"></div>
<div class="flex-1 space-y-2">
<div class="h-3.5 bg-(--color-surface-3) rounded animate-pulse w-3/4"></div>
<div class="h-3 bg-(--color-surface-3) rounded animate-pulse w-1/2"></div>
</div>
</div>
{/each}
</div>
{:else if error}
<p class="px-5 py-8 text-sm text-center text-(--color-danger)">{error}</p>
{:else if entries.length === 0}
<div class="px-5 py-12 text-center">
<p class="text-sm font-semibold text-(--color-text) mb-1">No books in progress</p>
<p class="text-xs text-(--color-muted)">Books you start reading will appear here.</p>
</div>
{:else}
{#each entries as entry, i}
{@const genres = parseGenres(entry.book.genres)}
{@const isCurrent = entry.book.slug === currentSlug}
<button
type="button"
onclick={() => openBook(entry)}
class={cn(
'w-full flex items-center gap-3 px-4 py-3 text-left transition-colors border-b border-(--color-border)/40 last:border-0',
isCurrent ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
)}
>
<!-- Cover thumbnail -->
<div class="shrink-0 w-10 h-14 rounded overflow-hidden bg-(--color-surface-2) border border-(--color-border) relative">
{#if entry.book.cover}
<img src={entry.book.cover} alt="" class="w-full h-full object-cover" loading="lazy" />
{:else}
<div class="w-full h-full flex items-center justify-center">
<svg class="w-5 h-5 text-(--color-muted)/40" fill="currentColor" viewBox="0 0 24 24">
<path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/>
</svg>
</div>
{/if}
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<div class="flex items-start gap-2">
<p class={cn(
'text-sm font-semibold leading-snug line-clamp-1 flex-1',
isCurrent ? 'text-(--color-brand)' : 'text-(--color-text)'
)}>
{entry.book.title}
</p>
{#if isCurrent}
<span class="shrink-0 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 leading-none mt-0.5">
Now
</span>
{/if}
</div>
{#if entry.book.author}
<p class="text-xs text-(--color-muted) mt-0.5 truncate">{entry.book.author}</p>
{/if}
<div class="flex items-center gap-1.5 mt-1 flex-wrap">
<span class="text-xs text-(--color-muted)/60">Ch. {entry.chapter}</span>
{#each genres.slice(0, 2) as g}
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted)">{g}</span>
{/each}
</div>
</div>
<!-- Chevron (dimmed for current, normal for others) -->
<svg class={cn('w-4 h-4 shrink-0', isCurrent ? 'text-(--color-brand)/40' : 'text-(--color-muted)/40')} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</button>
{/each}
{/if}
</div>
</div>
</div>

View File

@@ -2,7 +2,9 @@
import { audioStore } from '$lib/audio.svelte';
import { cn } from '$lib/utils';
import { goto } from '$app/navigation';
import { fly } from 'svelte/transition';
import type { Voice } from '$lib/types';
import ChapterPickerOverlay from '$lib/components/ChapterPickerOverlay.svelte';
interface Props {
/** Called when the user closes the overlay. */
@@ -92,25 +94,13 @@
const filteredCfai = $derived(cfaiVoices.filter((v) => voiceLabel(v).toLowerCase().includes(voiceSearchLower)));
// ── Chapter search ────────────────────────────────────────────────────────
let chapterSearch = $state('');
// (search state is managed internally by ChapterPickerOverlay)
// Scroll the current chapter into view instantly (no animation) when the
// chapter modal opens. Applied to every chapter button; only scrolls when
// the chapter number matches the currently playing one. Runs once on mount
// before the browser paints so no scroll animation is ever visible.
function scrollIfActive(node: HTMLElement, isActive: boolean) {
if (isActive) node.scrollIntoView({ block: 'center', behavior: 'instant' });
// ── Chapter click-to-play ─────────────────────────────────────────────────
function playChapter(chapterNumber: number) {
audioStore.autoStartChapter = chapterNumber;
goto(`/books/${audioStore.slug}/chapters/${chapterNumber}`);
}
const filteredChapters = $derived(
chapterSearch.trim() === ''
? audioStore.chapters
: audioStore.chapters.filter((ch) =>
(ch.title || `Chapter ${ch.number}`)
.toLowerCase()
.includes(chapterSearch.toLowerCase()) ||
String(ch.number).includes(chapterSearch)
)
);
function voiceLabel(v: Voice | string): string {
if (typeof v === 'string') {
@@ -156,13 +146,6 @@
voiceSearch = '';
}
// ── Chapter click-to-play ─────────────────────────────────────────────────
function playChapter(chapterNumber: number) {
audioStore.autoStartChapter = chapterNumber;
onclose();
goto(`/books/${audioStore.slug}/chapters/${chapterNumber}`);
}
// ── Speed ────────────────────────────────────────────────────────────────
const SPEED_OPTIONS = [0.75, 1, 1.25, 1.5, 2] as const;
@@ -247,6 +230,7 @@
<!-- Full-screen listening mode overlay -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
transition:fly={{ y: '100%', duration: 320, opacity: 1 }}
bind:this={overlayEl}
class="fixed inset-0 z-60 flex flex-col overflow-hidden"
style="
@@ -451,67 +435,6 @@
</div>
{/if}
<!-- Chapter modal (full-screen overlay) -->
{#if showChapterModal && audioStore.chapters.length > 0}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[80] flex flex-col"
style="background: var(--color-surface);"
>
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0" style="padding-top: max(0.75rem, env(safe-area-inset-top));">
<button
type="button"
onclick={() => { showChapterModal = false; }}
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Close chapter picker"
>
<svg class="w-5 h-5" 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"/>
</svg>
</button>
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider flex-1">Chapters</span>
</div>
<div class="px-4 py-3 shrink-0 border-b border-(--color-border)">
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input
type="search"
placeholder="Search chapters…"
bind:value={chapterSearch}
class="w-full pl-9 pr-4 py-2 text-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
/>
</div>
</div>
<div class="flex-1 overflow-y-auto overscroll-contain" style="padding-bottom: env(safe-area-inset-bottom);">
{#each filteredChapters as ch (ch.number)}
<button
type="button"
onclick={() => playChapter(ch.number)}
use:scrollIfActive={ch.number === audioStore.chapter}
class={cn(
'w-full flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors text-left',
ch.number === audioStore.chapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
)}
>
<span class={cn(
'w-8 h-8 shrink-0 rounded-full border-2 flex items-center justify-center tabular-nums text-xs font-semibold transition-colors',
ch.number === audioStore.chapter ? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)' : 'border-(--color-border) text-(--color-muted)'
)}>{ch.number}</span>
<span class={cn('flex-1 text-sm truncate', ch.number === audioStore.chapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)')}>{ch.title || `Chapter ${ch.number}`}</span>
{#if ch.number === audioStore.chapter}
<svg class="w-4 h-4 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
{/if}
</button>
{/each}
{#if filteredChapters.length === 0}
<p class="px-4 py-8 text-sm text-(--color-muted) text-center">No chapters match "{chapterSearch}"</p>
{/if}
</div>
</div>
{/if}
<!-- ── Controls area (bottom half) ───────────────────────────────────── -->
<div class="flex-1 flex flex-col justify-end px-6 pb-6 gap-0 shrink-0 overflow-hidden" style="z-index: 2; position: relative;">
@@ -664,24 +587,57 @@
{/if}
</button>
<!-- Announce chapter pill (only meaningful when auto-next is on) -->
<button
type="button"
onclick={() => (audioStore.announceChapter = !audioStore.announceChapter)}
class={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
audioStore.announceChapter
<!-- Announce chapter pill (only meaningful when auto-next is on) -->
<button
type="button"
onclick={() => (audioStore.announceChapter = !audioStore.announceChapter)}
class={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
audioStore.announceChapter
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
)}
aria-pressed={audioStore.announceChapter}
title={audioStore.announceChapter ? 'Chapter announcing on' : 'Chapter announcing off'}
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
</svg>
Announce
</button>
<!-- Stream / Generate mode toggle -->
<!-- CF AI voices are batch-only and always use generate mode regardless of this setting -->
<button
type="button"
onclick={() => {
if (!audioStore.voice.startsWith('cfai:')) {
audioStore.audioMode = audioStore.audioMode === 'stream' ? 'generate' : 'stream';
}
}}
disabled={audioStore.voice.startsWith('cfai:')}
class={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
audioStore.voice.startsWith('cfai:')
? 'border-(--color-border) bg-(--color-surface-2) text-(--color-border) cursor-not-allowed opacity-50'
: audioStore.audioMode === 'stream'
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
)}
aria-pressed={audioStore.announceChapter}
title={audioStore.announceChapter ? 'Chapter announcing on' : 'Chapter announcing off'}
>
)}
aria-pressed={audioStore.audioMode === 'stream'}
title={audioStore.voice.startsWith('cfai:') ? 'CF AI voices always use generate mode' : audioStore.audioMode === 'stream' ? 'Stream mode — audio starts instantly' : 'Generate mode — wait for full audio before playing'}
>
{#if audioStore.audioMode === 'stream' && !audioStore.voice.startsWith('cfai:')}
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
<path d="M8 5v14l11-7z"/>
</svg>
Announce
</button>
{:else}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
{/if}
{audioStore.audioMode === 'stream' && !audioStore.voice.startsWith('cfai:') ? 'Stream' : 'Generate'}
</button>
<!-- Sleep timer pill -->
<button
@@ -704,3 +660,16 @@
</div>
</div>
<!-- Chapter picker rendered OUTSIDE the transformed overlay so that
fixed inset-0 anchors to the real viewport, not the CSS-transformed
containing block (transform: translateY breaks fixed positioning). -->
{#if showChapterModal && audioStore.chapters.length > 0}
<ChapterPickerOverlay
chapters={audioStore.chapters}
activeChapter={audioStore.chapter}
zIndex="z-[80]"
onselect={playChapter}
onclose={() => { showChapterModal = false; }}
/>
{/if}

View File

@@ -0,0 +1,184 @@
<script lang="ts">
import { browser } from '$app/environment';
import { cn } from '$lib/utils';
interface Notification {
id: string;
title: string;
message: string;
link: string;
read: boolean;
}
interface Props {
notifications: Notification[];
userId: string;
isAdmin: boolean;
onclose: () => void;
onMarkRead: (id: string) => void;
onMarkAllRead: () => void;
onDismiss: (id: string) => void;
onClearAll: () => void;
}
let {
notifications,
userId,
isAdmin,
onclose,
onMarkRead,
onMarkAllRead,
onDismiss,
onClearAll,
}: Props = $props();
let filter = $state<'all' | 'unread'>('all');
const filtered = $derived(
filter === 'unread' ? notifications.filter(n => !n.read) : notifications
);
const unreadCount = $derived(notifications.filter(n => !n.read).length);
// Body scroll lock + Escape to close
$effect(() => {
if (browser) {
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}
});
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onclose();
}
const viewAllHref = $derived(isAdmin ? '/admin/notifications' : '/notifications');
</script>
<svelte:window onkeydown={onKeydown} />
<!-- Backdrop -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[70] flex flex-col"
style="background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);"
onpointerdown={(e) => { if (e.target === e.currentTarget) onclose(); }}
>
<!-- Modal panel — slides down from top -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="w-full max-w-2xl mx-auto mt-0 sm:mt-16 flex flex-col bg-(--color-surface) sm:rounded-2xl border-b sm:border border-(--color-border) shadow-2xl overflow-hidden"
style="max-height: 100svh;"
onpointerdown={(e) => e.stopPropagation()}
>
<!-- Header row -->
<div class="flex items-center justify-between px-4 py-3 border-b border-(--color-border) shrink-0">
<div class="flex items-center gap-3">
<span class="text-base font-semibold text-(--color-text)">Notifications</span>
{#if unreadCount > 0}
<span class="text-xs font-semibold px-2 py-0.5 rounded-full bg-(--color-brand) text-black leading-none">
{unreadCount}
</span>
{/if}
</div>
<div class="flex items-center gap-1">
{#if unreadCount > 0}
<button
type="button"
onclick={onMarkAllRead}
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors px-2 py-1 rounded hover:bg-(--color-surface-2)"
>Mark all read</button>
{/if}
{#if notifications.length > 0}
<button
type="button"
onclick={onClearAll}
class="text-xs text-(--color-muted) hover:text-red-400 transition-colors px-2 py-1 rounded hover:bg-(--color-surface-2)"
>Clear all</button>
{/if}
<button
type="button"
onclick={onclose}
class="shrink-0 px-3 py-1 rounded-lg text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Close notifications"
>
Cancel
</button>
</div>
</div>
<!-- Filter tabs -->
<div class="flex gap-0 px-4 py-2 border-b border-(--color-border)/60 shrink-0">
<button
type="button"
onclick={() => filter = 'all'}
class={cn(
'text-xs px-3 py-1.5 rounded-l border border-(--color-border) transition-colors',
filter === 'all'
? 'bg-(--color-brand) text-black border-(--color-brand) font-semibold'
: 'text-(--color-muted) hover:text-(--color-text)'
)}
>All ({notifications.length})</button>
<button
type="button"
onclick={() => filter = 'unread'}
class={cn(
'text-xs px-3 py-1.5 rounded-r border border-l-0 border-(--color-border) transition-colors',
filter === 'unread'
? 'bg-(--color-brand) text-black border-(--color-brand) font-semibold'
: 'text-(--color-muted) hover:text-(--color-text)'
)}
>Unread ({unreadCount})</button>
</div>
<!-- Scrollable list -->
<div class="flex-1 overflow-y-auto overscroll-contain min-h-0">
{#if filtered.length === 0}
<div class="py-16 text-center text-(--color-muted) text-sm">
{filter === 'unread' ? 'No unread notifications' : 'No notifications yet'}
</div>
{:else}
{#each filtered as n (n.id)}
<div class={cn(
'flex items-start gap-1 border-b border-(--color-border)/40 last:border-0 hover:bg-(--color-surface-2) group transition-colors',
n.read && 'opacity-60'
)}>
<a
href={n.link || (isAdmin ? '/admin' : '/')}
onclick={() => { onMarkRead(n.id); onclose(); }}
class="flex-1 px-4 py-3.5 min-w-0"
>
<div class="flex items-center gap-1.5">
{#if !n.read}
<span class="w-1.5 h-1.5 rounded-full bg-(--color-brand) shrink-0"></span>
{/if}
<span class="text-sm font-semibold text-(--color-text) truncate">{n.title}</span>
</div>
<p class="text-sm text-(--color-muted) mt-0.5 line-clamp-2">{n.message}</p>
</a>
<button
type="button"
onclick={() => onDismiss(n.id)}
class="shrink-0 p-3 text-(--color-muted) hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all"
title="Dismiss"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{/each}
{/if}
</div>
<!-- Footer -->
<div class="px-4 py-3 border-t border-(--color-border)/40 shrink-0">
<a
href={viewAllHref}
onclick={onclose}
class="block text-center text-sm text-(--color-muted) hover:text-(--color-brand) transition-colors"
>View all notifications</a>
</div>
</div>
</div>

View File

@@ -223,26 +223,13 @@
bind:value={query}
type="search"
placeholder="Search books, authors, genres…"
class="flex-1 bg-transparent text-(--color-text) placeholder:text-(--color-muted) text-base focus:outline-none min-w-0"
class="flex-1 bg-transparent text-(--color-text) placeholder:text-(--color-muted) text-base focus:outline-none min-w-0 [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden"
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); submitQuery(); } }}
autocomplete="off"
autocorrect="off"
spellcheck={false}
/>
{#if query}
<button
type="button"
onclick={() => { query = ''; inputEl?.focus(); }}
class="shrink-0 p-1 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Clear search"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
{/if}
<button
type="button"
onclick={onclose}

View File

@@ -0,0 +1,285 @@
<script lang="ts">
/**
* SeasonalDecoration — full-viewport canvas particle overlay.
*
* Modes:
* snow — white circular snowflakes drifting down with gentle sway
* sakura — pink/white ellipse petals falling and rotating
* fireflies — small glowing dots floating up, pulsing opacity
* leaves — orange/red/yellow tear-drop shapes tumbling down
* stars — white stars twinkling in place (fixed positions, opacity animation)
*/
type Mode = 'snow' | 'sakura' | 'fireflies' | 'leaves' | 'stars';
interface Props { mode: Mode }
let { mode }: Props = $props();
let canvas = $state<HTMLCanvasElement | null>(null);
let raf = 0;
// ── Particle types ──────────────────────────────────────────────────────
interface Particle {
x: number; y: number; r: number;
vx: number; vy: number;
angle: number; vAngle: number;
opacity: number; vOpacity: number;
color: string;
// star-specific
twinkleOffset?: number;
}
// ── Palette helpers ──────────────────────────────────────────────────────
function rand(min: number, max: number) { return min + Math.random() * (max - min); }
function randInt(min: number, max: number) { return Math.floor(rand(min, max + 1)); }
const SNOW_COLORS = ['rgba(255,255,255,0.85)', 'rgba(200,220,255,0.75)', 'rgba(220,235,255,0.8)'];
const SAKURA_COLORS = ['rgba(255,182,193,0.85)', 'rgba(255,200,210,0.8)', 'rgba(255,240,245,0.9)', 'rgba(255,160,180,0.75)'];
const FIREFLY_COLORS = ['rgba(180,255,100,0.9)', 'rgba(220,255,150,0.85)', 'rgba(255,255,180,0.8)'];
const LEAF_COLORS = ['rgba(210,80,20,0.85)', 'rgba(190,120,30,0.8)', 'rgba(220,160,40,0.85)', 'rgba(180,60,10,0.8)', 'rgba(240,140,30,0.9)'];
const STAR_COLORS = ['rgba(255,255,255,0.9)', 'rgba(255,240,180,0.85)', 'rgba(180,210,255,0.8)'];
// ── Spawn helpers ────────────────────────────────────────────────────────
function spawnSnow(W: number, H: number): Particle {
return {
x: rand(0, W), y: rand(-H * 0.2, -4),
r: rand(1.5, 5),
vx: rand(-0.4, 0.4), vy: rand(0.6, 2.0),
angle: 0, vAngle: 0,
opacity: rand(0.5, 1), vOpacity: 0,
color: SNOW_COLORS[randInt(0, SNOW_COLORS.length - 1)],
};
}
function spawnSakura(W: number, H: number): Particle {
return {
x: rand(0, W), y: rand(-H * 0.2, -4),
r: rand(3, 7),
vx: rand(-0.6, 0.6), vy: rand(0.5, 1.6),
angle: rand(0, Math.PI * 2), vAngle: rand(-0.03, 0.03),
opacity: rand(0.6, 1), vOpacity: 0,
color: SAKURA_COLORS[randInt(0, SAKURA_COLORS.length - 1)],
};
}
function spawnFirefly(W: number, H: number): Particle {
return {
x: rand(0, W), y: rand(H * 0.3, H),
r: rand(1.5, 3.5),
vx: rand(-0.3, 0.3), vy: rand(-0.8, -0.2),
angle: 0, vAngle: 0,
opacity: rand(0.2, 0.8), vOpacity: rand(0.008, 0.025) * (Math.random() < 0.5 ? 1 : -1),
color: FIREFLY_COLORS[randInt(0, FIREFLY_COLORS.length - 1)],
};
}
function spawnLeaf(W: number, H: number): Particle {
return {
x: rand(0, W), y: rand(-H * 0.2, -4),
r: rand(4, 9),
vx: rand(-1.2, 1.2), vy: rand(0.8, 2.5),
angle: rand(0, Math.PI * 2), vAngle: rand(-0.05, 0.05),
opacity: rand(0.6, 1), vOpacity: 0,
color: LEAF_COLORS[randInt(0, LEAF_COLORS.length - 1)],
};
}
function spawnStar(W: number, H: number): Particle {
return {
x: rand(0, W), y: rand(0, H),
r: rand(0.8, 2.5),
vx: 0, vy: 0,
angle: 0, vAngle: 0,
opacity: rand(0.1, 0.9),
vOpacity: rand(0.004, 0.015) * (Math.random() < 0.5 ? 1 : -1),
color: STAR_COLORS[randInt(0, STAR_COLORS.length - 1)],
twinkleOffset: rand(0, Math.PI * 2),
};
}
// ── Draw helpers ─────────────────────────────────────────────────────────
function drawSnow(ctx: CanvasRenderingContext2D, p: Particle) {
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = p.color;
ctx.globalAlpha = p.opacity;
ctx.fill();
}
function drawSakura(ctx: CanvasRenderingContext2D, p: Particle) {
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.angle);
ctx.beginPath();
ctx.ellipse(0, 0, p.r * 1.8, p.r, 0, 0, Math.PI * 2);
ctx.fillStyle = p.color;
ctx.globalAlpha = p.opacity;
ctx.fill();
ctx.restore();
}
function drawFirefly(ctx: CanvasRenderingContext2D, p: Particle) {
// Glow effect: large soft circle + small bright core
const grd = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.r * 4);
grd.addColorStop(0, p.color);
grd.addColorStop(1, 'rgba(0,0,0,0)');
ctx.beginPath();
ctx.arc(p.x, p.y, p.r * 4, 0, Math.PI * 2);
ctx.fillStyle = grd;
ctx.globalAlpha = p.opacity * 0.6;
ctx.fill();
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = p.color;
ctx.globalAlpha = p.opacity;
ctx.fill();
}
function drawLeaf(ctx: CanvasRenderingContext2D, p: Particle) {
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.angle);
ctx.beginPath();
ctx.moveTo(0, -p.r * 1.5);
ctx.bezierCurveTo(p.r * 1.2, -p.r * 0.5, p.r * 1.2, p.r * 0.5, 0, p.r * 1.5);
ctx.bezierCurveTo(-p.r * 1.2, p.r * 0.5, -p.r * 1.2, -p.r * 0.5, 0, -p.r * 1.5);
ctx.fillStyle = p.color;
ctx.globalAlpha = p.opacity;
ctx.fill();
ctx.restore();
}
function drawStar(ctx: CanvasRenderingContext2D, p: Particle, t: number) {
const pulse = 0.5 + 0.5 * Math.sin(t * 0.002 + (p.twinkleOffset ?? 0));
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = p.color;
ctx.globalAlpha = p.opacity * pulse;
ctx.fill();
}
// ── Particle count by mode ────────────────────────────────────────────────
const COUNT: Record<Mode, number> = {
snow: 120, sakura: 60, fireflies: 50, leaves: 45, stars: 150,
};
// ── Main effect ──────────────────────────────────────────────────────────
$effect(() => {
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let W = window.innerWidth;
let H = window.innerHeight;
canvas.width = W;
canvas.height = H;
const onResize = () => {
W = window.innerWidth;
H = window.innerHeight;
canvas!.width = W;
canvas!.height = H;
// Reseed stars on resize since they're positionally fixed
if (mode === 'stars') {
particles.length = 0;
for (let i = 0; i < COUNT.stars; i++) particles.push(spawnStar(W, H));
}
};
window.addEventListener('resize', onResize);
const n = COUNT[mode];
const particles: Particle[] = [];
// Pre-scatter initial particles across the full height
for (let i = 0; i < n; i++) {
let p: Particle;
switch (mode) {
case 'snow': p = spawnSnow(W, H); p.y = rand(0, H); break;
case 'sakura': p = spawnSakura(W, H); p.y = rand(0, H); break;
case 'fireflies': p = spawnFirefly(W, H); break;
case 'leaves': p = spawnLeaf(W, H); p.y = rand(0, H); break;
case 'stars': p = spawnStar(W, H); break;
}
particles.push(p);
}
let t = 0;
function tick() {
ctx!.clearRect(0, 0, W, H);
ctx!.save();
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
switch (mode) {
case 'snow': {
// Gentle horizontal sway
p.vx = Math.sin(t * 0.001 + p.y * 0.01) * 0.5;
p.x += p.vx; p.y += p.vy;
if (p.y > H + 10) particles[i] = spawnSnow(W, H);
else drawSnow(ctx!, p);
break;
}
case 'sakura': {
p.vx = Math.sin(t * 0.0008 + p.y * 0.008) * 0.8;
p.x += p.vx; p.y += p.vy;
p.angle += p.vAngle;
if (p.y > H + 20) particles[i] = spawnSakura(W, H);
else drawSakura(ctx!, p);
break;
}
case 'fireflies': {
p.x += p.vx + Math.sin(t * 0.002 + i) * 0.3;
p.y += p.vy;
p.opacity += p.vOpacity;
if (p.opacity >= 1) { p.opacity = 1; p.vOpacity *= -1; }
if (p.opacity <= 0.1) { p.opacity = 0.1; p.vOpacity *= -1; }
if (p.y < -10) particles[i] = spawnFirefly(W, H);
else drawFirefly(ctx!, p);
break;
}
case 'leaves': {
p.vx = Math.sin(t * 0.001 + p.y * 0.01) * 1.2 + p.vx * 0.02;
p.x += p.vx; p.y += p.vy;
p.angle += p.vAngle;
if (p.y > H + 20) particles[i] = spawnLeaf(W, H);
else drawLeaf(ctx!, p);
break;
}
case 'stars': {
drawStar(ctx!, p, t);
break;
}
}
}
ctx!.restore();
t++;
raf = requestAnimationFrame(tick);
}
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('resize', onResize);
};
});
</script>
<!--
Fixed full-viewport overlay, pointer-events-none so all clicks pass through.
z-index 40 keeps it below the sticky nav (z-50) but above page content.
-->
<canvas
bind:this={canvas}
class="fixed inset-0 z-40 pointer-events-none"
aria-hidden="true"
></canvas>

View File

@@ -16,7 +16,7 @@
const display = $derived(hovered || rating || 0);
</script>
<div class="flex items-center gap-1">
<div class="flex items-center gap-2">
<div class="flex items-center gap-0.5">
{#each [1,2,3,4,5] as star}
<button
@@ -44,10 +44,13 @@
</button>
{/each}
</div>
{#if avg && count}
<span class="text-xs text-(--color-muted) ml-1">{avg} ({count})</span>
{:else if avg}
<span class="text-xs text-(--color-muted) ml-1">{avg}</span>
{#if count > 0}
<div class="flex flex-col">
<span class="text-sm font-semibold text-(--color-text)">{avg.toFixed(1)}</span>
<span class="text-xs text-(--color-muted) leading-none">{count} {count === 1 ? 'rating' : 'ratings'}</span>
</div>
{:else if !readonly}
<span class="text-xs text-(--color-muted)">Rate this book</span>
{/if}
</div>

View File

@@ -2,7 +2,7 @@
> Auto-generated i18n message functions. Import `messages.js` to use translated strings.
Compiled from: `/Users/kalekber/code/libnovel-v2/ui/project.inlang`
Compiled from: `/opt/libnovel-v3/ui/project.inlang`
## What is this folder?

View File

@@ -373,11 +373,13 @@ export * from './admin_tools_label.js'
export * from './admin_nav_scrape.js'
export * from './admin_nav_audio.js'
export * from './admin_nav_translation.js'
export * from './admin_nav_import.js'
export * from './admin_nav_changelog.js'
export * from './admin_nav_image_gen.js'
export * from './admin_nav_text_gen.js'
export * from './admin_nav_catalogue_tools.js'
export * from './admin_nav_ai_jobs.js'
export * from './admin_nav_notifications.js'
export * from './admin_nav_feedback.js'
export * from './admin_nav_errors.js'
export * from './admin_nav_analytics.js'
@@ -405,18 +407,6 @@ export * from './admin_audio_no_cache_results.js'
export * from './admin_changelog_gitea.js'
export * from './admin_changelog_no_releases.js'
export * from './admin_changelog_load_error.js'
export * from './admin_translation_page_title.js'
export * from './admin_translation_heading.js'
export * from './admin_translation_tab_enqueue.js'
export * from './admin_translation_tab_jobs.js'
export * from './admin_translation_filter_placeholder.js'
export * from './admin_translation_no_matching.js'
export * from './admin_translation_no_jobs.js'
export * from './admin_ai_jobs_page_title.js'
export * from './admin_ai_jobs_heading.js'
export * from './admin_ai_jobs_subheading.js'
export * from './admin_text_gen_page_title.js'
export * from './admin_text_gen_heading.js'
export * from './comments_top.js'
export * from './comments_new.js'
export * from './comments_posting.js'
@@ -453,4 +443,16 @@ export * from './feed_not_logged_in.js'
export * from './feed_reader_label.js'
export * from './feed_chapters_label.js'
export * from './feed_browse_cta.js'
export * from './feed_find_users_cta.js'
export * from './feed_find_users_cta.js'
export * from './admin_translation_page_title.js'
export * from './admin_translation_heading.js'
export * from './admin_translation_tab_enqueue.js'
export * from './admin_translation_tab_jobs.js'
export * from './admin_translation_filter_placeholder.js'
export * from './admin_translation_no_matching.js'
export * from './admin_translation_no_jobs.js'
export * from './admin_ai_jobs_page_title.js'
export * from './admin_ai_jobs_heading.js'
export * from './admin_ai_jobs_subheading.js'
export * from './admin_text_gen_page_title.js'
export * from './admin_text_gen_heading.js'

View File

@@ -26,6 +26,10 @@ const fr_admin_ai_jobs_heading = /** @type {(inputs: Admin_Ai_Jobs_HeadingInputs
};
/**
* | output |
* | --- |
* | "AI Jobs" |
*
* @param {Admin_Ai_Jobs_HeadingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -37,4 +41,4 @@ export const admin_ai_jobs_heading = /** @type {((inputs?: Admin_Ai_Jobs_Heading
if (locale === "id") return id_admin_ai_jobs_heading(inputs)
if (locale === "pt") return pt_admin_ai_jobs_heading(inputs)
return fr_admin_ai_jobs_heading(inputs)
});
});

View File

@@ -26,6 +26,10 @@ const fr_admin_ai_jobs_page_title = /** @type {(inputs: Admin_Ai_Jobs_Page_Title
};
/**
* | output |
* | --- |
* | "AI Jobs — Admin" |
*
* @param {Admin_Ai_Jobs_Page_TitleInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -37,4 +41,4 @@ export const admin_ai_jobs_page_title = /** @type {((inputs?: Admin_Ai_Jobs_Page
if (locale === "id") return id_admin_ai_jobs_page_title(inputs)
if (locale === "pt") return pt_admin_ai_jobs_page_title(inputs)
return fr_admin_ai_jobs_page_title(inputs)
});
});

View File

@@ -26,6 +26,10 @@ const fr_admin_ai_jobs_subheading = /** @type {(inputs: Admin_Ai_Jobs_Subheading
};
/**
* | output |
* | --- |
* | "Background AI generation tasks" |
*
* @param {Admin_Ai_Jobs_SubheadingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -37,4 +41,4 @@ export const admin_ai_jobs_subheading = /** @type {((inputs?: Admin_Ai_Jobs_Subh
if (locale === "id") return id_admin_ai_jobs_subheading(inputs)
if (locale === "pt") return pt_admin_ai_jobs_subheading(inputs)
return fr_admin_ai_jobs_subheading(inputs)
});
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Nav_ImportInputs */
const en_admin_nav_import = /** @type {(inputs: Admin_Nav_ImportInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Import`)
};
const ru_admin_nav_import = /** @type {(inputs: Admin_Nav_ImportInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Import`)
};
const id_admin_nav_import = /** @type {(inputs: Admin_Nav_ImportInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Import`)
};
const pt_admin_nav_import = /** @type {(inputs: Admin_Nav_ImportInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Import`)
};
const fr_admin_nav_import = /** @type {(inputs: Admin_Nav_ImportInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Import`)
};
/**
* | output |
* | --- |
* | "Import" |
*
* @param {Admin_Nav_ImportInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_import = /** @type {((inputs?: Admin_Nav_ImportInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_ImportInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_import(inputs)
if (locale === "ru") return ru_admin_nav_import(inputs)
if (locale === "id") return id_admin_nav_import(inputs)
if (locale === "pt") return pt_admin_nav_import(inputs)
return fr_admin_nav_import(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Nav_NotificationsInputs */
const en_admin_nav_notifications = /** @type {(inputs: Admin_Nav_NotificationsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Notifications`)
};
const ru_admin_nav_notifications = /** @type {(inputs: Admin_Nav_NotificationsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Уведомления`)
};
const id_admin_nav_notifications = /** @type {(inputs: Admin_Nav_NotificationsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Notifikasi`)
};
const pt_admin_nav_notifications = /** @type {(inputs: Admin_Nav_NotificationsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Notificações`)
};
const fr_admin_nav_notifications = /** @type {(inputs: Admin_Nav_NotificationsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Notifications`)
};
/**
* | output |
* | --- |
* | "Notifications" |
*
* @param {Admin_Nav_NotificationsInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_notifications = /** @type {((inputs?: Admin_Nav_NotificationsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_NotificationsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_notifications(inputs)
if (locale === "ru") return ru_admin_nav_notifications(inputs)
if (locale === "id") return id_admin_nav_notifications(inputs)
if (locale === "pt") return pt_admin_nav_notifications(inputs)
return fr_admin_nav_notifications(inputs)
});

View File

@@ -26,6 +26,10 @@ const fr_admin_text_gen_heading = /** @type {(inputs: Admin_Text_Gen_HeadingInpu
};
/**
* | output |
* | --- |
* | "Text Generation" |
*
* @param {Admin_Text_Gen_HeadingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -37,4 +41,4 @@ export const admin_text_gen_heading = /** @type {((inputs?: Admin_Text_Gen_Headi
if (locale === "id") return id_admin_text_gen_heading(inputs)
if (locale === "pt") return pt_admin_text_gen_heading(inputs)
return fr_admin_text_gen_heading(inputs)
});
});

View File

@@ -26,6 +26,10 @@ const fr_admin_text_gen_page_title = /** @type {(inputs: Admin_Text_Gen_Page_Tit
};
/**
* | output |
* | --- |
* | "Text Gen — Admin" |
*
* @param {Admin_Text_Gen_Page_TitleInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -37,4 +41,4 @@ export const admin_text_gen_page_title = /** @type {((inputs?: Admin_Text_Gen_Pa
if (locale === "id") return id_admin_text_gen_page_title(inputs)
if (locale === "pt") return pt_admin_text_gen_page_title(inputs)
return fr_admin_text_gen_page_title(inputs)
});
});

View File

@@ -26,6 +26,10 @@ const fr_admin_translation_filter_placeholder = /** @type {(inputs: Admin_Transl
};
/**
* | output |
* | --- |
* | "Filter by slug, lang, or status…" |
*
* @param {Admin_Translation_Filter_PlaceholderInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -37,4 +41,4 @@ export const admin_translation_filter_placeholder = /** @type {((inputs?: Admin_
if (locale === "id") return id_admin_translation_filter_placeholder(inputs)
if (locale === "pt") return pt_admin_translation_filter_placeholder(inputs)
return fr_admin_translation_filter_placeholder(inputs)
});
});

View File

@@ -26,6 +26,10 @@ const fr_admin_translation_heading = /** @type {(inputs: Admin_Translation_Headi
};
/**
* | output |
* | --- |
* | "Machine Translation" |
*
* @param {Admin_Translation_HeadingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -37,4 +41,4 @@ export const admin_translation_heading = /** @type {((inputs?: Admin_Translation
if (locale === "id") return id_admin_translation_heading(inputs)
if (locale === "pt") return pt_admin_translation_heading(inputs)
return fr_admin_translation_heading(inputs)
});
});

View File

@@ -26,6 +26,10 @@ const fr_admin_translation_no_jobs = /** @type {(inputs: Admin_Translation_No_Jo
};
/**
* | output |
* | --- |
* | "No translation jobs yet." |
*
* @param {Admin_Translation_No_JobsInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -37,4 +41,4 @@ export const admin_translation_no_jobs = /** @type {((inputs?: Admin_Translation
if (locale === "id") return id_admin_translation_no_jobs(inputs)
if (locale === "pt") return pt_admin_translation_no_jobs(inputs)
return fr_admin_translation_no_jobs(inputs)
});
});

View File

@@ -26,6 +26,10 @@ const fr_admin_translation_no_matching = /** @type {(inputs: Admin_Translation_N
};
/**
* | output |
* | --- |
* | "No matching jobs." |
*
* @param {Admin_Translation_No_MatchingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -37,4 +41,4 @@ export const admin_translation_no_matching = /** @type {((inputs?: Admin_Transla
if (locale === "id") return id_admin_translation_no_matching(inputs)
if (locale === "pt") return pt_admin_translation_no_matching(inputs)
return fr_admin_translation_no_matching(inputs)
});
});

View File

@@ -26,6 +26,10 @@ const fr_admin_translation_page_title = /** @type {(inputs: Admin_Translation_Pa
};
/**
* | output |
* | --- |
* | "Translation — Admin" |
*
* @param {Admin_Translation_Page_TitleInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -37,4 +41,4 @@ export const admin_translation_page_title = /** @type {((inputs?: Admin_Translat
if (locale === "id") return id_admin_translation_page_title(inputs)
if (locale === "pt") return pt_admin_translation_page_title(inputs)
return fr_admin_translation_page_title(inputs)
});
});

View File

@@ -26,6 +26,10 @@ const fr_admin_translation_tab_enqueue = /** @type {(inputs: Admin_Translation_T
};
/**
* | output |
* | --- |
* | "Enqueue" |
*
* @param {Admin_Translation_Tab_EnqueueInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -37,4 +41,4 @@ export const admin_translation_tab_enqueue = /** @type {((inputs?: Admin_Transla
if (locale === "id") return id_admin_translation_tab_enqueue(inputs)
if (locale === "pt") return pt_admin_translation_tab_enqueue(inputs)
return fr_admin_translation_tab_enqueue(inputs)
});
});

View File

@@ -26,6 +26,10 @@ const fr_admin_translation_tab_jobs = /** @type {(inputs: Admin_Translation_Tab_
};
/**
* | output |
* | --- |
* | "Jobs" |
*
* @param {Admin_Translation_Tab_JobsInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -37,4 +41,4 @@ export const admin_translation_tab_jobs = /** @type {((inputs?: Admin_Translatio
if (locale === "id") return id_admin_translation_tab_jobs(inputs)
if (locale === "pt") return pt_admin_translation_tab_jobs(inputs)
return fr_admin_translation_tab_jobs(inputs)
});
});

View File

@@ -41,4 +41,4 @@ export const profile_theme_cyber = /** @type {((inputs?: Profile_Theme_CyberInpu
if (locale === "id") return id_profile_theme_cyber(inputs)
if (locale === "pt") return pt_profile_theme_cyber(inputs)
return fr_profile_theme_cyber(inputs)
});
});

View File

@@ -41,4 +41,4 @@ export const profile_theme_forest = /** @type {((inputs?: Profile_Theme_ForestIn
if (locale === "id") return id_profile_theme_forest(inputs)
if (locale === "pt") return pt_profile_theme_forest(inputs)
return fr_profile_theme_forest(inputs)
});
});

View File

@@ -41,4 +41,4 @@ export const profile_theme_mono = /** @type {((inputs?: Profile_Theme_MonoInputs
if (locale === "id") return id_profile_theme_mono(inputs)
if (locale === "pt") return pt_profile_theme_mono(inputs)
return fr_profile_theme_mono(inputs)
});
});

View File

@@ -44,6 +44,9 @@ export interface Book {
source_url: string;
ranking: number;
meta_updated: string;
archived?: boolean;
visibility?: string;
submitted_by?: string;
}
export interface ChapterIdx {
@@ -76,6 +79,7 @@ export interface PBUserSettings {
font_family?: string;
font_size?: number;
announce_chapter?: boolean;
audio_mode?: string;
updated?: string;
}
@@ -94,6 +98,8 @@ export interface User {
oauth_id?: string;
polar_customer_id?: string;
polar_subscription_id?: string;
notify_new_chapters?: boolean;
notify_new_chapters_push?: boolean;
}
// ─── Auth token cache ─────────────────────────────────────────────────────────
@@ -376,7 +382,7 @@ export async function getTrendingBooks(limit = 8): Promise<Book[]> {
const key = `books:trending:${limit}`;
const cached = await cache.get<Book[]>(key);
if (cached) return cached;
const books = await listN<Book>('books', limit, 'ranking>0', '+ranking');
const books = await listN<Book>('books', limit, 'ranking>0&&visibility="public"', '+ranking');
await cache.set(key, books, 15 * 60);
return books;
}
@@ -399,7 +405,8 @@ export async function getRecommendedBooks(
const genreFilter = sortedGenres
.map((g) => `genres~"${g.replace(/"/g, '')}"`)
.join('||');
books = await listN<Book>('books', limit * 4, genreFilter, '+ranking');
const filter = `visibility="public"&&(${genreFilter})`;
books = await listN<Book>('books', limit * 4, filter, '+ranking');
await cache.set(key, books, 10 * 60);
}
return books.filter((b) => !excludeSlugs.has(b.slug)).slice(0, limit);
@@ -413,7 +420,7 @@ export async function recentlyAddedBooks(limit = 6): Promise<Book[]> {
const key = `books:recent:${limit}`;
const cached = await cache.get<Book[]>(key);
if (cached) return cached;
const books = await listN<Book>('books', limit, '', '-meta_updated');
const books = await listN<Book>('books', limit, 'visibility="public"', '-meta_updated');
await cache.set(key, books, 5 * 60);
return books;
}
@@ -447,9 +454,11 @@ export async function recentlyUpdatedBooks(limit = 8): Promise<Book[]> {
if (!slugs.length) return recentlyAddedBooks(limit);
const books = await getBooksBySlugs(new Set(slugs));
// Restore recency order (getBooksBySlugs returns in title sort order)
// Restore recency order and filter to public-only books.
const bookMap = new Map(books.map((b) => [b.slug, b]));
const ordered = slugs.flatMap((s) => (bookMap.has(s) ? [bookMap.get(s)!] : []));
const ordered = slugs
.flatMap((s) => (bookMap.has(s) ? [bookMap.get(s)!] : []))
.filter((b) => b.visibility === 'public');
await cache.set(key, ordered, 5 * 60);
return ordered;
@@ -656,11 +665,16 @@ function libraryFilter(sessionId: string, userId?: string): string {
/** Returns all slugs the user has explicitly saved to their library. */
export async function getSavedSlugs(sessionId: string, userId?: string): Promise<Set<string>> {
const cacheKey = userId ? `saved_slugs:user:${userId}` : `saved_slugs:session:${sessionId}`;
const cached = await cache.get<string[]>(cacheKey);
if (cached) return new Set(cached);
const rows = await listAll<UserLibraryEntry>(
'user_library',
libraryFilter(sessionId, userId)
);
return new Set(rows.map((r) => r.slug));
const slugs = rows.map((r) => r.slug);
await cache.set(cacheKey, slugs, SAVED_SLUGS_TTL);
return new Set(slugs);
}
/** Returns whether a specific slug is saved. */
@@ -707,7 +721,11 @@ export async function saveBook(
if (!res.ok) {
const body = await res.text().catch(() => '');
log.error('pocketbase', 'saveBook POST failed', { slug, status: res.status, body });
return;
}
// Invalidate saved-slugs cache so the next discover load excludes this book.
const savedKey = userId ? `saved_slugs:user:${userId}` : `saved_slugs:session:${sessionId}`;
await cache.invalidate(savedKey);
}
/** Remove a book from the user's library. */
@@ -1013,7 +1031,7 @@ export async function getSettings(
export async function saveSettings(
sessionId: string,
settings: { autoNext: boolean; voice: string; speed: number; theme?: string; locale?: string; fontFamily?: string; fontSize?: number; announceChapter?: boolean },
settings: { autoNext: boolean; voice: string; speed: number; theme?: string; locale?: string; fontFamily?: string; fontSize?: number; announceChapter?: boolean; audioMode?: string },
userId?: string
): Promise<void> {
const existing = await listOne<PBUserSettings & { id: string }>(
@@ -1033,6 +1051,7 @@ export async function saveSettings(
if (settings.fontFamily !== undefined) payload.font_family = settings.fontFamily;
if (settings.fontSize !== undefined) payload.font_size = settings.fontSize;
if (settings.announceChapter !== undefined) payload.announce_chapter = settings.announceChapter;
if (settings.audioMode !== undefined) payload.audio_mode = settings.audioMode;
if (userId) payload.user_id = userId;
if (existing) {
@@ -1162,6 +1181,101 @@ export async function getSlugsWithAudio(): Promise<Set<string>> {
return new Set(jobs.map((j) => j.slug));
}
/**
* Returns books that have at least one completed audio chapter, sorted by
* number of narrated chapters descending.
* Cached for 5 minutes (same TTL as the catalogue audio badge).
*/
const AUDIO_BOOKS_CACHE_KEY = 'audio:books_with_count';
const AUDIO_BOOKS_CACHE_TTL = 5 * 60;
export interface AudioBookEntry {
book: Book;
audioChapters: number;
}
export async function getBooksWithAudioCount(limit = 100): Promise<AudioBookEntry[]> {
const cached = await cache.get<AudioBookEntry[]>(AUDIO_BOOKS_CACHE_KEY);
if (cached) return cached.slice(0, limit);
// Count done jobs per slug
const jobs = await listAll<AudioJob>('audio_jobs', 'status="done"', 'slug');
const countBySlug = new Map<string, number>();
for (const j of jobs) {
// audio_jobs can have multiple voice variants for the same chapter — deduplicate
// by chapter number so we count chapters, not voice variants.
// cache_key format: "slug/chapter/voice"
const slug = j.slug;
if (!countBySlug.has(slug)) countBySlug.set(slug, 0);
// We'll use a Set per slug after this loop instead
}
// Build slug → Set<chapter> to deduplicate voice variants
const chapsBySlug = new Map<string, Set<number>>();
for (const j of jobs) {
if (!chapsBySlug.has(j.slug)) chapsBySlug.set(j.slug, new Set());
chapsBySlug.get(j.slug)!.add(j.chapter);
}
const slugs = [...chapsBySlug.keys()];
if (slugs.length === 0) return [];
const books = await getBooksBySlugs(slugs);
const bookMap = new Map(books.map((b) => [b.slug, b]));
const entries: AudioBookEntry[] = [];
for (const [slug, chapters] of chapsBySlug) {
const book = bookMap.get(slug);
if (!book || book.visibility !== 'public') continue;
entries.push({ book, audioChapters: chapters.size });
}
// Sort by most chapters narrated first
entries.sort((a, b) => b.audioChapters - a.audioChapters);
await cache.set(AUDIO_BOOKS_CACHE_KEY, entries, AUDIO_BOOKS_CACHE_TTL);
return entries.slice(0, limit);
}
/**
* Returns a map of chapter number → best available voice for a given slug.
* "Best" means: prefer `preferredVoice` if a done job exists for it,
* otherwise fall back to any done voice for that chapter.
* Result is cached per slug for 60 seconds (audio jobs complete frequently).
*/
export async function getReadyChaptersForSlug(
slug: string,
preferredVoice = ''
): Promise<Map<number, string>> {
const cacheKey = `audio:ready_chapters:${slug}`;
const cached = await cache.get<{ chapter: number; voice: string }[]>(cacheKey);
const raw = cached ?? await (async () => {
const filter = encodeURIComponent(`slug="${slug.replace(/"/g, '\\"')}"&&status="done"`);
const jobs = await listAll<AudioJob>('audio_jobs', filter, 'chapter');
const result: { chapter: number; voice: string }[] = jobs.map((j) => ({
chapter: j.chapter,
voice: j.voice ?? ''
}));
await cache.set(cacheKey, result, 60);
return result;
})();
// Build chapter → voices map
const byChapter = new Map<number, string[]>();
for (const { chapter, voice } of raw) {
if (!byChapter.has(chapter)) byChapter.set(chapter, []);
byChapter.get(chapter)!.push(voice);
}
// Resolve best voice per chapter
const result = new Map<number, string>();
for (const [chapter, voices] of byChapter) {
const best = preferredVoice && voices.includes(preferredVoice)
? preferredVoice
: voices[0] ?? '';
result.set(chapter, best);
}
return result;
}
// ─── Translation jobs ─────────────────────────────────────────────────────────
export interface TranslationJob {
@@ -1317,10 +1431,34 @@ export async function isSessionRevoked(authSessionId: string): Promise<boolean>
}
/**
* List all active sessions for a user.
* Returns true for user-agents that are clearly automated tools (curl, scrapers,
* debug logins, etc.) that should not appear in the user-facing sessions list.
* These sessions still exist in the DB so auth checks continue to work.
*/
function isBotUserAgent(ua: string): boolean {
if (!ua) return false;
const lower = ua.toLowerCase();
return (
lower.startsWith('curl/') ||
lower.startsWith('python') ||
lower.startsWith('wget/') ||
lower.startsWith('go-http-client') ||
lower.startsWith('axios/') ||
lower.startsWith('node-fetch') ||
lower.startsWith('undici') ||
lower.startsWith('okhttp') ||
lower.startsWith('java/')
);
}
/**
* List all active sessions for a user, excluding non-browser/tool sessions
* (curl, debug-login artifacts, scrapers, etc.) from the displayed list.
* The records still exist in the DB so auth validity checks are unaffected.
*/
export async function listUserSessions(userId: string): Promise<UserSession[]> {
return listAll<UserSession>('user_sessions', `user_id="${userId}"`, '-last_seen');
const all = await listAll<UserSession>('user_sessions', `user_id="${userId}"`, '-last_seen');
return all.filter((s) => !isBotUserAgent(s.user_agent) && s.ip !== 'debug');
}
/**
@@ -1339,9 +1477,11 @@ async function pruneStaleUserSessions(
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
const toDelete = new Set<string>();
// Mark stale sessions
// Mark stale sessions and debug/tool sessions for deletion
for (const s of all) {
if (s.last_seen < cutoff) toDelete.add(s.id);
if (s.last_seen < cutoff || s.ip === 'debug' || isBotUserAgent(s.user_agent)) {
toDelete.add(s.id);
}
}
// Mark excess sessions beyond the cap (oldest first — list is sorted -last_seen)
@@ -1383,6 +1523,20 @@ export async function revokeUserSession(recordId: string, userId: string): Promi
return del.ok || del.status === 204;
}
/**
* Delete a session by its auth session ID (the value stored in the cookie).
* Used on logout so the row doesn't linger as a phantom active session.
*/
export async function deleteSessionByAuthId(authSessionId: string): Promise<void> {
const row = await listOne<UserSession>('user_sessions', `session_id="${authSessionId}"`);
if (!row) return;
const token = await getToken();
await fetch(`${PB_URL}/api/collections/user_sessions/records/${row.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
}).catch(() => {});
}
/**
* Revoke all sessions for a user (used on password change etc).
*/
@@ -1399,6 +1553,56 @@ export async function revokeAllUserSessions(userId: string): Promise<void> {
);
}
/**
* Delete all data associated with a user account:
* - user_settings, user_library, progress, comment_votes, book_ratings,
* user_subscriptions, user_sessions, notifications rows owned by the user
* - the app_users record itself
*
* Does NOT delete audio files from MinIO (shared cache) or book comments
* (anonymised to preserve discussion threads).
*/
export async function deleteUserAccount(userId: string, sessionId: string): Promise<void> {
const collections = [
{ name: 'user_settings', filter: `(user_id="${userId}" || session_id="${sessionId}")` },
{ name: 'user_library', filter: `(user_id="${userId}" || session_id="${sessionId}")` },
{ name: 'progress', filter: `(user_id="${userId}" || session_id="${sessionId}")` },
{ name: 'comment_votes', filter: `user_id="${userId}"` },
{ name: 'book_ratings', filter: `user_id="${userId}"` },
{ name: 'user_subscriptions', filter: `(follower_id="${userId}" || followee_id="${userId}")` },
{ name: 'notifications', filter: `user_id="${userId}"` },
{ name: 'user_sessions', filter: `user_id="${userId}"` },
];
const token = await getToken();
for (const { name, filter } of collections) {
try {
const rows = await listAll<{ id: string }>(name, filter);
await Promise.all(
rows.map((r) =>
fetch(`${PB_URL}/api/collections/${name}/records/${r.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
}).catch(() => {})
)
);
} catch {
// Best-effort: log and continue so one failure doesn't abort the rest
log.warn('pocketbase', `deleteUserAccount: failed to purge ${name}`, { userId });
}
}
// Delete the user record last
const res = await pbDelete(`/api/collections/app_users/records/${userId}`);
if (!res.ok) {
const body = await res.text().catch(() => '');
log.error('pocketbase', 'deleteUserAccount: failed to delete app_users record', { userId, status: res.status, body });
throw new Error(`Failed to delete user record (${res.status})`);
}
log.info('pocketbase', 'deleteUserAccount: account deleted', { userId });
}
/**
* Update the avatar_url field for a user record.
*/
@@ -1415,6 +1619,28 @@ export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Pr
}
}
/**
* Update a user's notification preferences (stored on app_users record).
*/
export async function updateUserNotificationPrefs(
userId: string,
prefs: {
notify_new_chapters?: boolean;
notify_new_chapters_push?: boolean;
}
): Promise<void> {
const token = await getToken();
const res = await fetch(`${PB_URL}/api/collections/app_users/records/${userId}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(prefs)
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`updateUserNotificationPrefs failed: ${res.status} ${body}`);
}
}
// ─── Comments ─────────────────────────────────────────────────────────────────
export interface PBBookComment {
@@ -1924,7 +2150,7 @@ export async function getSubscriptionFeed(
for (const p of allProgressArrays[i]) {
if (seen.has(p.slug)) continue;
const book = bookMap.get(p.slug);
if (!book) continue;
if (!book || book.visibility !== 'public') continue;
seen.add(p.slug);
feed.push({ book, readerUsername: username, updated: p.updated });
}
@@ -1966,12 +2192,27 @@ function discoveryFilter(sessionId: string, userId?: string): string {
return `session_id="${sessionId}"`;
}
/** Cache TTL (seconds) for per-user voted/saved slug sets. Short — changes on every swipe. */
const VOTED_SLUGS_TTL = 30;
const SAVED_SLUGS_TTL = 60;
export async function getVotedSlugs(sessionId: string, userId?: string): Promise<Set<string>> {
const cacheKey = userId ? `discovery_votes:user:${userId}` : `discovery_votes:session:${sessionId}`;
const cached = await cache.get<string[]>(cacheKey);
if (cached) return new Set(cached);
const rows = await listAll<DiscoveryVote>(
'discovery_votes',
discoveryFilter(sessionId, userId)
).catch(() => [] as DiscoveryVote[]);
return new Set(rows.map((r) => r.slug));
const slugs = rows.map((r) => r.slug);
await cache.set(cacheKey, slugs, VOTED_SLUGS_TTL);
return new Set(slugs);
}
/** Invalidate the voted-slugs cache entry after a vote is recorded. */
async function invalidateVotedSlugsCache(sessionId: string, userId?: string): Promise<void> {
const key = userId ? `discovery_votes:user:${userId}` : `discovery_votes:session:${sessionId}`;
await cache.invalidate(key);
}
export async function upsertDiscoveryVote(
@@ -1994,6 +2235,7 @@ export async function upsertDiscoveryVote(
const res = await pbPost('/api/collections/discovery_votes/records', payload);
if (!res.ok) log.warn('pocketbase', 'upsertDiscoveryVote POST failed', { slug, status: res.status });
}
await invalidateVotedSlugsCache(sessionId, userId);
}
export async function clearDiscoveryVotes(sessionId: string, userId?: string): Promise<void> {
@@ -2004,6 +2246,7 @@ export async function clearDiscoveryVotes(sessionId: string, userId?: string): P
pbDelete(`/api/collections/discovery_votes/records/${r.id}`).catch(() => {})
)
);
await invalidateVotedSlugsCache(sessionId, userId);
}
// ─── Ratings ──────────────────────────────────────────────────────────────────
@@ -2098,13 +2341,18 @@ export async function getBooksForDiscovery(
userId?: string,
prefs?: DiscoveryPrefs
): Promise<Book[]> {
const [allBooks, votedSlugs, savedSlugs] = await Promise.all([
// Fetch all 4 independent data sources in parallel — previously getAllRatings
// ran sequentially after the first group, adding it to the critical path.
const [allBooks, votedSlugs, savedSlugs, ratingRows] = await Promise.all([
listBooks(),
getVotedSlugs(sessionId, userId),
getSavedSlugs(sessionId, userId)
getSavedSlugs(sessionId, userId),
getAllRatings(),
]);
let candidates = allBooks.filter((b) => !votedSlugs.has(b.slug) && !savedSlugs.has(b.slug));
let candidates = allBooks.filter(
(b) => b.visibility === 'public' && !votedSlugs.has(b.slug) && !savedSlugs.has(b.slug)
);
if (prefs?.genres?.length) {
const preferred = new Set(prefs.genres.map((g) => g.toLowerCase()));
@@ -2120,10 +2368,7 @@ export async function getBooksForDiscovery(
if (sf.length >= 3) candidates = sf;
}
// Fetch avg ratings for candidates, weight top-rated books to surface earlier.
// Fetch in one shot for all candidate slugs. Low-rated / unrated books still
// appear — they're just pushed further back via a stable sort before shuffle.
const ratingRows = await getAllRatings();
// Build slug→avg rating map
const ratingMap = new Map<string, { sum: number; count: number }>();
for (const r of ratingRows) {
const cur = ratingMap.get(r.slug) ?? { sum: 0, count: 0 };
@@ -2199,6 +2444,7 @@ export async function undoDiscoveryVote(
if (row) {
await pbDelete(`/api/collections/discovery_votes/records/${row.id}`).catch(() => {});
}
await invalidateVotedSlugsCache(sessionId, userId);
}
// ─── User stats ────────────────────────────────────────────────────────────────
@@ -2285,12 +2531,103 @@ export async function getUserStats(
};
}
// ─── Site Config ─────────────────────────────────────────────────────────────
//
// A single singleton record in the `site_config` collection holds global
// display settings (seasonal decoration, logo animation, etc.).
// The record is lazily created on first write; reads return safe defaults if
// the collection/record doesn't exist yet.
export interface SiteConfig {
/** Seasonal decoration particle effect: null = off */
decoration: 'snow' | 'sakura' | 'fireflies' | 'leaves' | 'stars' | null;
/** Special CSS class applied to the nav logo text */
logoAnimation: 'none' | 'glow' | 'rainbow' | 'pulse' | 'shimmer';
/** Human-readable label for the current event/season shown in a small badge */
eventLabel: string;
}
const SITE_CONFIG_DEFAULTS: SiteConfig = {
decoration: null,
logoAnimation: 'none',
eventLabel: '',
};
// In-memory short cache so every SSR request doesn't hammer PocketBase
let _siteConfigCache: { value: SiteConfig; exp: number } | null = null;
const SITE_CONFIG_CACHE_TTL = 60_000; // 60 seconds
export async function getSiteConfig(): Promise<SiteConfig> {
if (_siteConfigCache && Date.now() < _siteConfigCache.exp) {
return _siteConfigCache.value;
}
try {
const list = await pbGet<{ items: Array<{ id: string } & SiteConfig> }>(
'/api/collections/site_config/records?perPage=1'
);
const row = list.items?.[0];
const value: SiteConfig = row
? {
decoration: row.decoration ?? null,
logoAnimation: row.logoAnimation ?? 'none',
eventLabel: row.eventLabel ?? '',
}
: { ...SITE_CONFIG_DEFAULTS };
_siteConfigCache = { value, exp: Date.now() + SITE_CONFIG_CACHE_TTL };
return value;
} catch {
// Collection may not exist yet — return defaults silently
return { ...SITE_CONFIG_DEFAULTS };
}
}
export async function saveSiteConfig(patch: Partial<SiteConfig>): Promise<void> {
// Bust cache
_siteConfigCache = null;
// Ensure collection exists and find the singleton record
let existingId: string | null = null;
try {
const list = await pbGet<{ items: Array<{ id: string }> }>(
'/api/collections/site_config/records?perPage=1'
);
existingId = list.items?.[0]?.id ?? null;
} catch {
// Collection doesn't exist yet — create it via PocketBase API
await pbPost('/api/collections', {
name: 'site_config',
type: 'base',
fields: [
{ name: 'decoration', type: 'text' },
{ name: 'logoAnimation', type: 'text' },
{ name: 'eventLabel', type: 'text' },
],
});
}
if (existingId) {
await pbPatch(`/api/collections/site_config/records/${existingId}`, patch);
} else {
await pbPost('/api/collections/site_config/records', {
...SITE_CONFIG_DEFAULTS,
...patch,
});
}
}
// ─── AI Jobs ──────────────────────────────────────────────────────────────────
const AI_JOBS_CACHE_KEY = 'admin:ai_jobs';
const AI_JOBS_CACHE_TTL = 30; // 30 seconds — same as other admin job lists
/**
* List all AI jobs from PocketBase, sorted by started descending.
* No caching — admin views always want fresh data.
* Short-lived cache (30s) to avoid hammering PocketBase on every navigation.
*/
export async function listAIJobs(): Promise<AIJob[]> {
return listAll<AIJob>('ai_jobs', '', '-started');
const cached = await cache.get<AIJob[]>(AI_JOBS_CACHE_KEY);
if (cached) return cached;
const jobs = await listAll<AIJob>('ai_jobs', '', '-started');
await cache.set(AI_JOBS_CACHE_KEY, jobs, AI_JOBS_CACHE_TTL);
return jobs;
}

View File

@@ -15,18 +15,31 @@ import { env } from '$env/dynamic/private';
import * as cache from '$lib/server/cache';
export const BACKEND_URL = env.BACKEND_API_URL ?? 'http://localhost:8080';
const ADMIN_TOKEN = env.BACKEND_ADMIN_TOKEN ?? '';
/**
* Fetch a path on the backend, throwing a 502 on network failures.
*
* The `path` must start with `/` (e.g. `/api/voices`).
* Requests to `/api/admin/*` automatically include the Bearer token from
* the BACKEND_ADMIN_TOKEN environment variable.
*
* SvelteKit `error()` exceptions are always re-thrown so callers can
* short-circuit correctly inside their own catch blocks.
*/
export async function backendFetch(path: string, init?: RequestInit): Promise<Response> {
let finalInit = init;
if (ADMIN_TOKEN && path.startsWith('/api/admin')) {
finalInit = {
...init,
headers: {
Authorization: `Bearer ${ADMIN_TOKEN}`,
...((init?.headers ?? {}) as Record<string, string>)
}
};
}
try {
return await fetch(`${BACKEND_URL}${path}`, init);
return await fetch(`${BACKEND_URL}${path}`, finalInit);
} catch (e) {
// Re-throw SvelteKit HTTP errors so they propagate to the framework.
if (e instanceof Error && 'status' in e) throw e;
@@ -34,6 +47,27 @@ export async function backendFetch(path: string, init?: RequestInit): Promise<Re
}
}
/**
* Like backendFetch but always attaches the admin bearer token regardless of path.
* Use this when an admin user should bypass the visibility filter on public endpoints
* (e.g. GET /api/catalogue for the admin catalogue view).
*/
export async function backendFetchAdmin(path: string, init?: RequestInit): Promise<Response> {
const finalInit: RequestInit = {
...init,
headers: {
...(ADMIN_TOKEN ? { Authorization: `Bearer ${ADMIN_TOKEN}` } : {}),
...((init?.headers ?? {}) as Record<string, string>)
}
};
try {
return await fetch(`${BACKEND_URL}${path}`, finalInit);
} catch (e) {
if (e instanceof Error && 'status' in e) throw e;
throw error(502, 'Could not reach backend');
}
}
// ─── Cached admin model lists ─────────────────────────────────────────────────
const MODELS_CACHE_TTL = 10 * 60; // 10 minutes — model lists rarely change

View File

@@ -1,6 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
import { getSettings } from '$lib/server/pocketbase';
import { getSettings, getSiteConfig } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
// Routes that are accessible without being logged in
@@ -17,7 +17,7 @@ export const load: LayoutServerLoad = async ({ locals, url, cookies }) => {
redirect(302, `/login`);
}
let settings = { autoNext: false, voice: 'af_bella', speed: 1.0, theme: 'amber', locale: 'en', fontFamily: 'system', fontSize: 1.0, announceChapter: false };
let settings = { autoNext: false, voice: 'af_bella', speed: 1.0, theme: 'amber', locale: 'en', fontFamily: 'system', fontSize: 1.0, announceChapter: false, audioMode: 'stream' };
try {
const row = await getSettings(locals.sessionId, locals.user?.id);
if (row) {
@@ -29,7 +29,8 @@ export const load: LayoutServerLoad = async ({ locals, url, cookies }) => {
locale: row.locale ?? 'en',
fontFamily: row.font_family ?? 'system',
fontSize: row.font_size || 1.0,
announceChapter: row.announce_chapter ?? false
announceChapter: row.announce_chapter ?? false,
audioMode: row.audio_mode ?? 'stream'
};
}
} catch (e) {
@@ -59,6 +60,11 @@ export const load: LayoutServerLoad = async ({ locals, url, cookies }) => {
return {
user: locals.user,
isPro: locals.isPro,
settings
settings,
siteConfig: await getSiteConfig().catch(() => ({
decoration: null as null,
logoAnimation: 'none' as const,
eventLabel: '',
})),
};
};

View File

@@ -13,6 +13,8 @@
import { locales, getLocale } from '$lib/paraglide/runtime.js';
import ListeningMode from '$lib/components/ListeningMode.svelte';
import SearchModal from '$lib/components/SearchModal.svelte';
import NotificationsModal from '$lib/components/NotificationsModal.svelte';
import SeasonalDecoration from '$lib/components/SeasonalDecoration.svelte';
import { fly, fade } from 'svelte/transition';
let { children, data }: { children: Snippet; data: LayoutData } = $props();
@@ -23,6 +25,49 @@
// Universal search
let searchOpen = $state(false);
// Notifications
let notificationsOpen = $state(false);
let notifications = $state<{id: string; title: string; message: string; link: string; read: boolean}[]>([]);
async function loadNotifications() {
if (!data.user) return;
try {
const res = await fetch('/api/notifications?user_id=' + data.user.id);
if (res.ok) {
const d = await res.json();
notifications = d.notifications || [];
}
} catch (e) { console.error('load notifications:', e); }
}
async function markRead(id: string) {
try {
await fetch('/api/notifications/' + id, { method: 'PATCH' });
notifications = notifications.map(n => n.id === id ? {...n, read: true} : n);
} catch (e) { console.error('mark read:', e); }
}
async function markAllRead() {
if (!data.user) return;
try {
await fetch('/api/notifications?user_id=' + data.user.id, { method: 'PATCH' });
notifications = notifications.map(n => ({ ...n, read: true }));
} catch (e) { console.error('mark all read:', e); }
}
async function dismissNotification(id: string) {
try {
await fetch('/api/notifications/' + id, { method: 'DELETE' });
notifications = notifications.filter(n => n.id !== id);
} catch (e) { console.error('dismiss notification:', e); }
}
async function clearAllNotifications() {
if (!data.user) return;
try {
await fetch('/api/notifications?user_id=' + data.user.id, { method: 'DELETE' });
notifications = [];
} catch (e) { console.error('clear notifications:', e); }
}
$effect(() => { if (data.user) loadNotifications(); });
$effect(() => { if (notificationsOpen && data.user) loadNotifications(); });
const unreadCount = $derived(notifications.filter(n => !n.read).length);
// Close search on navigation
$effect(() => {
void page.url.pathname;
@@ -50,6 +95,20 @@
let listeningModeOpen = $state(false);
let listeningModeChapters = $state(false);
// ── Site config (seasonal decoration + logo animation) ──────────────────
// svelte-ignore state_referenced_locally
let siteDecoration = $state(data.siteConfig?.decoration ?? null);
// svelte-ignore state_referenced_locally
let siteLogoAnim = $state(data.siteConfig?.logoAnimation ?? 'none');
// svelte-ignore state_referenced_locally
let siteEventLabel = $state(data.siteConfig?.eventLabel ?? '');
// Refresh when invalidateAll() re-runs layout load (e.g. after admin saves)
$effect(() => {
siteDecoration = data.siteConfig?.decoration ?? null;
siteLogoAnim = data.siteConfig?.logoAnimation ?? 'none';
siteEventLabel = data.siteConfig?.eventLabel ?? '';
});
// Build time formatted in the user's local timezone (populated on mount so
// SSR and CSR don't produce a mismatch — SSR renders nothing, hydration fills it in).
let buildTimeLocal = $state('');
@@ -111,6 +170,7 @@
audioStore.voice = data.settings.voice;
audioStore.speed = data.settings.speed;
audioStore.announceChapter = data.settings.announceChapter ?? false;
audioStore.audioMode = (data.settings.audioMode === 'generate' ? 'generate' : 'stream');
}
// Always sync theme + font (profile page calls invalidateAll after saving)
currentTheme = data.settings.theme ?? 'amber';
@@ -133,6 +193,7 @@
const fontFamily = currentFontFamily;
const fontSize = currentFontSize;
const announceChapter = audioStore.announceChapter;
const audioMode = audioStore.audioMode;
// Skip saving until settings have been applied from the server AND
// at least one user-driven change has occurred after that.
@@ -143,7 +204,7 @@
fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoNext, voice, speed, theme, fontFamily, fontSize, announceChapter })
body: JSON.stringify({ autoNext, voice, speed, theme, fontFamily, fontSize, announceChapter, audioMode })
}).catch(() => {});
}, 800) as unknown as number;
});
@@ -467,7 +528,7 @@
style="display:none"
></audio>
<div class="min-h-screen flex flex-col" class:pb-24={audioStore.active}>
<div class="min-h-screen flex flex-col" class:pb-24={audioStore.active && !audioStore.suppressMiniBar}>
<!-- Navigation progress bar — shown while SSR is running for any page transition -->
{#if navigating}
<div class="fixed top-0 left-0 right-0 z-[100] h-1 bg-(--color-surface-2)">
@@ -476,8 +537,18 @@
{/if}
<header class="border-b border-(--color-border) bg-(--color-surface) sticky top-0 z-50">
<nav class="max-w-6xl mx-auto px-4 h-14 flex items-center gap-6">
<a href="/" class="text-(--color-brand) font-bold text-lg tracking-tight hover:text-(--color-brand-dim) shrink-0">
<a href="/" class="text-(--color-brand) font-bold text-lg tracking-tight hover:text-(--color-brand-dim) shrink-0 flex items-center gap-1.5
{siteLogoAnim === 'glow' ? 'logo-anim-glow' : ''}
{siteLogoAnim === 'shimmer' ? 'logo-anim-shimmer' : ''}
{siteLogoAnim === 'pulse' ? 'logo-anim-pulse' : ''}
{siteLogoAnim === 'rainbow' ? 'logo-anim-rainbow' : ''}
">
libnovel
{#if siteEventLabel}
<span class="text-[10px] font-semibold px-1.5 py-0.5 rounded-full bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 leading-none tracking-wide">
{siteEventLabel}
</span>
{/if}
</a>
{#if page.data.book?.title && /\/books\/[^/]+\/chapters\//.test(page.url.pathname)}
@@ -515,6 +586,14 @@
>
{m.nav_catalogue()}
</a>
{#if data.user}
<a
href="/submit"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/submit') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
Publish
</a>
{/if}
{#if !data.isPro}
<a
href="/subscribe"
@@ -525,19 +604,36 @@
</a>
{/if}
<div class="ml-auto flex items-center gap-2">
<!-- Universal search button (hidden on chapter/reader pages) -->
{#if !/\/books\/[^/]+\/chapters\//.test(page.url.pathname)}
<button
type="button"
onclick={() => { searchOpen = true; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; menuOpen = false; }}
title="Search (/ or ⌘K)"
aria-label="Search books"
class="flex items-center justify-center w-8 h-8 rounded transition-colors text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</button>
<!-- Universal search button -->
<button
type="button"
onclick={() => { searchOpen = true; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; menuOpen = false; notificationsOpen = false; }}
title="Search (/ or ⌘K)"
aria-label="Search books"
class="flex items-center justify-center w-8 h-8 rounded transition-colors text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</button>
<!-- Notifications bell -->
{#if data.user}
<div class="relative">
<button
type="button"
onclick={() => { notificationsOpen = !notificationsOpen; searchOpen = false; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; menuOpen = false; }}
title="Notifications"
class="flex items-center justify-center w-8 h-8 rounded transition-colors {notificationsOpen ? 'bg-(--color-surface-2)' : 'hover:bg-(--color-surface-2)'} relative"
>
<svg class="w-4 h-4 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
</svg>
{#if unreadCount > 0}
<span class="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
{/if}
</button>
</div>
{/if}
<!-- Theme dropdown (desktop) -->
<div class="hidden sm:block relative">
@@ -681,14 +777,15 @@
</Button>
</div>
<!-- Click-outside overlay for dropdowns -->
{#if langMenuOpen || userMenuOpen}
<div
class="fixed inset-0 z-40"
onpointerdown={() => { langMenuOpen = false; userMenuOpen = false; }}
aria-hidden="true"
></div>
{/if}
<!-- Click-outside overlay for dropdowns -->
{#if langMenuOpen || userMenuOpen}
<div
class="fixed inset-0 z-40"
onpointerdown={() => { langMenuOpen = false; userMenuOpen = false; }}
aria-hidden="true"
></div>
{/if}
{:else}
<div class="ml-auto">
<a
@@ -732,6 +829,15 @@
>
{m.nav_catalogue()}
</a>
{#if data.user}
<a
href="/submit"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/submit') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
>
Publish
</a>
{/if}
<a
href="https://feedback.libnovel.cc"
target="_blank"
@@ -821,6 +927,17 @@
{/if}
</header>
<!-- Backdrop for mobile hamburger menu — outside <header> so the blur
only affects page content below, not the drawer items themselves -->
{#if menuOpen}
<div
class="fixed top-14 inset-x-0 bottom-0 z-40 sm:hidden"
style="background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);"
onpointerdown={() => { menuOpen = false; }}
aria-hidden="true"
></div>
{/if}
<main class="flex-1 max-w-6xl mx-auto w-full px-4 py-8">
{#key page.url.pathname + page.url.search}
<div in:fade={{ duration: 180, delay: 60 }} out:fade={{ duration: 100 }}>
@@ -895,7 +1012,7 @@
</div>
<!-- ── Persistent mini-player bar ─────────────────────────────────────────── -->
{#if audioStore.active}
{#if audioStore.active && !audioStore.suppressMiniBar}
<div class="fixed bottom-0 left-0 right-0 z-50 bg-(--color-surface) border-t border-(--color-border) shadow-2xl">
<!-- Generation progress bar (sits at very top of the bar) -->
@@ -916,6 +1033,7 @@
max={audioStore.duration || 0}
value={audioStore.currentTime}
oninput={seek}
onchange={seek}
class="w-full h-1 accent-[--color-brand] cursor-pointer block"
style="margin: 0; border-radius: 0; accent-color: var(--color-brand);"
/>
@@ -1061,12 +1179,10 @@
<!-- Listening mode — mounted at root level, independent of audioStore.active,
so closing/pausing audio never tears it down and loses context. -->
{#if listeningModeOpen}
<div transition:fly={{ y: '100%', duration: 320, opacity: 1 }} style="pointer-events: none;">
<ListeningMode
onclose={() => { listeningModeOpen = false; listeningModeChapters = false; }}
openChapters={listeningModeChapters}
/>
</div>
<ListeningMode
onclose={() => { listeningModeOpen = false; listeningModeChapters = false; }}
openChapters={listeningModeChapters}
/>
{/if}
<!-- Universal search modal — shown from anywhere except focus mode / listening mode -->
@@ -1074,12 +1190,24 @@
<SearchModal onclose={() => { searchOpen = false; }} />
{/if}
<!-- Notifications modal — full-screen, shown for all logged-in users -->
{#if notificationsOpen && data.user}
<NotificationsModal
notifications={notifications}
userId={data.user.id}
isAdmin={data.user.role === 'admin'}
onclose={() => { notificationsOpen = false; }}
onMarkRead={markRead}
onMarkAllRead={markAllRead}
onDismiss={dismissNotification}
onClearAll={clearAllNotifications}
/>
{/if}
<svelte:window onkeydown={(e) => {
// Don't intercept when typing in an input/textarea
const tag = (e.target as HTMLElement).tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement).isContentEditable) return;
// Don't open on chapter reader pages
if (/\/books\/[^/]+\/chapters\//.test(page.url.pathname)) return;
if (searchOpen) return;
// `/` key or Cmd/Ctrl+K
if (e.key === '/' || ((e.metaKey || e.ctrlKey) && e.key === 'k')) {
@@ -1087,3 +1215,13 @@
searchOpen = true;
}
}} />
<!-- Seasonal decoration overlay — rendered above page content, below nav -->
{#if siteDecoration}
<SeasonalDecoration mode={siteDecoration} />
{/if}
<style>
/* Logo animation keyframes are defined globally in app.css */
/* This block intentionally left minimal — all logo-anim-* classes live in app.css */
</style>

View File

@@ -6,7 +6,8 @@ import {
getHomeStats,
getSubscriptionFeed,
getTrendingBooks,
getRecommendedBooks
getRecommendedBooks,
getBooksWithAudioCount
} from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
import type { Book, Progress } from '$lib/server/pocketbase';
@@ -87,8 +88,8 @@ export const load: PageServerLoad = async ({ locals }) => {
const inProgressSlugs = new Set(continueReading.map((c) => c.book.slug));
const recentlyUpdated = recentBooks.filter((b) => !inProgressSlugs.has(b.slug)).slice(0, 6);
// Fetch trending, recommendations, and subscription feed in parallel
const [trendingBooks, recommendedBooks, subscriptionFeed] = await Promise.all([
// Fetch trending, recommendations, subscription feed, and audio books in parallel
const [trendingBooks, recommendedBooks, subscriptionFeed, audioBooks] = await Promise.all([
getTrendingBooks(8).catch(() => [] as Book[]),
topGenres.length > 0
? getRecommendedBooks(topGenres, inProgressSlugs, 8).catch(() => [] as Book[])
@@ -98,12 +99,18 @@ export const load: PageServerLoad = async ({ locals }) => {
log.error('home', 'failed to load subscription feed', { err: String(e) });
return [] as Awaited<ReturnType<typeof getSubscriptionFeed>>;
})
: Promise.resolve([])
: Promise.resolve([]),
getBooksWithAudioCount(20).catch(() => [])
]);
// Strip books the user is already reading from trending (redundant)
const trendingFiltered = trendingBooks.filter((b) => !inProgressSlugs.has(b.slug));
// Strip already-reading books from audio shelf; cap at 8
const readyToListen = audioBooks
.filter((e) => !inProgressSlugs.has(e.book.slug))
.slice(0, 8);
return {
continueInProgress,
continueCompleted,
@@ -111,6 +118,7 @@ export const load: PageServerLoad = async ({ locals }) => {
subscriptionFeed,
trendingBooks: trendingFiltered,
recommendedBooks,
readyToListen,
topGenre: topGenres[0] ?? null,
stats: {
...stats,

View File

@@ -8,7 +8,7 @@
let { data }: { data: PageData } = $props();
// ── Section visibility ────────────────────────────────────────────────────────
type SectionId = 'recently-updated' | 'browse-genre' | 'from-following' | 'trending' | 'because-you-read';
type SectionId = 'recently-updated' | 'browse-genre' | 'from-following' | 'trending' | 'because-you-read' | 'ready-to-listen';
const SECTIONS_KEY = 'home_sections_v1';
function loadHidden(): Set<SectionId> {
@@ -40,6 +40,7 @@
'from-following': 'From Following',
'trending': 'Trending Now',
'because-you-read': data.topGenre ? `Because you read ${data.topGenre}` : 'Recommendations',
'ready-to-listen': 'Ready to Listen',
});
const hiddenList = $derived(
@@ -105,7 +106,8 @@
autoAdvanceSeed++;
}
// ── Swipe handling ─────────────────────────────────────────────────────── let swipeStartX = 0;
// ── Swipe handling ───────────────────────────────────────────────────────
let swipeStartX = 0;
function onSwipeStart(e: TouchEvent) {
swipeStartX = e.touches[0].clientX;
}
@@ -134,18 +136,34 @@
<!-- ── Hero carousel ──────────────────────────────────────────────────────────── -->
{#if heroBook}
{@const stackBooks = heroBooks.length > 1
? Array.from({ length: Math.min(heroBooks.length - 1, 3) }, (_, i) =>
heroBooks[(heroIndex + 1 + i) % heroBooks.length])
: []}
<section class="mb-6">
<div class="relative">
<!-- Card — swipe to navigate -->
<!-- Outer flex row: front card + queued book spines (sm+ only) -->
<div class="relative flex items-stretch gap-0">
<!-- Front card — swipe to navigate -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="group relative flex gap-0 rounded-xl overflow-hidden bg-(--color-surface-2) border border-(--color-border) hover:border-(--color-brand)/50 transition-all"
class="group relative flex gap-0 rounded-xl overflow-hidden bg-(--color-surface-2) border border-(--color-border) hover:border-(--color-brand)/50 transition-all z-[2] flex-1 min-w-0"
ontouchstart={onSwipeStart}
ontouchend={onSwipeEnd}
>
<!-- Cover -->
<!-- Atmospheric blurred cover background (z-0, content is z-[1]) -->
{#if heroBook.book.cover}
{#key heroIndex}
<div
class="absolute inset-0 bg-cover bg-center scale-110 animate-fade-in pointer-events-none z-0"
style="background-image: url('{heroBook.book.cover}'); filter: blur(40px) saturate(1.3); opacity: 0.18;"
aria-hidden="true"
></div>
{/key}
{/if}
<!-- Cover — drives card height via aspect-[2/3] -->
<a href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
class="w-32 sm:w-44 shrink-0 aspect-[2/3] overflow-hidden block">
class="w-32 sm:w-44 shrink-0 self-stretch overflow-hidden block relative z-[1]">
{#if heroBook.book.cover}
{#key heroIndex}
<img src={heroBook.book.cover} alt={heroBook.book.title}
@@ -160,35 +178,35 @@
{/if}
</a>
<!-- Info -->
<div class="flex flex-col justify-between p-5 sm:p-7 min-w-0 flex-1">
<div>
<!-- Info — fixed height matching cover, overflow hidden so text never expands the card -->
<div class="relative z-[1] flex flex-col justify-between p-5 sm:p-7 min-w-0 flex-1 overflow-hidden
h-[calc(128px*3/2)] sm:h-[calc(176px*3/2)]">
<div class="min-h-0 overflow-hidden">
<p class="text-xs font-semibold text-(--color-brand) uppercase tracking-widest mb-2">{m.home_continue_reading()}</p>
<h2 class="text-xl sm:text-2xl font-bold text-(--color-text) leading-snug line-clamp-2 mb-1">{heroBook.book.title}</h2>
{#if heroBook.book.author}
<p class="text-sm text-(--color-muted)">{heroBook.book.author}</p>
<p class="text-sm text-(--color-muted) truncate">{heroBook.book.author}</p>
{/if}
{#if heroBook.book.summary}
<p class="hidden sm:block text-sm text-(--color-muted) mt-3 line-clamp-2 max-w-prose">{heroBook.book.summary}</p>
<p class="hidden sm:block text-sm text-(--color-muted) mt-3 line-clamp-3 max-w-prose">{heroBook.book.summary}</p>
{/if}
</div>
<div class="flex items-center gap-3 mt-4 flex-wrap">
<div class="flex items-center gap-3 mt-4 flex-wrap shrink-0">
<a href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
{m.home_chapter_badge({ n: String(heroBook.chapter) })}
</a>
<button
type="button"
onclick={() => playChapter(heroBook!.book.slug, heroBook!.chapter)}
<a
href="/books/{heroBook.book.slug}"
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-(--color-surface-3) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/40 font-semibold text-sm transition-colors"
title="Listen to narration"
title="Book info"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 9a3 3 0 114 2.83V17m0 0a2 2 0 11-4 0m4 0H9m9-8a9 9 0 11-18 0 9 9 0 0118 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 16h-1v-4h-1m1-4h.01M12 2a10 10 0 100 20A10 10 0 0012 2z"/>
</svg>
Listen
</button>
Info
</a>
{#each parseGenres(heroBook.book.genres).slice(0, 2) as genre}
<span class="text-xs px-2 py-1 rounded-full bg-(--color-surface-3) text-(--color-muted)">{genre}</span>
{/each}
@@ -196,36 +214,59 @@
</div>
</div>
<!-- Dot indicators -->
{#if heroBooks.length > 1}
<div class="flex items-center justify-center gap-2 mt-2.5">
{#each heroBooks as _, i}
<button
type="button"
onclick={() => heroDot(i)}
aria-label="Go to book {i + 1}"
>
<span class="block rounded-full transition-all duration-300 {i === heroIndex
? 'w-4 h-1.5 bg-(--color-brand)'
: 'w-1.5 h-1.5 bg-(--color-border) hover:bg-(--color-muted)'}"></span>
</button>
{/each}
</div>
{/if}
<!-- Queued book spines — visible sm+ only, peek to the right of the front card -->
{#each stackBooks as stackBook, i}
{@const opacity = i === 0 ? 'opacity-70' : 'opacity-40'}
{@const width = i === 0 ? 'sm:w-10' : 'sm:w-7'}
<a
href="/books/{stackBook.book.slug}/chapters/{stackBook.chapter}"
class="hidden sm:block shrink-0 {width} rounded-r-xl overflow-hidden border border-l-0 border-(--color-border) {opacity} hover:opacity-90 transition-opacity"
aria-label={stackBook.book.title}
tabindex="-1"
>
{#if stackBook.book.cover}
<img src={stackBook.book.cover} alt="" aria-hidden="true"
class="w-full h-full object-cover object-left" loading="lazy" />
{:else}
<div class="w-full h-full bg-(--color-surface-3)"></div>
{/if}
</a>
{/each}
</div>
<!-- Dot indicators -->
{#if heroBooks.length > 1}
<div class="flex items-center justify-center gap-2 mt-2.5">
{#each heroBooks as _, i}
<button
type="button"
onclick={() => heroDot(i)}
aria-label="Go to book {i + 1}"
>
<span class="block rounded-full transition-all duration-300 {i === heroIndex
? 'w-4 h-1.5 bg-(--color-brand)'
: 'w-1.5 h-1.5 bg-(--color-border) hover:bg-(--color-muted)'}"></span>
</button>
{/each}
</div>
{/if}
</section>
{/if}
<!-- ── Streak widget ───────────────────────────────────────────────────────────── -->
{#if streak > 0}
<div class="mb-6 flex items-center gap-3 flex-wrap text-sm">
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border)">
<span class="font-semibold text-(--color-text)">{streak}</span>
<span class="text-(--color-muted)">day{streak !== 1 ? 's' : ''} reading</span>
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-brand)/10 border border-(--color-brand)/30 text-(--color-brand) font-semibold">
<svg class="w-4 h-4 shrink-0" viewBox="0 0 24 24" fill="currentColor">
<path d="M13.5 0.67s.74 2.65.74 4.8c0 2.06-1.35 3.73-3.41 3.73-2.07 0-3.63-1.67-3.63-3.73l.03-.36C5.21 7.51 4 10.62 4 14c0 4.42 3.58 8 8 8s8-3.58 8-8C20 8.61 17.41 3.8 13.5.67zM11.71 19c-1.78 0-3.22-1.4-3.22-3.14 0-1.62 1.05-2.76 2.81-3.12 1.77-.36 3.6-1.21 4.62-2.58.39 1.29.59 2.65.59 4.04 0 2.65-2.15 4.8-4.8 4.8z"/>
</svg>
{streak} day{streak !== 1 ? 's' : ''}
</span>
{#if data.stats.booksInProgress > 0}
<span class="text-(--color-muted)">
<span class="font-semibold text-(--color-text)">{data.stats.booksInProgress}</span> {data.stats.booksInProgress === 1 ? 'book' : 'books'} in progress
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-(--color-muted)">
<span class="font-semibold text-(--color-text)">{data.stats.booksInProgress}</span>
{data.stats.booksInProgress === 1 ? 'book' : 'books'} in progress
</span>
{/if}
</div>
@@ -235,32 +276,39 @@
{#if shelfBooks.length > 0}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">{m.home_continue_reading()}</h2>
<h2 class="text-lg font-bold text-(--color-text)">{m.home_continue_reading()}</h2>
<a href="/books" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
</div>
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
{#each shelfBooks as { book, chapter }}
<div class="group relative flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-32 sm:w-36">
<div class="group relative flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-36 sm:w-40">
<a href="/books/{book.slug}/chapters/{chapter}" class="block">
<div class="aspect-[2/3] overflow-hidden relative">
{#if book.cover}
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
</div>
{/if}
<!-- Chapter badge -->
<span class="absolute bottom-1.5 right-1.5 text-xs bg-(--color-brand) text-(--color-surface) font-bold px-1.5 py-0.5 rounded">
{m.home_chapter_badge({ n: String(chapter) })}
</span>
<!-- Reading progress bar -->
{#if book.total_chapters > 0}
{@const pct = Math.min(100, Math.round((chapter / book.total_chapters) * 100))}
<div class="absolute bottom-0 left-0 right-0 h-1 bg-black/40">
<div class="h-full bg-(--color-brand) transition-all" style="width: {pct}%"></div>
</div>
{/if}
</div>
</a>
<!-- Listen button (hover overlay) -->
<button
type="button"
onclick={() => playChapter(book.slug, chapter)}
class="absolute bottom-8 left-1.5 w-7 h-7 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
class="absolute bottom-9 left-1.5 w-7 h-7 rounded-full bg-black/60 text-white flex items-center justify-center opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity"
title="Listen"
aria-label="Listen to chapter {chapter}"
>
@@ -268,6 +316,9 @@
</button>
<a href="/books/{book.slug}/chapters/{chapter}" class="p-2 block">
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
{#if book.author}
<p class="text-xs text-(--color-muted) truncate mt-0.5">{book.author}</p>
{/if}
</a>
</div>
{/each}
@@ -279,7 +330,7 @@
{#if data.continueCompleted.length > 0}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">Completed</h2>
<h2 class="text-lg font-bold text-(--color-text)">Completed</h2>
</div>
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
{#each data.continueCompleted as { book, chapter }}
@@ -290,7 +341,7 @@
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
</div>
{/if}
<span class="absolute top-1.5 right-1.5 text-xs bg-green-600/90 text-white font-bold px-1.5 py-0.5 rounded">Done</span>
@@ -307,11 +358,74 @@
</section>
{/if}
<!-- ── Ready to Listen shelf ──────────────────────────────────────────────────── -->
{#if data.readyToListen.length > 0 && !hidden.has('ready-to-listen')}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-lg font-bold text-(--color-text)">Ready to Listen</h2>
<div class="flex items-center gap-3">
<a href="/listen" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">View all</a>
<button type="button" onclick={() => hide('ready-to-listen')} title="Hide section"
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
</svg>
</button>
</div>
</div>
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
{#each data.readyToListen as { book, audioChapters }}
{@const genres = parseGenres(book.genres)}
<div class="group relative flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-36 sm:w-40">
<a href="/books/{book.slug}" class="block">
<div class="aspect-[2/3] overflow-hidden relative">
{#if book.cover}
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
</div>
{/if}
<!-- Headphones badge -->
<span class="absolute bottom-1.5 left-1.5 inline-flex items-center gap-1 text-xs bg-(--color-brand)/90 text-(--color-surface) font-bold px-1.5 py-0.5 rounded">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M12 3a9 9 0 00-9 9v5a3 3 0 003 3h1a1 1 0 001-1v-4a1 1 0 00-1-1H5v-2a7 7 0 0114 0v2h-2a1 1 0 00-1 1v4a1 1 0 001 1h1a3 3 0 003-3v-5a9 9 0 00-9-9z"/></svg>
{audioChapters} ch
</span>
</div>
</a>
<div class="p-2 flex flex-col gap-1 flex-1">
<a href="/books/{book.slug}" class="block">
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
</a>
{#if genres.length > 0}
<div class="flex flex-wrap gap-1 mt-auto pt-0.5">
{#each genres.slice(0, 2) as genre}
<span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>
{/each}
</div>
{/if}
</div>
<!-- Listen Ch.1 button -->
<button
type="button"
onclick={() => playChapter(book.slug, 1)}
class="mx-2 mb-2 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md bg-(--color-brand)/15 hover:bg-(--color-brand)/30 text-(--color-brand) text-xs font-semibold transition-colors"
aria-label="Listen from chapter 1"
>
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
Listen
</button>
</div>
{/each}
</div>
</section>
{/if}
<!-- ── Genre discovery strip ─────────────────────────────────────────────────── -->
{#if !hidden.has('browse-genre')}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">Browse by genre</h2>
<h2 class="text-lg font-bold text-(--color-text)">Browse by genre</h2>
<div class="flex items-center gap-3">
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
<button type="button" onclick={() => hide('browse-genre')} title="Hide section"
@@ -324,8 +438,11 @@
</div>
<div class="flex gap-2 overflow-x-auto pb-1 scrollbar-none -mx-4 px-4">
{#each GENRES as genre}
{@const isTop = data.topGenre && genre.toLowerCase() === data.topGenre.toLowerCase()}
<a href="/catalogue?genre={encodeURIComponent(genre)}"
class="shrink-0 px-3.5 py-1.5 rounded-full border border-(--color-border) bg-(--color-surface-2) text-sm text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors whitespace-nowrap">
class="shrink-0 px-3.5 py-1.5 rounded-full border text-sm transition-colors whitespace-nowrap {isTop
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand) font-semibold'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text) hover:bg-(--color-surface-3)'}">
{genre}
</a>
{/each}
@@ -337,7 +454,7 @@
{#if data.trendingBooks.length > 0 && !hidden.has('trending')}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">Trending Now</h2>
<h2 class="text-lg font-bold text-(--color-text)">Trending Now</h2>
<div class="flex items-center gap-3">
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
<button type="button" onclick={() => hide('trending')} title="Hide section"
@@ -358,7 +475,7 @@
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
</div>
{/if}
<span class="absolute top-1.5 left-1.5 text-xs bg-(--color-brand)/80 text-(--color-surface) font-bold px-1.5 py-0.5 rounded">#{book.ranking}</span>
@@ -386,15 +503,18 @@
{#if data.recommendedBooks.length > 0 && data.topGenre && !hidden.has('because-you-read')}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">
Because you read <span class="text-(--color-brand)">{data.topGenre}</span>
<h2 class="text-lg font-bold text-(--color-text)">
Because you read <span class="text-(--color-brand)">{data.topGenre ? data.topGenre.charAt(0).toUpperCase() + data.topGenre.slice(1) : ''}</span>
</h2>
<button type="button" onclick={() => hide('because-you-read')} title="Hide section"
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
</svg>
</button>
<div class="flex items-center gap-3">
<a href="/catalogue?genre={encodeURIComponent(data.topGenre ?? '')}" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">View all</a>
<button type="button" onclick={() => hide('because-you-read')} title="Hide section"
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
</svg>
</button>
</div>
</div>
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
{#each data.recommendedBooks as book}
@@ -406,7 +526,7 @@
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
</div>
{/if}
</div>
@@ -433,7 +553,7 @@
{#if dedupedRecent.length > 0 && !hidden.has('recently-updated')}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">{m.home_recently_updated()}</h2>
<h2 class="text-lg font-bold text-(--color-text)">{m.home_recently_updated()}</h2>
<div class="flex items-center gap-3">
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
<button type="button" onclick={() => hide('recently-updated')} title="Hide section"
@@ -454,7 +574,7 @@
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
</div>
{/if}
{#if count > 1}
@@ -486,7 +606,7 @@
{#if data.subscriptionFeed.length > 0 && !hidden.has('from-following')}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">{m.home_from_following()}</h2>
<h2 class="text-lg font-bold text-(--color-text)">{m.home_from_following()}</h2>
<button type="button" onclick={() => hide('from-following')} title="Hide section"
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -503,7 +623,7 @@
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
</div>
{/if}
</div>

View File

@@ -1,8 +1,16 @@
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
import { listAIJobs } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
export const load: LayoutServerLoad = async ({ locals }) => {
if (locals.user?.role !== 'admin') {
redirect(302, '/');
}
const jobs = await listAIJobs().catch((e) => {
log.warn('admin/layout', 'failed to load ai jobs for sidebar badge', { err: String(e) });
return [];
});
const runningAiJobs = jobs.filter((j) => j.status === 'running' || j.status === 'pending').length;
return { runningAiJobs };
};

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import * as m from '$lib/paraglide/messages.js';
import type { LayoutData } from './$types';
const internalLinks = [
{
@@ -18,6 +19,11 @@
label: () => m.admin_nav_translation(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />`
},
{
href: '/admin/import',
label: () => m.admin_nav_import(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />`
},
{
href: '/admin/image-gen',
label: () => m.admin_nav_image_gen(),
@@ -33,6 +39,11 @@
label: () => m.admin_nav_ai_jobs(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />`
},
{
href: '/admin/notifications',
label: () => m.admin_nav_notifications(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />`
},
{
href: '/admin/catalogue-tools',
label: () => m.admin_nav_catalogue_tools(),
@@ -42,6 +53,11 @@
href: '/admin/changelog',
label: () => m.admin_nav_changelog(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4h10a2 2 0 012 2v12a2 2 0 01-2 2H7a2 2 0 01-2-2V6a2 2 0 012-2z" />`
},
{
href: '/admin/site-theme',
label: () => 'Site Theme',
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />`
}
];
@@ -90,8 +106,9 @@
interface Props {
children?: import('svelte').Snippet;
data: LayoutData;
}
let { children }: Props = $props();
let { children, data }: Props = $props();
let sidebarOpen = $state(false);
</script>
@@ -121,6 +138,7 @@
<nav class="flex flex-col gap-0.5">
{#each internalLinks as link}
{@const active = page.url.pathname.startsWith(link.href)}
{@const isAiJobs = link.href === '/admin/ai-jobs'}
<a
href={link.href}
onclick={() => (sidebarOpen = false)}
@@ -132,7 +150,12 @@
<svg class="w-3.5 h-3.5 shrink-0 {active ? 'text-(--color-brand)' : 'opacity-50'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{@html link.icon}
</svg>
{link.label()}
<span class="flex-1">{link.label()}</span>
{#if isAiJobs && data.runningAiJobs > 0}
<span class="text-[10px] font-bold tabular-nums px-1.5 py-0.5 rounded-full bg-(--color-brand) text-black leading-none">
{data.runningAiJobs}
</span>
{/if}
</a>
{/each}
</nav>

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import { untrack } from 'svelte';
import { invalidateAll } from '$app/navigation';
import type { PageData } from './$types';
import type { AIJob } from '$lib/server/pocketbase';
@@ -8,9 +7,9 @@
let { data }: { data: PageData } = $props();
let jobs = $state<AIJob[]>(untrack(() => data.jobs));
let jobs = $state<AIJob[]>([]);
// Keep in sync on server reloads
// data.jobs is a plain AIJob[] (resolved on server); re-sync on invalidateAll
$effect(() => {
jobs = data.jobs;
});
@@ -58,6 +57,7 @@
// ── Cancel ────────────────────────────────────────────────────────────────────
let cancellingIds = $state(new Set<string>());
let cancelErrors: Record<string, string> = $state({});
let cancellingAll = $state(false);
async function cancelJob(id: string) {
if (cancellingIds.has(id)) return;
@@ -78,6 +78,14 @@
}
}
async function cancelAllRunning() {
if (cancellingAll) return;
cancellingAll = true;
const inFlight = jobs.filter((j) => j.status === 'running' || j.status === 'pending');
await Promise.all(inFlight.map((j) => cancelJob(j.id)));
cancellingAll = false;
}
// ── Review & Apply (chapter-names jobs) ──────────────────────────────────────
interface ProposedTitle {
@@ -156,22 +164,24 @@
};
review = r;
try {
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
try {
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
let payload: { pattern?: string; slug?: string; results?: ProposedTitle[] } = {};
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
let payload: { pattern?: string; slug?: string; results?: ProposedTitle[] } = {};
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
r.pattern = payload.pattern ?? '';
r.titles = (payload.results ?? []).map((t: ProposedTitle) => ({ ...t }));
r.loading = false;
} catch (e) {
r.loading = false;
r.error = String(e);
}
} else if (job.kind === 'image-gen') {
review = {
...r,
pattern: payload.pattern ?? '',
titles: (payload.results ?? []).map((t: ProposedTitle) => ({ ...t })),
loading: false
};
} catch (e) {
review = { ...r, loading: false, error: String(e) };
}
} else if (job.kind === 'image-gen') {
const r: ImageGenReview = {
kind: 'image-gen',
jobId: job.id,
@@ -190,38 +200,40 @@
};
review = r;
try {
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
try {
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
let payload: {
prompt?: string;
type?: string;
chapter?: number;
content_type?: string;
image_b64?: string;
bytes?: number;
} = {};
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
let payload: {
prompt?: string;
type?: string;
chapter?: number;
content_type?: string;
image_b64?: string;
bytes?: number;
} = {};
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
if (!payload.image_b64) {
r.error = 'No image in job payload.';
r.loading = false;
return;
}
r.imageType = payload.type ?? 'cover';
r.chapter = payload.chapter ?? 0;
r.prompt = payload.prompt ?? '';
r.contentType = payload.content_type ?? 'image/png';
r.bytes = payload.bytes ?? 0;
r.imageSrc = `data:${r.contentType};base64,${payload.image_b64}`;
r.loading = false;
} catch (e) {
r.loading = false;
r.error = String(e);
if (!payload.image_b64) {
review = { ...r, error: 'No image in job payload.', loading: false };
return;
}
} else if (job.kind === 'description') {
const contentType = payload.content_type ?? 'image/png';
review = {
...r,
imageType: payload.type ?? 'cover',
chapter: payload.chapter ?? 0,
prompt: payload.prompt ?? '',
contentType,
bytes: payload.bytes ?? 0,
imageSrc: `data:${contentType};base64,${payload.image_b64}`,
loading: false
};
} catch (e) {
review = { ...r, loading: false, error: String(e) };
}
} else if (job.kind === 'description') {
const r: DescriptionReview = {
kind: 'description',
jobId: job.id,
@@ -237,28 +249,30 @@
};
review = r;
try {
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
try {
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
let payload: {
instructions?: string;
old_description?: string;
new_description?: string;
} = {};
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
let payload: {
instructions?: string;
old_description?: string;
new_description?: string;
} = {};
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
r.instructions = payload.instructions ?? '';
r.oldDescription = payload.old_description ?? '';
r.newDescription = payload.new_description ?? '';
r.loading = false;
} catch (e) {
r.loading = false;
r.error = String(e);
}
review = {
...r,
instructions: payload.instructions ?? '',
oldDescription: payload.old_description ?? '',
newDescription: payload.new_description ?? '',
loading: false
};
} catch (e) {
review = { ...r, loading: false, error: String(e) };
}
}
}
function closeReview() {
review = null;
@@ -406,7 +420,9 @@
function fmtDate(s: string | undefined) {
if (!s) return '—';
return new Date(s).toLocaleString(undefined, {
const d = new Date(s);
if (d.getFullYear() < 2000) return '—';
return d.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
@@ -477,6 +493,27 @@
{/each}
</div>
<!-- Bulk actions -->
{#if stats.running + stats.pending > 0}
<div class="flex justify-end">
<button
onclick={cancelAllRunning}
disabled={cancellingAll}
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium bg-(--color-danger)/10 text-(--color-danger) hover:bg-(--color-danger)/20 disabled:opacity-50 transition-colors"
>
{#if cancellingAll}
<svg class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
Cancelling…
{:else}
Cancel all in-flight ({stats.running + stats.pending})
{/if}
</button>
</div>
{/if}
<!-- Filters -->
<div class="flex flex-wrap gap-3 items-center">
<input

View File

@@ -38,6 +38,46 @@
$effect(() => { void imgModel; void numSteps; void width; void height; saveConfig(); });
// ── Visibility management ─────────────────────────────────────────────────────
type VisBook = { slug: string; vis: string; busy: boolean };
let visBooks = $state<VisBook[]>([]);
let visSlugInput = $state('');
let visError = $state('');
async function addVisBook(e: Event) {
e.preventDefault();
const slug = visSlugInput.trim();
if (!slug || visBooks.some((b) => b.slug === slug)) return;
visError = '';
try {
const res = await fetch(`/api/books/${slug}`);
if (!res.ok) { visError = `Book "${slug}" not found.`; return; }
const book = await res.json() as { slug: string; visibility?: string };
visBooks = [...visBooks, { slug: book.slug, vis: book.visibility ?? 'admin_only', busy: false }];
visSlugInput = '';
} catch {
visError = 'Failed to load book.';
}
}
function removeVisBook(slug: string) {
visBooks = visBooks.filter((b) => b.slug !== slug);
}
async function toggleVisibility(slug: string, currentVis: string) {
visBooks = visBooks.map((b) => b.slug === slug ? { ...b, busy: true } : b);
const action = currentVis === 'public' ? 'unpublish' : 'publish';
try {
const res = await fetch(`/api/admin/books/${slug}/${action}`, { method: 'PATCH' });
if (!res.ok) throw new Error(await res.text());
const newVis = action === 'publish' ? 'public' : 'admin_only';
visBooks = visBooks.map((b) => b.slug === slug ? { ...b, vis: newVis, busy: false } : b);
} catch (e) {
visError = String(e);
visBooks = visBooks.map((b) => b.slug === slug ? { ...b, busy: false } : b);
}
}
// ── Batch covers ──────────────────────────────────────────────────────────────
let fromItem = $state(0);
let toItem = $state(0);
@@ -276,4 +316,68 @@
</div>
{/if}
</div>
<!-- ── Visibility management ───────────────────────────────────────────────── -->
<div class="rounded-xl border border-(--color-border) bg-(--color-surface-2) p-5 space-y-4 mt-6">
<div>
<h2 class="text-base font-semibold">Visibility management</h2>
<p class="text-sm text-(--color-muted) mt-0.5">
Scraped books default to <code class="text-xs bg-(--color-surface-3) px-1 py-0.5 rounded">admin_only</code>.
Publish individual books to make them visible to all users, or unpublish to restrict to admins.
</p>
</div>
{#snippet visMsg(msg: string, ok: boolean)}
<p class="text-sm {ok ? 'text-green-400' : 'text-(--color-danger)'}">{msg}</p>
{/snippet}
<div class="flex flex-col gap-3">
{#each visBooks as vb (vb.slug)}
<div class="flex items-center gap-3 rounded-lg border border-(--color-border) bg-(--color-surface) px-3 py-2">
<span class="font-mono text-sm flex-1 truncate">{vb.slug}</span>
<span class="text-xs px-2 py-0.5 rounded-full {vb.vis === 'public'
? 'bg-green-500/15 text-green-400 border border-green-500/30'
: 'bg-(--color-surface-3) text-(--color-muted) border border-(--color-border)'}">
{vb.vis === 'public' ? 'public' : 'admin only'}
</span>
<button
type="button"
onclick={() => toggleVisibility(vb.slug, vb.vis)}
disabled={vb.busy}
class="text-xs px-2.5 py-1 rounded border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/40 disabled:opacity-50 transition-colors"
>
{vb.busy ? '…' : vb.vis === 'public' ? 'Unpublish' : 'Publish'}
</button>
<button
type="button"
onclick={() => removeVisBook(vb.slug)}
class="text-(--color-muted) hover:text-(--color-danger) transition-colors"
aria-label="Remove"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{/each}
</div>
<form onsubmit={addVisBook} class="flex gap-2">
<input
type="text"
bind:value={visSlugInput}
placeholder="book-slug"
class="flex-1 rounded-lg border border-(--color-border) bg-(--color-surface) px-3 py-2 text-sm font-mono text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
/>
<button
type="submit"
class="px-4 py-2 rounded-lg border border-(--color-border) text-sm text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/40 transition-colors"
>
Load book
</button>
</form>
{#if visError}
{@render visMsg(visError, false)}
{/if}
</div>
</div>

View File

@@ -18,23 +18,27 @@ const CACHE_KEY = 'admin:changelog:releases';
const CACHE_TTL = 5 * 60; // 5 minutes
export const load: PageServerLoad = async ({ fetch }) => {
// Return cached data synchronously (no streaming needed — already fast).
const cached = await cache.get<Release[]>(CACHE_KEY);
if (cached) {
return { releases: cached };
return { releases: cached, error: undefined as string | undefined };
}
try {
const res = await fetch(GITEA_RELEASES_URL, {
headers: { Accept: 'application/json' }
});
if (!res.ok) {
return { releases: [], error: `Gitea API returned ${res.status}` };
// Cache miss: stream the external Gitea request so navigation isn't blocked.
const releasesPromise = (async () => {
try {
const res = await fetch(GITEA_RELEASES_URL, {
headers: { Accept: 'application/json' }
});
if (!res.ok) return [] as Release[];
const releases: Release[] = await res.json();
const filtered = releases.filter((r) => !r.draft);
await cache.set(CACHE_KEY, filtered, CACHE_TTL);
return filtered;
} catch {
return [] as Release[];
}
const releases: Release[] = await res.json();
const filtered = releases.filter((r) => !r.draft);
await cache.set(CACHE_KEY, filtered, CACHE_TTL);
return { releases: filtered };
} catch (e) {
return { releases: [], error: String(e) };
}
})();
return { releases: releasesPromise, error: undefined as string | undefined };
};

View File

@@ -32,29 +32,33 @@
</a>
</div>
{#if data.error}
<p class="text-sm text-(--color-danger)">{m.admin_changelog_load_error({ error: data.error })}</p>
{:else if data.releases.length === 0}
<p class="text-sm text-(--color-muted) py-8 text-center">{m.admin_changelog_no_releases()}</p>
{:else}
<div class="space-y-0 divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden">
{#each data.releases as release}
<div class="px-5 py-4 bg-(--color-surface) space-y-2">
<div class="flex items-baseline gap-3 flex-wrap">
<span class="font-mono text-sm font-semibold text-(--color-brand)">{release.tag_name}</span>
{#if release.name && release.name !== release.tag_name}
<span class="text-sm text-(--color-text)">{release.name}</span>
{#await data.releases}
<p class="text-sm text-(--color-muted) py-8 text-center">Loading releases…</p>
{:then releases}
{#if releases.length === 0}
<p class="text-sm text-(--color-muted) py-8 text-center">{m.admin_changelog_no_releases()}</p>
{:else}
<div class="space-y-0 divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden">
{#each releases as release}
<div class="px-5 py-4 bg-(--color-surface) space-y-2">
<div class="flex items-baseline gap-3 flex-wrap">
<span class="font-mono text-sm font-semibold text-(--color-brand)">{release.tag_name}</span>
{#if release.name && release.name !== release.tag_name}
<span class="text-sm text-(--color-text)">{release.name}</span>
{/if}
{#if release.prerelease}
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-muted)">pre-release</span>
{/if}
<span class="text-xs text-(--color-muted) ml-auto">{fmtDate(release.published_at)}</span>
</div>
{#if release.body.trim()}
<p class="text-sm text-(--color-muted) leading-relaxed whitespace-pre-wrap">{release.body.trim()}</p>
{/if}
{#if release.prerelease}
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-muted)">pre-release</span>
{/if}
<span class="text-xs text-(--color-muted) ml-auto">{fmtDate(release.published_at)}</span>
</div>
{#if release.body.trim()}
<p class="text-sm text-(--color-muted) leading-relaxed whitespace-pre-wrap">{release.body.trim()}</p>
{/if}
</div>
{/each}
</div>
{/if}
{/each}
</div>
{/if}
{:catch}
<p class="text-sm text-(--color-danger)">{m.admin_changelog_load_error({ error: 'Failed to load releases' })}</p>
{/await}
</div>

Some files were not shown because too many files have changed in this diff Show More