Commit Graph
1582 Commits
Author SHA1 Message Date
john-okeefe 1461273162 fix(sync): wire dead token cleanup queries into daily maintenance runner
CleanupExpiredRefreshTokens and CleanupExpiredOpdsTokens were generated
by sqlc but never invoked anywhere in the codebase, so expired/revoked
tokens accumulated in the database indefinitely. The refresh-token query
was parameterized in the settings-registry work specifically so its
retention window could follow the configurable session duration, but the
periodic caller was never wired up.

annotations.go:
- Rename StartTombstonePurger to StartDailyMaintenance, which now runs
  all periodic cleanup tasks from a single 24h-tick goroutine.
- Add runDailyMaintenance helper: tombstones, then OPDS tokens, then
  refresh tokens, each logging independently so one failure never skips
  the others.
- Refresh-token retention is read from the registry (SessionDuration)
  on every tick so live admin edits are honored; guarded on the registry
  being wired so unwired test paths simply skip cleanup.
- All three queries only delete rows that are already expired or
  revoked, so active sessions are never logged out.

main.go:
- Update the call site: tombstonePurgerCancel becomes maintenanceCancel
  and calls StartDailyMaintenance.

Net footprint: still one goroutine and one ticker; the cleanup adds one
DELETE per table per day.
2026-08-10 10:43:05 -04:00
john-okeefe 598d70f735 feat(admin): editable tunable settings UI with grouped sub-sections
Replace the read-only "System Information" card (which listed hardcoded
values) with editable HTMX forms, organized so the live vs restart
distinction and related settings are visually clear.

admin_settings.templ:
- AdminSettings signature now takes liveGroups and restartGroups
  ([]SettingGroup) instead of a flat entry list.
- Remove the static System Information list. Render two cards: "Live"
  (green, applies immediately) and "Restart Required" (warning header,
  saved but only takes effect after restart).
- Within each card, TunableSettingsSection clusters entries into
  labeled sub-sections by Group (e.g. "Password Quality", "Device Rate
  Limits", "Login Lockout", "Worker Pool") with uppercase tracked
  sub-headers.
- TunableSettingRow renders an inline HTMX form per setting: a Yes/No
  select for bools, a number input with min/max for ints, text
  otherwise, posting to /admin/settings/tunable. Rows show "modified
  from default" when the value differs from the compiled default.

types.go:
- Add SettingEntry (template-local mirror of database.SettingEntry,
  keeps templates from importing database) and SettingGroup.

utils.go:
- Add GroupTunableSettings: splits a flat, group-sorted entry list into
  live and restart []SettingGroup buckets preserving source order.
  utils_test.go covers the multi-group + empty cases.

frontend.go:
- The /admin/settings page handler now loads entries from the registry,
  drops the three keys that have dedicated UI cards (default_timezone
  dropdown, scan_poll_interval_seconds, auto_scan_enabled) so they are
  not listed twice, groups the rest, and passes liveGroups/restartGroups
  into the template.
2026-08-10 08:03:02 -04:00
john-okeefe 537330e7e0 feat(app): wire settings registry into startup and admin routes
Construct the SettingsRegistry at boot, load it, and thread it through
every consumer so the configurable values take effect and stay cached.

cmd/server/main.go:
- Build the registry from the Queries handle and Load() it right after
  schema init; a load failure logs and continues (getters fall back to
  compiled defaults, so startup is never blocked).
- Wire the registry into the package-level password validator
  (SetDefaultPasswordSettings) and call SetSettings on every handler/
  service that reads tunables: AuthHandler, DeviceAuthMiddleware,
  OPDSHandler, SidecarHandler, SystemSettingsHandler,
  AnnotationService, ConversionService.
- Source the restart-time values from the registry: login lockout
  (max attempts + duration) feeds NewLoginAttemptTracker, and the new
  NewSyncQueueProcessorWithConfig / NewWorkerWithConfig take the sync
  queue and worker pool configs.

router.go:
- Config gains a Settings *database.SettingsRegistry field.
- The global auth rate limiter now reads RequestsPerMinute from
  registry.AuthRateLimit() (env stays as the enabled/disabled switch
  and as the fallback if the registry is unset).

admin_library.go:
- The HTMX scan-settings save endpoint reloads the registry after
  writing so the change is visible without a page reload.
- Add PUT /admin/settings/tunable: a small HTMX endpoint that calls
  SystemSettingsHandler.ApplySetting and returns a colored status
  snippet ("Saved" or "Saved — restart required") for the admin UI's
  per-row forms.
2026-08-10 08:02:41 -04:00
john-okeefe 885f6d8187 feat(api): unified tunable settings endpoints with typed validation
Add a single pair of admin-only endpoints that supersede the scattered
scan-settings JSON routes as the canonical way to read and write
tunable system settings. Existing legacy routes are kept working for
backward compatibility and now refresh the registry cache on write.

system_settings.go:
- GET /api/system/settings returns every known setting with full
  metadata (value, type, min, max, requires_restart, category, group,
  description, is_default) via SettingsRegistry.All().
- PUT /api/system/settings accepts {key, value}; ApplySetting() looks
  up the compiled Default for the key, runs type-aware validation
  (int range, bool parse, non-empty string, timezone via
  time.LoadLocation), upserts via UpsertSystemSetting, reloads the
  registry, and reports whether a restart is needed for the change to
  take full effect. Shared by the JSON endpoint and the HTMX endpoint.
- Legacy UpdateScanSettings / GetScanSettings / UpdateTimezoneSettings
  now reload the registry after writing and prefer the registry when
  reading, so the cache stays consistent regardless of entry point.

sidecar.go:
- SidecarHandler gains an optional registry; the timezone branch of
  UpdateSystemConfiguration (PUT /api/system/config) calls
  settings.Reload() after the write so the new value is visible
  immediately. base_url handling is unchanged.

system.go:
- Register GET/PUT /api/system/settings under the existing admin
  /api/system group.
2026-08-10 08:02:22 -04:00
john-okeefe 936a48405b refactor(background): parameterize sync queue and worker pool constructors
Split each constructor into a default-args wrapper and a config-accepting
variant so the sync queue interval/batch size and the worker pool size/
queue cap can be sourced from the settings registry at startup. These
values are constructed once at boot, so they are tagged requires_restart
in the admin UI.

queue.go:
- NewSyncQueueProcessorWithConfig(db, interval, batchSize) takes the
  flush interval and batch size as parameters; NewSyncQueueProcessor
  becomes a thin wrapper with the historical 5s / 50 defaults.

worker.go:
- NewWorkerWithConfig(numWorkers, queueCap, connManager) takes the
  queue capacity as a parameter; NewWorker becomes a thin wrapper with
  the historical cap of 100.

No behavior change for existing callers; main.go will switch to the
config-accepting variants in a follow-up wiring commit.
2026-08-10 08:02:02 -04:00
john-okeefe 757398bf15 feat(sync): make annotation tombstone TTL configurable
The 30-day retention window for soft-deleted annotations was a package
const; move it behind the registry so it can be tuned live.

annotations.go:
- AnnotationService gains an optional *database.SettingsRegistry and a
  tombstoneTTL() helper. The skip-resurrect checks and the purge cutoff
  now call it instead of reading the TombstoneTTL const directly.
- Add ActiveTombstoneTTL() so callers outside the sync package can
  compute cutoffs consistently with the service.
- The package-level TombstoneTTL const is retained as the fallback for
  tests / unwired code paths.

kobo.go, koreader.go:
- The per-book tombstone sweep cutoff now uses
  h.annotationSvc.ActiveTombstoneTTL() instead of the wsync.TombstoneTTL
  const, so both the service and the handlers honor the configured TTL.
2026-08-10 08:01:45 -04:00
john-okeefe d12911d3c8 feat(api): make device rate limits, OPDS page size, and conversion cache configurable
Move three more hardcoded values behind the settings registry. All
apply immediately on the next request (no restart needed).

device_auth.go:
- DeviceAuthMiddleware reads per-route device rate limits (sync /
  progress / metadata per minute) from the registry on each
  authenticated request via a rateLimitConfig() helper, falling back to
  the Default* constants when no registry is wired.
- The X-RateLimit-Limit response header previously hardcoded "60" for
  every request type; it now reflects the actual configured limit for
  the request type via rateLimitForRequestType().

opds.go:
- Default (50) and maximum (200) OPDS page sizes come from the
  registry's OpdsDefaultPageSize()/OpdsMaxPageSize() instead of inline
  literals, so catalog pagination can be tuned without a redeploy.

conversion_service.go:
- The 24h kepub cache lifetime is read from the registry via a
  cacheTTL() helper (was a bare 24 * time.Hour literal in the
  constructor). The field default is retained for tests that construct
  the service directly.
- conversion_service_test.go updated to assert both the field default
  and the cacheTTL() accessor return 24h.
2026-08-10 08:01:28 -04:00
john-okeefe 457a38306d feat(auth): make session duration and password rules configurable
Replace the hardcoded 7-day session lifetime and fixed password
complexity rules with registry-backed accessors so they can be tuned
from the admin UI without a code change.

auth.go:
- Drop the SessionDuration const; keep DefaultSessionDuration (7 days)
  as the fallback used when no registry is wired (e.g. in tests).
- AuthHandler gains an optional *database.SettingsRegistry and a
  sessionDuration() helper that reads the registry, falling back to
  DefaultSessionDuration.
- Cookie MaxAge, JWT exp claim, and ExpiresIn responses now derive from
  sessionDuration() instead of the package-level SessionDurationSec, so
  a settings change takes effect on the next login.

refresh_token.go:
- Refresh-token lifetime follows sessionDuration() via a new
  refreshTokenTTL() helper (was a separate refreshTokenExpiration const
  that silently had to be kept in sync with the session duration).

password_validator.go:
- PasswordValidator now reads min length and the upper/lower/number/
  special toggles from the registry at validation time, so rule
  changes apply immediately. The special-character regex is compiled
  once and reused (sync.Once).
- GetPasswordRequirements() and ValidatePassword() reflect the active
  configured rules instead of a static list.
- Add SetDefaultPasswordSettings() so the package-level default
  validator (used by echo's struct-tag validator) follows live config.

All paths degrade gracefully to the historical defaults when no
registry is wired.
2026-08-10 08:01:11 -04:00
john-okeefe bc47450653 feat(db): typed tunable system settings + SettingsRegistry
Add a typed, cached registry over the system_settings table so that
values which used to be hardcoded Go literals can be changed at runtime.

Schema (database/schema/schema.sql):
- Extend system_settings with setting_type, min_value, max_value,
  requires_restart, and category columns (all ADD COLUMN IF NOT EXISTS,
  nullable for backward compat with the original three rows).
- Seed rows for every tunable: session duration, password rules,
  login lockout, auth/device rate limits, OPDS page size, tombstone TTL,
  conversion cache TTL, sync queue interval/batch, and worker pool
  size/cap. Seed values equal the previous hardcoded literals, so
  behavior is unchanged on upgrade. ON CONFLICT DO NOTHING preserves
  any admin-modified values.

Queries (queries.sql):
- Add UpsertSystemSetting (RETURNING *) so new keys without a seed row
  can still be written through the API.
- Add GetSystemSettingFull + GetAllSystemSettingsFull returning the
  full typed row.
- Refactor CleanupExpiredRefreshTokens to take the retention window as
  a parameter (make_interval(secs => $1)) instead of the INTERVAL '7
  days' literal, so it can follow a configurable session duration.

Registry (internal/database/settings_registry.go):
- SettingsRegistry holds an in-memory cache of all known settings,
  populated by Load at startup and refreshed by Reload on writes.
- Typed domain getters (SessionDuration, PasswordRules, DeviceRateLimits,
  TombstoneTTL, OpdsPageSize, ConversionCacheTTL, SyncQueueConfig,
  WorkerPoolConfig, LoginLockout, AuthRateLimit, ...) with compiled-in
  fallback defaults and min/max clamping, so a corrupt or missing row
  can never break the app.
- SettingDefaults is the single source of truth for keys, types, bounds,
  and human descriptions; All() exposes metadata + current values for
  the admin UI/API.

The registry lives in the database package (rather than its own
internal/settings package) because a quirk in this custom go1.26.5
toolchain prevented the large handlers package from importing any
newly-created package; every consumer already imports database.

Tests: settings_registry_test.go covers default validity per type,
int clamping at both bounds, garbage-value fallback, and unknown-key
lookup.
2026-08-10 08:00:52 -04:00
john-okeefe 64ad10b3f2 feat(admin): add scan settings + system information to settings page
- Scanning section: auto-scan toggle (on/off) and poll interval input
  with HTMX form that updates system_settings table
- System Information section: surfaces all hardcoded constants
  (session duration, password policy, rate limits, worker pool,
  sync queue, tombstone TTL, OPDS page size, CORS, etc.)
- New ScanSettingsData type, ScanSettingsSection partial template
- New HTMX endpoint: PUT /admin/settings/scan
- registerAdminSettingsRoutes for scan settings HTMX CRUD
- Fix bookhoord→bookhoard import typo in admin_library.go
2026-08-07 23:37:14 -04:00
john-okeefe a42232e67a fix(admin): expandable sidebar section + library manage toggle
- Admin section in sidebar now uses same expandable panel pattern as
  Appearance and User sections (toggle button with chevron, auto-opens
  when on /admin pages)
- Library Manage button toggles open/close instead of only opening
  (uses htmx.ajax for open, clears panel for close)
2026-08-07 23:29:01 -04:00
john-okeefe 006cc0c2c9 chore(frontend): slim admin.ts, remove dead library.ts
- Remove dead functions from admin.ts: loadSystemStats, renderSystemStats,
  triggerLibraryScan, triggerQuickScan, all WebSocket functions
- Remove library.ts (695 lines of innerHTML string-building replaced by
  HTMX server-rendered partials)
- Remove library import from main.ts
2026-08-07 09:35:47 -04:00
john-okeefe 706be09dec feat(admin): redesign library management with HTMX expandable rows + stats dashboard
Library management:
- Redesign admin_library page with expandable rows that load detail
  panels via HTMX (LibraryList, LibraryPanel, FolderBrowserContent partials)
- Add create/edit/delete library modals using data-* attributes
- Add folder browser modal with inline add/remove via HTMX
- Add user visibility checkboxes toggled via HTMX
- New endpoints in admin_library.go: create, update, delete, panel,
  folders add/remove, browse, visibility
- New template types: FolderData, DirEntry, UserVisibilityData,
  AdminStats; extend LibraryData with TypeValue and FolderCount

Admin dashboard:
- Rewrite admin.templ to show 4-stat grid (libraries, media, users, devices)
- Add getAdminStats helper querying library/user/media/device counts
- Pass AdminStats to template from both /admin and /admin/ handlers
2026-08-07 09:35:39 -04:00
john-okeefe a87c8afc22 refactor(admin): remove separate sidebar, integrate admin nav into main sidebar
- Delete AdminSidebar component entirely
- Add Administration section (Dashboard, Libraries, Users, Settings) to
  header sidebar, visible only for admin users
- Remove Admin Panel link from user dropdown
- Strip admin chrome (sidebar wrapper, back buttons) from all 5 admin
  page templates
- Fix activeClass to handle trailing-slash routes correctly
- Add isUserVisible helper for library visibility toggles
- Fix processing issues page: remove dead Alpine JS, wire HTMX dismiss
  with proper mediaItemId, add issue ID swap targets
- Add processing issue resolve/delete routes to library router
- Add GetProcessingIssueStatsData context-based method
- Fix users page: remove broken hx-headers auth, simplify role select
- Fix settings page: remove dead adminSettings Alpine ref
2026-08-07 09:35:30 -04:00
john-okeefe acc9d08c60 fix: embed time/tzdata so time.LoadLocation works in Alpine container
Alpine doesn't ship the IANA timezone database, causing
time.LoadLocation('America/New_York') to fail with 'Invalid timezone'
for every non-UTC option in the profile settings dropdown.
2026-08-06 15:18:22 -04:00
john-okeefe e09c55cecc feat(devices): reform Add Device modal with KOReader plugin instructions
- Add gear icon (proper cog) and use it for device settings button
- Replace KOReader manual identifier input with step-by-step plugin
  setup instructions (clone repo, enter server URL, approve pending reg)
- Show server URL with copy button pre-filled from baseURL
- Kobo keeps manual registration flow (device name + identifier)
- Comment out Web Browser and Mobile App options (not implemented)
- Use Alpine x-model on device-type select to toggle between
  KOReader instructions and Kobo registration form
2026-08-06 14:48:48 -04:00
john-okeefe 6f1c13998c fix(ui): populate Last Updated and Synced From fields on progress page
GetAllProgressData (SSR handler) was missing LastUpdated, DeviceIcon,
DeviceName, DeviceType, and EpubCFI fields that the template expects.
All showed blank. Now matches the API handler's field population.
2026-08-06 14:06:47 -04:00
john-okeefe 0a0caa9533 fix(ui): round progress percentage to 2 decimals on progress page
The large percentage badge displayed the raw float value unformatted
(e.g. 1.523456789%). Now uses %.2f for a clean display (e.g. 1.52%).
2026-08-06 13:25:54 -04:00
john-okeefe 4716790564 fix: OPDS base_url placeholder bug + setup gate requires base_url
Three bugs fixed:

1. Schema seeded base_url with fake placeholder 'bookhoard.example.com'.
   Removed seed; startup now seeds from BASE_URL env var only if DB row
   is empty (admin changes persist across restarts). One-time UPDATE
   clears the placeholder in existing installs.

2. config.GetBaseURL() had a broken type assertion (local SystemConfigRow
   vs database.SystemConfig) that always failed, returning . Admin panel
   showed env var fallback instead of actual DB value. Fixed with a
   function-type getter that properly wraps the DB query.

3. OPDS handler read base_url only from DB with no fallback. When DB had
   the placeholder, all feed links pointed to an unreachable domain,
   breaking KOReader search/download. Added deriveBaseURL() helper that
   falls back to the request Host/scheme when DB value is empty.

Setup gate improvements:
- isSetupComplete now requires both admin user AND non-empty base_url
- Setup middleware no longer exempts all /api/ routes; only allows
  /api/auth/register, /api/auth/login, /api/system/config before setup
  is complete. All other API routes get 503.
- Cache invalidated when base_url is saved via admin settings

Dev workflow:
- New bruno/NewDevDBSetup/SetBaseUrl.yml for dev DB setup
- NewDB.sh runs SetBaseUrl between RegisterUser and CreateEbookLibrary
2026-08-06 13:02:35 -04:00
john-okeefe 8e2c1a4b3a fix(ui): make icon and color selection work in collection modal
Three root causes, all fixed:

1. Icon buttons were created with setAttribute('onclick', ...) in
   populateIconGrid, but selectIcon is module-scoped (not on window),
   so clicking threw ReferenceError. Switch to addEventListener with
   a closure. Icon search/focus used plain oninput/onfocus attributes
   with the same problem — convert to Alpine @input/@focus.

2. selectColor's highlight selector queried [onclick="selectColor(...)\]
2026-08-06 11:35:14 -04:00
john-okeefe 4a870a7f18 fix(ui): keep search filter visible on system collections 2026-08-06 11:22:26 -04:00
john-okeefe 40dabfd788 feat(ui): hide management controls for system collections
System collections (Not Started, Continue Reading, etc.) compute
their contents dynamically from reading_progress, so manual
add/remove has no effect. Hide the search bar, Remove Selected
button, Add Books button, per-card checkboxes, and Remove buttons
when the collection is system, making the page visually read-only.

- Add IsSystem bool to CollectionData, populated from the database
  is_system_collection flag.
- Add data-is-system to #collection-data so the JS renderer can
  also conditionally omit controls on library switch.
- Wrap toolbar controls, book picker modal, card checkboxes, and
  remove buttons in if !collection.IsSystem in the template.
2026-08-06 11:08:29 -04:00
john-okeefe ef3c05714a fix(ui): custom confirm dialog, picker visibility, remove wiring
Three fixes for the collection detail page:

1. Picker modal never showed because the outer overlay div had
   style="display:none" with no x-show binding — add x-show bound
   to $store.bookPicker.isOpen plus a backdrop and click-to-close.

2. Replace native confirm() with an in-page Alpine modal for UI
   continuity. Add confirm dialog state (showConfirm, confirmMessage,
   pendingAction) and methods (requestRemoveBook, requestBulkRemove,
   executeConfirmed, closeConfirm) to the collections component.
   The actual API calls (doRemoveBook/doBulkRemove) are triggered only
   when the user confirms.

3. Rename removeBook → requestRemoveBook and bulkRemove →
   requestBulkRemove in template + JS renderer so the dialog opens
   instead of navigating or failing silently.
2026-08-06 10:55:37 -04:00
john-okeefe 816ee0ec80 fix(ui): wire up collection detail page interactions
The /collections/:id page had several broken features because three
referenced functions (removeBook, toggleBookForRemoval,
filterCollectionBooks) were never defined, and every book card was
wrapped in <a href="/media/..."> so clicking the checkbox or remove
button navigated to the book detail page instead.

Card restructure:
- Remove the <a> wrapper; title and cover are now individual links.
- Checkbox sits in a <label> with expanded click area (p-2 -m-2).
- Checkbox uses Alpine :checked/@change bound to a reactive
  selectedBooks array on the collections component.

Remove (single + bulk):
- Add removeBook(id) and bulkRemove() methods with confirm() dialogs.
- Wire the "Remove Selected" button with :disabled binding and @click.
- Selected-count badge is now Alpine-reactive (x-show/x-text).

Search within collection:
- Add filterCollectionBooks() that filters cards client-side by
  title/author via data-* attributes and @input.

Book picker ("Add Books"):
- Point the HTMX search inputs at the existing /api/media-items/search
  endpoint instead of the non-existent /api/media-items/filtered.
- Add hx-trigger="loadBooks" + hx-get to the grid so loadBooks()
  actually fires an initial request when the picker opens.
- Merge the hidden limit/offset inputs into the #book-picker-filters
  div so hx-include picks them up (was a separate <form id=filter-form>
  that nobody referenced).
- Add show_checkbox mode to handleSearchHTML: when present, render a
  new BookPickerGrid template with clickable, selectable cards instead
  of the reader BookCard.
- Fix bookPicker submit() to location.reload() instead of a non-existent
  reloadCollection HTMX event, and clearFilters() to target text inputs.
2026-08-06 10:40:18 -04:00
john-okeefe 73a2852ee3 fix(reader): back button remembers the page you came from
The reader's back button was hardcoded to the book detail page
(/media/{id}), so even when you launched the reader straight from the
dashboard the back button ignored that and sent you to the detail view.

Mirror the existing book-detail.ts referrer pattern: capture
document.referrer into sessionStorage on load (excluding other reader
pages and the reader's own URL), then override the back link's click to
navigate there. Falls back to the link's original href (/media/{id}) when
no valid referrer exists (direct URL access).
2026-08-06 09:51:04 -04:00
john-okeefe f67232a20b fix(ui): show read action on JS-rendered dashboard cards
The dashboard re-renders its sections client-side (library switch,
refresh, saving settings) via renderBookCard in dashboard.ts, which was
still the old markup with no .book-card-action overlay. So the read
button appeared on the server-rendered cards but vanished as soon as the
dashboard re-rendered, while the bookshelf (always templ-rendered) kept
working.

- Rewrite renderBookCard to match the templ BookCard: detail link plus
  the play/read action overlay, routing to the reader or the detail page
  when the book has an active conflict.
- Add has_conflict to the BookInfo TS type and stamp it in the dashboard
  sections API (GetSections) so client-rendered cards can route correctly.
- Add pointer-events-none / group-hover:pointer-events-auto to the
  client-rendered carousel nav buttons so they no longer swallow hover
  over edge cards, matching the templ fix.
2026-08-06 08:40:06 -04:00
john-okeefe 7b465ac97e fix(ui): use a grid icon for Customize Dashboard
The settings icon is a circle with radiating spokes, which reads as a
sun/light-mode toggle rather than a dashboard control. Swap it for the
grid icon, the standard dashboard-layout affordance.
2026-08-06 08:24:54 -04:00
john-okeefe 033a012069 feat(ui): group sidebar footer menus into distinct panels
The account and appearance menus sat in one container with no separation,
so expanding one made the other hard to find. Each collapsible menu now
lives in its own subtly lifted, bordered panel (.sidebar-panel) so the
expanded items stay visually contained and the menus never blend together.
Applied to the account, appearance, and sign-in menus.
2026-08-06 08:14:10 -04:00
john-okeefe 9165c4eb3f feat(ui): use a book-open icon for the card read action
Swaps the play (triangle) icon for book-open on book cards, since the
action is to start reading rather than play media.
2026-08-06 08:01:33 -04:00
john-okeefe 9ef6c5b6ed feat(ui): split book-card play action into reader/detail routing
The play button on book cards now opens the reader directly, instead of
always going to the detail page. Cards with an active progress sync
conflict route the play button to the detail page (which hosts the
conflict dialogue and resolves before writing progress), so the user is
never silently dropped into the reader with an unresolved conflict.

Backend:
- Add HasConflict to BookInfo and stamp it via ListSyncConflictsByUser
  (MarkActiveConflicts / MarkActiveConflictsSections) on the dashboard,
  bookshelf, series, tag, and search result card builders.
- Each page issues a single conflict query regardless of card count.

BookCard:
- Restructure into a detail link (cover + meta) with the play action as a
  sibling overlay using a pointer-events split: the container passes
  clicks through to detail while only the circular button routes to the
  reader. No nested anchors.
- On touch devices (hover: none) the play button stays visible.

Fix: carousel nav buttons had opacity-0 without pointer-events-none, so
they swallowed hover/clicks over book cards on the dashboard. They are
now click-through until the carousel is hovered.
2026-08-06 07:52:20 -04:00
john-okeefe 987ece38f0 fix(reader): keep footer compact on mobile and clear text under chrome
On phones the footer's progress cell rendered the full chapter title (e.g. 'Long Chapter Name · 5 / 12') in a div with no max-width or nowrap, so the text wrapped to multiple lines and ballooned the bottom bar. Combined with viewport offsets that were computed from assumed pixel heights with ~0px margin, the book text slipped underneath the bars.

Chapter label in footer: split #progress-display into two spans (progressLabel hidden on mobile via 'hidden sm:inline', progressMain always shown) and cap it with 'truncate whitespace-nowrap max-w-[5rem] sm:max-w-none' so it can never wrap or grow the bar. Phones now show just '5 / 12'; larger screens keep 'Chapter · 5 / 12'.

Viewport offset: replace the fragile hardcoded calc() with runtime measurement. Gave the chrome bars ids (reader-topbar/reader-bottombar) and added updateViewportInsets(), which sets #reader-viewport top/bottom from each bar's real offsetHeight (which already includes env(safe-area-inset-*) padding) plus a 6px margin. It runs on init and refreshes on resize, orientationchange, and via a ResizeObserver, so the content area tracks the actual chrome height on any DPI, notch, home-indicator, or zoom level instead of guessing.

Refactored formatProgress into formatProgressParts (returns {label, main}; only chapter mode sets a label) with a setProgress() helper wiring progressLabel/progressMain/progressText across the relocate, cycleProgressMode, and applyProgressMode call sites. Rebuilt reader_templ.go and style.css.
2026-08-05 21:58:42 -04:00
john-okeefe b23f6b0bab build: regenerate stylesheet for sidebar app shell 2026-08-05 16:45:28 -04:00
john-okeefe 8ea70ea7f8 feat(ui): migrate remaining pages to the new shell
Apply the sidebar shell and bold primitives across the rest of the app so
the whole experience shares one visual language:

- Browse/organize: series (keeps stacked-covers/series-card CSS),
  collections (keeps wood-paneling + carousel classes), browse_detail.
- Account/stats: profile + form + modal, progress, analytics (stat-card
  tiles), devices, conflicts (status semantics), queue (status/priority
  badges).
- Admin: admin sidebar restyled with icons, dashboard/users/library/
  processing-issues/settings migrated to .card/.btn/.input primitives.
- Docs/setup/misc: docs (keeps prose/highlight.js), api_explorer, setup
  wizard, unlinked_books, custom_section builder.
- Modals/fragments: collection_modal, collection_rules,
  restore_system_collection_modal, filter_item, book_detail_modals — all
  overlays use --surface-overlay + --shadow-pop.

Every Alpine handler, HTMX attribute, id, name, and data-* is preserved;
only presentation changes.
2026-08-05 16:45:02 -04:00
john-okeefe 07578e0206 feat(ui): redesign dashboard, bookshelf, book detail, and auth pages
- Dashboard: page heading, bold section headers with accent icon tiles,
  carousel chevrons as SVG icons with theme-aware gradients (wood-paneling
  gradient classes preserved), and a cinematic BookCard (hover overlay with
  a quick-action button). BookCard is now fluid so it fills both the
  carousel slot and the bookshelf grid.
- Bookshelf: the filter wall becomes a search toolbar + a slide-in filter
  drawer (filtersOpen state added to the bookshelf Alpine component). Every
  filter input, the tristate cover toggle, tag autocomplete, save/load/clear
  actions, HTMX search/sort, and pagination are preserved.
- Book detail: blurred cover backdrop hero, rounded-2xl cover, bold
  typography, .btn action bar, progress/metadata cards, chip-style external
  links. All interactive rating, modals, and data-attrs preserved.
- Auth/landing: brand-gradient hero for index/login/register, icon feature
  cards, data-driven theme select (ThemeOptions). All ids (#theme-select,
  #result, #auth-result, password-requirement ids) and Alpine init preserved.
2026-08-05 16:44:52 -04:00
john-okeefe f402a3ee03 feat(ui): sidebar app shell and bold design system
Replace the top-nav with a fixed left sidebar + slim content topbar
(the Komga/Audiobookshelf layout), the signature change versus the
conservative tighten-ui branch.

- App shell: .app-sidebar (off-canvas on mobile via Alpine mobileMenuOpen,
  pinned at 16rem on lg), .app-topbar (fixed, blurred, 4rem), and
  .app-subbar for in-page sticky bars (parks under the topbar). Content is
  auto-offset via body:has(.app-sidebar) so reader.templ/error.templ (which
  have no sidebar) are untouched.
- Header rebuilt as the sidebar: logo + vertical nav (activeClass), an
  inline Appearance picker driven by ThemeOptions/WoodOptions, and an
  inline account menu / sign-in (preserving the inline htmx login). The
  search lives in the topbar so its dropdown still anchors correctly.
- Bolder primitives: .card -> rounded-2xl, cinematic .book-card-cover
  hover overlay with a quick-action affordance, .brand-gradient hero
  surface, .hero-backdrop (blurred cover) and .stat-card utilities.
2026-08-05 16:44:42 -04:00
john-okeefe f3f908eb7e feat(ui): design system foundation
Add semantic design tokens and base primitives to replace the verbose
inline var() styling that made the UI feel dated.

- Rename colliding Tailwind color tokens (bg-primary/bg-secondary) to
  semantic names (surface/surface-raised/content/content-muted/brand/line)
- Derive hover, overlay, border-strong, accent-muted and elevation tokens
  once on <body> so they adapt to every theme automatically
- Make :root mirror Tokyo Night to kill the first-paint theme flash
- Add component primitives in @layer components: .card (surface + soft
  shadow, no hard border), .btn variants, .input, .chip, .badge, .icon-btn
- Add theme-aware status/priority badges and global focus-visible styling
- Add an inline-SVG Icon() component (consistent stroke language) to
  replace the mixed emoji/SVG iconography
- Data-drive theme/wood options and add an active-nav helper
- Add skeleton shimmer + x-cloak support
2026-08-05 16:03:07 -04:00
john-okeefe 94a4facc6c chore(deps): promote golang.org/x/net to direct dependency
Release / build-and-push (push) Successful in 3m2s
Pulled in as a direct requirement by templ v0.3.1020 code generation; it was previously only an indirect dependency.
v0.5.0
2026-08-05 15:31:48 -04:00
john-okeefe 11617c1860 fix(reader): tighten mobile header/footer and add safe-area margins
The reader chrome used the same dimensions at every screen size, and the book viewport offset was hardcoded to 52px. This made the header/footer oversized on phones and left the body text flush against (or overlapping) the bars, with no handling for notched-device safe areas.

- Add viewport-fit=cover so notched devices expose safe-area insets.
- Shrink the top bar on mobile (px-3 py-2 / text-base, scaling up at sm:) and hide the chapter title on phones (hidden sm:block sm:truncate).
- Shrink the bottom bar on mobile (tighter padding/gap, p-1.5 sm:p-2 on buttons) while keeping all controls visible.
- Replace the hardcoded top-[52px] bottom-[52px] viewport offsets with responsive calc() values (44/48px mobile, 60px at sm:) that fold in env(safe-area-inset-*), plus matching safe-area padding on the bars, so the book content always clears the chrome with a visible margin.

Regenerates reader_templ.go and rebuilds style.css.
2026-08-05 15:31:40 -04:00
john-okeefe d0040fe428 chore(deploy): add restart: unless-stopped to app container
Release / build-and-push (push) Successful in 2m26s
The app service in docker-compose.yml had no restart policy (defaults to
'no'), so if the process exited -- e.g. the watcher-leak panic fixed in
the previous commit -- the container stayed down until a manual
restart. Adding restart: unless-stopped makes the container self-recover
from crashes or host reboots, while still honoring explicit 'docker
compose down'.

Defense-in-depth alongside the scanner leak/panic fix: even if a future
unforeseen panic occurs, the app comes back automatically.
v0.4.0
2026-07-31 10:45:53 -04:00
john-okeefe 59d5de3607 fix(scanner): eliminate fsnotify watcher leak and harden worker against panics
The bookhoard container crashed with 'panic: Failed to create file
watcher: too many open files' (media_scanner.go) after running for a few
hours, preceded by floods of 'no space left on device' from watcher.Add.

Root cause: every scan job called NewMediaScanner(), which eagerly
created an fsnotify watcher. SetFolders() then walked the entire library
tree and registered one inotify watch per directory (~3,000+ across the
libraries), and ScanFolders() registered them again during its walk. The
worker never called scanner.Close() on these ephemeral per-job scanners,
and the worker loop had no recover(), so:

  1. Leaked watchers accumulated until the kernel inotify watch cap was
     hit (ENOSPC -> 'no space left on device'), then
  2. the process fd limit (ulimit -n 1024) was exhausted, causing
     fsnotify.NewWatcher() to fail with EMFILE, and
  3. NewMediaScanner panicked on that error, taking down the whole
     process (exit code 2). With no restart policy the container stayed
     down.

The scan jobs run frequently (scan_poll_interval), so the leak built up
within hours. Note this was NOT a disk-space issue; df showed plenty free.

Fix:

- media_scanner.go: NewMediaScanner no longer creates a watcher eagerly
  (s.watcher starts nil), which removes the panic site entirely -- there
  is nothing to fail at construction. The watcher is created lazily only
  when needed.

- media_scanner.go: SetFolders gains a [?1049h(B[?7h[?25lEvery 2.0s: boolgaruda-ser8: Fri 31 Jul 2026 10:45:41 AM EDTin 0.002s (127)
sh: line 1: bool: command not found
[?12l[?25h[?1049l
[?1l> parameter. It creates
  and populates a watcher (returning an error instead of panicking) only
  when watch=true; otherwise it skips all watcher.Add calls. ScanFolders
  guards its watcher.Add with a nil check, and the WatchChanges event
  loop exits cleanly when there is no watcher (polling still runs).

- worker.go: the worker() loop now wraps each job in defer/recover() so a
  panicking job is recorded as failed and can never kill the process.

- worker.go: the three ephemeral scan handlers (processScanJob,
  processSetFoldersJob, processDirectoryScanJob) now defer scanner.Close()
  and call SetFolders(..., false), so scan jobs allocate zero watchers and
  zero inotify watches. Any pre-existing leak is also bounded by Close().

- handlers/scanner.go: the long-lived watch-mode scanners (StartScanner
  and StartWatchModeForLibrary) pass watch=true since they actually read
  watcher.Events for live change detection.

- calibre_integration_test.go: updated to the new SetFolders signature
  (watch=false, matching one-off scan usage).

Auto-add is fully preserved: new files are still detected by the periodic
poller (startBackupScan), which is independent of fsnotify and unaffected
by these changes. The watch-mode event loop remains as bonus responsiveness
when inotify is available; through Docker bind mounts where inotify is
unreliable, polling is what catches new books.
2026-07-31 10:45:41 -04:00
john-okeefe fffe0b17e6 ci(release): name Actions runs 'Release <tag>' instead of the commit message
Release / build-and-push (push) Successful in 2m34s
Add a top-level run-name so the Gitea Actions runs list shows
'Release v0.3.0' rather than the tagged commit's subject. Uses the same
expression (inputs.tag || ref_name) as the TAG env, so it resolves for
both tag pushes and manual workflow_dispatch.
v0.3.0
2026-07-30 15:50:02 -04:00
john-okeefe 451aa48aec ci(release): auto-generate release notes via git-cliff
Release / build-and-push (push) Successful in 2m42s
Replace the image-only tag pipeline with a full release workflow that also
publishes a Gitea Release whose body is the annotated tag's message,
generated from Conventional Commits by git-cliff. No hand-written release
notes are required.

- cliff.toml: group commits (Features, Bug Fixes, Refactor, Documentation,
  Tests, Miscellaneous Tasks) with scopes and short-SHA links; emit only the
  current tag's section rather than the full history.
- .gitea/workflows/release.yml: tag-driven. Reads the release body from the
  annotated tag (git tag -l --format), so the tag message and release body are
  a single source of truth. Idempotent create/PATCH; prints the Gitea API
  error body on failure so a 403 names the missing token scope instead of
  failing silently. Adds a workflow_dispatch tag input so manual re-runs
  target the right tag instead of the default branch.
- Makefile: release VERSION=vX.Y.Z generates notes via git cliff --latest
  against a throwaway tag, then creates an annotated tag with
  --cleanup=verbatim so the markdown group headers are preserved (git's
  default cleanup strips lines starting with "#").
- release: project-attached wrapper accepting a positional version arg
  (./release 0.3.0 or ./release v0.3.0) and auto-prefixing v, for ergonomic
  one-command releases.
2026-07-30 15:40:27 -04:00
john-okeefe 5ac407057e fix(search): link results to book detail page and add cover thumbnails
Release / build-and-push (push) Successful in 2m24s
Search results navigated to /bookshelf with no filters instead of the
selected book's page. Results now link to /media/:id and display cover
thumbnails, with cover URLs resolved server-side via ResolveMediaURL.
Removes the dead selectedBook localStorage plumbing.
2026-07-30 14:55:00 -04:00
john-okeefe bf83492bf7 feat(book-detail): add Mark as Read / Unread toggle button
The book detail page had no way to mark a book finished or reset its
read state from the UI. Reading state is modelled by reading_progress
alone, where 'read' is the canonical signal percentage >= 1.0 (used by
the dashboard Recently Read collection, analytics, and sync priority).

Add a single toggle button in the action row (after Read Now) whose
label is server-rendered from completion state:
- not read  -> "Mark as Read"    -> PUT /api/media-items/:id/progress
                                       { percentage: 1.0 }
- read      -> "Mark as Unread"  -> DELETE /api/media-items/:id/progress

Mark as Unread cannot use PUT { percentage: 0 }: the progress handler
silently ignores percentage < 0.005 when existing progress > 0.01
(internal/handlers/media.go anti-regression guard), so DELETE is the
only reliable reset.

If the book has an active sync mismatch (an unresolved sync_conflicts
row), the toggle resolves it first via POST /api/conflicts/:id/resolve
before writing progress. Order matters: resolving sets resolved_at,
arming the 10-minute HasRecentConflictResolution suppression window so
the subsequent progress write does not spawn a brand-new conflict. The
resolve winner is any valid source key from the conflict data (prefers
"web"); it does not affect the final state, which the progress write
sets. A 400 "already resolved" response is tolerated.

Notes, highlights, and ratings are independent of reading_progress (they
reference media_items, not progress) and are never affected by the
toggle. After toggling the page reloads so the progress card, Sync
Progress button, and conflict banner re-render server-side.

- templates/utils.go: add conflictWinnerSource and conflictID helpers.
- templates/book_detail.templ: data-conflict-id/winner on <body> and the
  toggle button.
- web/src/book-detail.ts: toggleRead() + conflictId/conflictWinner/
  readSaving state (read from <body> in init()).
- templates/book_detail_templ.go regenerated.
2026-07-30 13:31:18 -04:00
john-okeefe 33c69e7c71 chore(templates): regenerate stale book_detail_modals templ output
Running `templ generate` to pick up the book_detail changes also
resynced book_detail_modals_templ.go, whose committed output was stale
relative to its source. The regeneration (templ v0.3.1020) reformats
boolean attribute rendering (e.g. `selected`) via
templ.ResolveAttributeValue and reflects pre-existing source additions
such as id/for label associations.

No source (.templ) change in this file; generated output only.
2026-07-30 13:08:25 -04:00
john-okeefe c2f72ca785 docs(bruno): correct rating scale and endpoint paths
The Bruno collection docs mislabeled the rating system and referenced
endpoints that do not exist.

- Update Media Rating.yml: the rating value is a 1-10 integer scale
  (displayed as 1-5 stars with half-star precision), not "typically 1-5".
- opencollection.yml: the rating routes live under
  /api/media-items/:id/rating (not /api/ratings/:media_id), GET returns
  null (not 0) when unrated, and document the PUT upsert route. Correct
  the scale to 1-10 here as well.
2026-07-30 13:08:14 -04:00
john-okeefe ca8c592496 feat(book-detail): add interactive half-star rating widget
The book detail page only displayed user ratings as static, non-clickable
stars. The full rating CRUD stack already existed in the backend
(media_ratings table, POST/GET/PUT/DELETE /api/media-items/:id/rating)
but nothing in the web UI could create or update a rating.

Replace the display-only renderStars output for the user rating with an
Alpine.js widget that:
- Renders 5 stars, each split into two transparent hit zones so the
  underlying 1-10 scale maps to half-star precision (left half = x.5,
  right half = whole star).
- Shows a live hover preview via a ratingHover state field.
- Saves the rating in place through POST /api/media-items/:id/rating
  (which upserts) and reflects the value immediately, with no full page
  reload.
- Displays the numeric value (e.g. "3.5 / 5") and a Clear button that
  issues DELETE to remove the rating.
- Reads the server-rendered value from a new data-rating attribute on
  <body> during the bookDetail component init().

The community rating block is left as a display-only renderStars render
since it is imported metadata, not a user rating.

templates/book_detail_templ.go is regenerated (also picking up templ
v0.3.1020 reformatting of the generated output).
2026-07-30 13:08:00 -04:00
john-okeefe 1f5b0d0164 docs(api): document OPDS pagination links and OpenSearch search
Update the OPDS section of the API reference to reflect the now-working
catalog:

- Document the page/per_page parameters and that paging is driven by the
  rel=next/previous/first/last links plus OpenSearch paging metadata.
- Refresh the example feed XML to show the pagination links, opensearch
  namespace/elements, and standard Atom <title>/<author> elements.
- Document the search endpoint's two modes: OpenSearch description
  (application/opensearchdescription+xml, no q) and results feed (with q),
  with an example description document.
2026-07-30 12:12:58 -04:00
john-okeefe 13cc689bff fix(opds): wire up catalog pagination links and OpenSearch search
The device catalog feed was unusable on paged OPDS clients such as
KOReader: it sliced results into pages but never advertised how to reach
the next page, so clients could only ever fetch the first page (~50 books)
and could not search the catalog.

GetDeviceCatalog:
- Emit the full set of OPDS pagination link relations (self, start, first,
  previous, next, last) pointing at catalog?page=N&per_page=M, with the
  device auth token appended for path-based auth.
- Emit OpenSearch totalResults/itemsPerPage/startIndex metadata.
- Point rel=search at the OpenSearch description (correct MIME type).

SearchDeviceCatalog now branches on the q parameter:
- No q: return an OpenSearch description document whose Url template
  contains the {searchTerms} placeholder, so clients can formulate a query.
- With q: return the existing acquisition results feed, now including
  totalResults.

A pure addCatalogPaginationLinks helper holds the page/URL logic so it can
be unit tested without a database. New handler tests cover middle/first/
last/single/empty pages (correct presence of next/previous) and token
appending.

Ordering is intentionally left unchanged (created_at DESC, grouped by
library).
2026-07-30 12:12:51 -04:00
john-okeefe 9920fd47b9 feat(opds): add OpenSearch pagination metadata and search description
Extend the OPDS feed model so clients can page through large catalogs and
discover how to search them.

Feed changes:
- Add the OpenSearch namespace (xmlns:opensearch) to all feeds.
- Add optional TotalResults/ItemsPerPage/StartIndex fields, serialized as
  <opensearch:totalResults>, <opensearch:itemsPerPage> and
  <opensearch:startIndex>, plus a SetPagination helper.
- Add OpenSearchDescription/OpenSearchUrl types and a NewSearchDescription
  constructor with GenerateXML/GenerateXMLString. This produces the
  OpenSearch description document (application/opensearchdescription+xml)
  that OPDS clients like KOReader fetch to learn the {searchTerms} search
  URL template.

These are building blocks; the handlers are wired up in a follow-up commit.

Tests cover SetPagination, omission when unset, XML emission of the
paging metadata, and OpenSearch description generation/serialization.
2026-07-30 12:12:33 -04:00