Compare commits

...
26 Commits
Author SHA1 Message Date
John O'Keefe 03c3e151dc feat(audio-player): gear-menu default speed + split shortcuts into Mouse/Touch (plan §12)
Release / build-and-push (push) Successful in 3m13s
The Player settings popover gains a 'Default playback speed' row
beside autoplay — a compact rate select, always visible (the Speed ▾
footer hides when current == default, which made the setting
undiscoverable at 1x/1×); same setDefaultSpeed PUT. The shortcuts
dialog splits its pointer section into MOUSE (click/drag/shift/wheel)
and TOUCH (drag/pull-away) — the bar has two scrub systems and the
sections now say so.
2026-10-22 15:39:14 -04:00
John O'Keefe 3576a4415a feat(audio-player): per-book playback speed + user default (plan §8)
Speed becomes a per-USER-per-BOOK preference that syncs across
devices, with a user-level default for books not yet played:
- new audio_playback_speed table (mirrors reading_speed's shape; a
  preference, NOT a measurement and NOT a reading_progress column) +
  users.default_audio_speed, schema in place
- chapters endpoint resolves per-book row → user default → 1.0 and
  ships playback_speed + default_playback_speed (zero extra round
  trips; default powers the dropdown marker)
- PUT /api/media-items/:id/audio-speed upserts (range-checked; the
  client SPEEDS list stays the real gate; library visibility gates
  writes)
- the user default rides the profile path like theme/timezone:
  profile form gains a 'Default Playback Speed' select (server-side
  SpeedOptions mirrors the web SPEEDS list), GET /profile carries it
- web: restorePosition applies the resolved server speed (localStorage
  audio_speed demotes to a first-paint cache); setSpeed PUTs
  fire-and-forget with a failure toast; the Speed dropdown gains a
  '(default)' row marker and a 'Make N× my default' action with a
  success toast

ALSO fixes a pre-existing profile-page bug surfaced by the §8 form
verification: the password section's current_password input was
required INSIDE the shared form, so HTML5 validation blocked every
Save Changes submission (dead since the shell migration). The
password flow keeps its own gates (disabled-button checkers + server
400).

Integration test TestAudioPlaybackSpeed pins the full contract (unset
→ 1x, upsert resolution, range rejection, default fallback for
row-less books, default updates re-satisfying unset books). Verified
live: 9 Playwright checks.
2026-10-22 15:01:41 -04:00
John O'Keefe 3325b64463 fix(audio-player): precision rail rendered transparent
Alpine :style string bindings REPLACE the element's style attribute —
the rail's static background/border/z-index were wiped the moment the
binding evaluated, leaving bare text over the transports. Fold all
declarations into the single :style binding.
2026-10-22 14:10:42 -04:00
John O'Keefe a9430f3c06 feat(audio-player): end-time label toggles total / book-left / chapter-left (plan §10 web)
Clicking the end time cycles the three display modes, persisted per
device in localStorage (audio_time_display). Pure helpers (mode list,
normalize, cycle, endTimeLabel with 0-clamp and no-chapters fallback
to book remaining) + vitest; aria-label announces the current mode and
the next. Remaining values track the scrub cursor mid-drag. 140 unit
tests; 15 Playwright checks on KJV + Gardens.
2026-10-22 13:11:16 -04:00
John O'Keefe 96fd2aad42 feat(audio-player): scrub interaction rework — free-flow drag, pixel magnetism, touch pull-away precision (plan §9)
Mouse: drag is free-flow 1:1 (rail only on Shift+drag, now with
window from/to edge labels); clicks land with pixel magnetism
(MAGNET_RADIUS_PX=10) — the old 5-second threshold was sub-pixel on
long books and never fired, which was the 'chapter scrub isn't
accurate' bug. Hover inside the radius locks the tooltip to the line
with a highlight. Shift+click jumps to the nearest line from anywhere.

Touch: drag is an incremental cursor (applyDrag) whose scale tightens
continuously as the finger pulls off the bar (dragScale, exponential
10^(-|dy|/80px)) — the rail is the feedback: window shrinks with the
zoom (touchRailSpan) and lifts toward the finger (railLiftPx).

Dialog retitled 'Shortcuts & interactions' with a Mouse & touch
section driven by POINTER_GUIDE (audio-keys.ts). Old seconds-threshold
snapToChapter deleted. 133 unit tests; 19 Playwright checks on the
KJV (1189ch) and Gardens (34ch) books.
2026-10-22 13:05:10 -04:00
John O'Keefe 1f30602c23 feat(audio-player): progress bar overhaul — ticks, buffer, wheel, tooltip, precision rail (plan §1)
The scrubber becomes a layered bar: the native <input type=range> stays
on top for a11y (focus, arrow keys, aria — its track goes transparent
via static page CSS) while the layers beneath render the state:

- Chapter ticks: density-decimated (denseTicks keeps only marks >= 4px
  apart) so an 1189-chapter 86-hour book gets ~100 readable marks
  instead of a solid mass; the current chapter's tick is tinted.
- Buffered span: the ACTIVE track's buffered-ahead end mapped into the
  global bar (buffers are per-track and never span the whole bar).
  bufferedAheadEnd reads the range containing the playhead — a huge
  m4b's moov index at the file tail is its own buffered range and must
  not read as 'the whole book is buffered'. Rides progress + the
  events that demonstrably fire for Range-served files that go
  network-idle while paused (timeupdate/canplay/loadedmetadata/playing).
- Wheel over the bar: ±5s nudge, Shift = ±60s.
- Hover tooltip: time + chapter title under the pointer.
- Precision rail: while dragging, a ±5-minute window floats below the
  bar (fixed anchor; release and re-grab to travel far) and the drag
  maps at minutes-per-pixel instead of hours-per-pixel — the whole
  point for 80h+ books. Click snaps to a chapter line within ~5s
  (snapToChapter).

Pointer handling is ours end-to-end: pointerdown maps hours-per-pixel
+ snaps, captured moves remap through the rail, release seeks. The
native drag default is prevented so the two mappings never fight;
keyboard arrows keep their native step and seek via change.

Time labels get reserved width — the bar no longer resizes when the
elapsed label grows from 0:00 to 43:07 mid-drag (caught by the rail
drag check measuring a 25% mapping drift).

Pure math in audio-scrubber.ts: pointer mapping, chapter lookup, snap,
tick density, per-track buffer→global span, rail window/mapping/ticks.
Unit: 16 new tests including both book shapes (86h/1189ch and
26h/34ch). Live: 23 Playwright checks on KJV (folder) and Gardens of
the Moon (single-file m4b); zip books share the folder manifest shape
(no zip fixture exists in the dev library). Probe user deleted.
2026-10-22 09:49:02 -04:00
John O'Keefe 18bed79d3a feat(audio-player): settings popover + autoplay-on-open (plan §4)
Header gear (beside the keyboard icon) opens a 'Player settings'
popover — one option row today, structured for more: 'Autoplay on
open', a role=switch toggle persisted to localStorage audio_autoplay
('1'/'0', default off) via the normalizeAutoplay one-source helper.

maybeAutoplay() runs strictly AFTER restorePosition resolves: the
?loc=audio:<seconds> deep link keeps winning on position; the setting
only decides whether playback starts. A browser autoplay-policy
rejection (or any play() failure) is swallowed silently — the user
just taps play; the unhandled-rejection trap in init would otherwise
shout 'REJ:' into the title. settingsOpen joins closeTopSurface, so
Esc closes the panel with the other surfaces.

Unit: normalizeAutoplay tests. Live: 13 Playwright checks — default
off stays paused, toggle + persistence, reload autoplays with no
gesture, deep link lands at 600s global while autoplay starts, off
stays paused, and a stubbed NotAllowedError proves the silent
swallow; probe user created and deleted.
2026-10-22 09:04:03 -04:00
John O'Keefe fe7eab91e8 feat(audio-player): sleep timer — moon pill, countdown, 5s fade then pause (plan §3)
Off / 5 / 10 / 15 / 30 / 45 / 60 minutes / end-of-chapter. The moon
pill sits in the settings strip between Bookmark and Volume and shows
the live mm:ss countdown while armed (or 'End of chapter'); the
dropdown's armed row highlights like Skip/Speed.

audio-sleep.ts is the pure, DOM-free state machine (arm/due/fired/
fade-ramp/label + the SLEEP_CHOICES vocabulary the templ dropdown
iterates). The component owns the timers: a 1s countdown ticker in
minutes mode, and a ~5s element-volume fade on expiry that ends in
audio.pause() — the SAME pause listener a manual pause fires, so the
progress push and media-session state land. The user's configured
volume is restored right after the pause, and the fade operates on
the element's volume the user controls, not a parallel path.

End-of-chapter fires when the armed chapter is left — natural
crossing (tickChapter) or a manual jump (jumpToChapter) alike. Manual
play during a fade aborts the expiry (you're awake, you asked);
stopSleep restores any partial fade. Per-session ONLY: nothing
touches localStorage — the reload check proves it starts Off.

sleepOpen joins closeTopSurface's dropdown branch, so Esc closes it
with the other settings-strip surfaces. Muted (or zero-volume) expiry
skips the pantomime and pauses immediately.

Unit: audio-sleep.test.ts (deadline mechanics, chapter-fired
semantics both directions, fade ramp clamps, label formatting,
SLEEP_CHOICES integrity). Live: 24 Playwright checks on the KJV book
— dropdown, countdown ticking, armed-row highlight, end-of-chapter
fire + reset, internal 3s deadline fade→pause→restore, play-abort,
reload-starts-Off; probe user created and deleted.
2026-10-22 08:40:54 -04:00
John O'Keefe 295c4b0664 feat(audio-player): keyboard shortcuts — declarative keymap, dialog, media keys (plan §2)
Space/Enter (body-focus only), H/L + arrows skip, J/K + PgDn/PgUp
chapter steps, B bookmark capture (note box auto-focuses, Enter saves
even when blank, Shift+Enter inserts a newline, Esc cancels),
Alt+arrows / Alt+H/L speed steps, up/down volume with a brief OSD,
M mute, [ ] skip-duration cycle, ? shortcut dialog, Esc closes the
top-most surface (help → capture → note edit → drawers → dropdowns).

audio-keys.ts is the ONE declarative table: resolveShortcut() drives
the document keydown dispatcher and visibleKeymap() renders the
templ dialog (x-for over the exposed keymap — templ cannot import
TS). Hardware media keys ride the existing MediaSession handlers;
the Media* keydown codes resolve through the same table to the SAME
methods, so both layers stay one behavior.

Guards mirror the ebook reader: typing in any
INPUT/TEXTAREA/contentEditable never triggers shortcuts (bookmark
notes included), a focused button keeps Space/Enter for itself, and
the focused scrubber keeps its arrows (its own @change seeks — a
native arrow nudge moves 0.08s, not a 10s skip).

SKIP_SECONDS consolidates to one const beside SPEEDS (the templ skip
dropdown now iterates skipChoices; [ ] cycles via cycleSkip()) — the
same one-source pattern as the §5 speed-list consolidation.

Unit: audio-keys.test.ts (resolution, Alt disambiguation, media
keys, keymap integrity and conflict-freedom, dialog visibility) and
cycleSkip tests. Live: 39 Playwright checks on the KJV folder book
covering every binding, the guards, dialog and OSD; probe user
created and deleted.
2026-10-22 08:15:06 -04:00
John O'Keefe db96ea91cd refactor(audio-player): one source for the speed list (plan §5)
The rate list was duplicated — hardcoded in the templ x-for AND
implicitly in audio.ts — and phase 2's keyboard stepping would have
needed a third copy. SPEEDS is now the single exported const (the
agreed nine values, 0.5 through 3); the component exposes it as
'speeds' for the dropdown's x-for (templ cannot import TS), and
stepSpeed() walks the same array for the future audio-keys consumer.

normalizeSpeed() maps persisted values onto the list: garbage or
out-of-list localStorage entries fall back to 1x, and init() writes
the normalized value back so storage heals instead of staying
poisoned.

Pure logic unit-tested in audio.test.ts; live-verified with Playwright
on the KJV folder book: nine options render, pick/reload/persist and
the stale-value fallback all behave, button label and
audio.playbackRate agree in every case.
2026-10-21 21:08:27 -04:00
John O'Keefe 408a972665 fix(audio-player): playback speed silently reverted to 1x on every reload
restorePosition() swaps the <audio> element's src (manifest track for
multi-file books, whole file for single-file, plain URL on offline
start), and loading new media resets playbackRate to the element
default. init() had applied the persisted speed before any src
existed, so after the swap every load actually played at 1x while the
Speed pill still showed the stored value — pick 1.5x, reload, and
audio was back at 1x.

The seekTo() src-swap path already re-applied the rate; the initial
load paths now do the same (manifest branch + offline-catch branch).
Surfaced by the phase-1 speed-list browser check: button label and
audio.playbackRate disagreed after reload.
2026-10-21 21:08:06 -04:00
John O'Keefe 6508abc00f docs(agents): the doctrine — tests never run against a real server
Codify the standing rule at the top of the Tests section: the dev DB is
the test environment, and the invariant every test must uphold is the
testuser experience — its settings, scans, and reading data must be
exactly as the suite found them. The helpers exist to enforce this;
when a test needs protection the helpers don't provide, expand the
helpers rather than hand-rolling.

New known-gotchas from the 2026-10-21 incident cluster:
7. scanner re-extraction is write-after-success — audio_metadata is
   guarded on both writers; new scan-owned column writes need the same
   guard.
8. templ double-escapes style attribute values (sanitizer + EscapeString)
   — emit quote-free CSS via unquoted url() tokens through SafeCSS.
9. vp test runs the vite config — build-hook plugins fire in the test
   context too; outDir-mutating plugins need an "am I a build?" guard.
2026-10-21 20:25:26 -04:00
John O'Keefe 3ed852515b test(helpers): setSystemSettingRestored — leave shared settings as found
The testuser-experience rule: the suite must leave shared settings
exactly as it found them. setSystemSettingRestored snapshots the prior
system_settings row, writes through the sanctioned API path (the only
one that reloads the settings registry — raw SQL leaves the cache
stale), and registers a cleanup restoring what WAS there, never a
hardcoded value.

setExtractorVersion now rides it, so the ev-pin tests no longer
restore scan_extractor_version to a hardcoded 1: a deliberate dev bump
of the extractor version survives a suite run instead of being
silently cancelled (the live dev server obeys that setting on its next
poll and would stop re-extracting parser fixes). The now-redundant
explicit t.Cleanup restores are removed from
TestExtractorVersionReextract and pinExtractorVersionOne.
2026-10-21 20:24:44 -04:00
John O'Keefe f62b46fb15 test(integration): route regression tests for the audio_metadata guards
Three tests in scanner_audio_guard_test.go pin the routes the wipes
traveled; each drives a real scan/rescan/edit over ffmpeg-rendered
fixtures and asserts the payload survives with duration and manifest
intact:

- TestRescanMediaItemPreservesAudioMetadata: folder book through the
  focused payload writer.
- TestRescanMediaItemPreservesAudioMetadataSingleFileM4B: the incident
  class verbatim — single-file m4b (no layout key) routes through the
  generic updater; fixture rendered with ffmpeg AAC/ipod
  (writeTestTrackM4B).
- TestMetadataEditorSavePreservesAudioMetadata: the metadata editor's
  full-row save through PUT /api/media-items/:id.

Verified to bite: with the handler fix stashed, the editor test fails
with "metadata edit must never NULL audio_metadata"; with it, green.
2026-10-21 20:24:09 -04:00
John O'Keefe 35d8ba32e5 fix(media): metadata editor save no longer wipes audio_metadata
PUT /api/media-items/:id (the metadata editor) writes the FULL row via
UpdateMediaItem, whose query sets audio_metadata unconditionally — and
audio_metadata has no editor field, so the params literal left it nil
and every save NULLed the payload. A plain metadata edit (genre tweak,
title rename) dropped any audiobook back to "1 second long" with no
track manifest: the same incident class as the scanner wipe, sitting
on the most-used admin path in the app.

Carry existing.AudioMetadata through the write. Write-after-success:
only the audio chain (probe-backed extraction /
UpdateMediaItemAudioBookPayload) ever replaces that column.
2026-10-21 20:23:42 -04:00
John O'Keefe 80bfc1a77b fix(scanner): re-extraction is write-after-success — no NULL audio_metadata
The 2026-10-21 wipe: a re-extraction pass NULLed audio_metadata on the
m4b audiobooks — every affected book dropped its chapters, track
manifest and duration and displayed as 1 second long (the reader also
lost its manifest, since single-file books synthesize it from the
payload).

Guards on both writers of that column:
- updateMediaItemWithMetadata (generic full-row pass): when the new
  extraction carries no audio payload, keep existing.AudioMetadata.
  Single-file m4b books route here, and a failed/absent extraction
  leaves AudioMeta nil.
- updateAudioBookItem (focused payload write): an empty payload (no
  tracks, no chapters, no duration) over a populated row is a failed
  extraction — refuse the write with a loud warning and let the next
  scan retry.

audioPayloadIsEmpty() pins the empty-payload predicate (unit-tested):
the column is only ever replaced by fresh data from a real audio
extraction, never by absence of one.
2026-10-21 20:22:57 -04:00
John O'Keefe 970beee4b8 fix(pwa): add the manifest icons the manifest always referenced
manifest.json has referenced /static/icons/icon-{192,512}.png since
April, but the directory was never committed — ~100 404s per browsing
session and a broken PWA install. Rendered both sizes from the
existing favicon book glyph on the Tokyo Night palette
(#1a1b26 background, #7aa2f7 stroke).
2026-10-21 20:22:28 -04:00
John O'Keefe fa5b952c54 fix(audio-reader): valid doctype — page loaded in quirks mode
audio_reader.templ was the only template emitting <!DOCTYPE html/>
(trailing slash is a parse error), which put the audio player page in
quirks mode — document.compatMode was BackCompat while every other
page is CSS1Compat. Quirks layout rules are a standing hazard for the
player's h-screen/flex page even where it happens to render. Drop the
slash.
2026-10-21 20:21:58 -04:00
John O'Keefe ff17d14694 fix(book-detail): hero backdrop — templ double-escapes style values
templ's codegen wraps style attribute expressions in a sanitizer AND
templ.EscapeString, so any quote character — even inside templ.SafeCSS
— reached the DOM as literal &#39; text. The hero backdrop's url('...')
never parsed: no blurred cover on any book detail page, and Firefox
resolved the mangled token as a relative URL, fetching /media/& (500)
once per page load.

Fix: emit quote-free CSS — an unquoted url(...) token built through a
new cssURL() helper that percent-encodes the characters that would
break it (space, quotes, parens, backslash). Stored cover paths are
already percent-encoded URL paths, so the escaping is defensive only.
2026-10-21 20:21:33 -04:00
John O'Keefe 807899b137 fix(reader): arrows stay page turns while the progress slider holds focus
Clicking the bottom progress slider left it focused, and the reader's
keydown guard treats INPUT focus as typing — so arrow keys stepped the
range input instead of turning pages. With step="any" each press moved
~1% of the book and key repeat crossed the whole book in seconds; the
debounced progress saves then persisted every wild landing (positions
scattered from page 5 to 285 to 100%, "progress no longer saves
correctly after page turns").

Two layers:
- reader.templ: all three progress sliders blur on pointerup, so a
  mouse scrub no longer leaves focus behind.
- reader.ts handleKeydown: ArrowLeft/ArrowRight on a focused progress
  slider preventDefault, blur, and fall through to the page-turn
  handlers; every other key still belongs to the control, so a
  keyboard user who Tabbed to the slider keeps arrows for it.
2026-10-21 20:21:07 -04:00
John O'Keefe 6791d611f0 fix(vite): guard cleanStaleChunks — vp test deleted every built chunk
The cleanStaleChunks plugin lives in the shared vite config, so its
closeBundle hook also fired under `vp test` — where generateBundle never
runs, produced stayed empty, and the sweep deleted every hashed chunk
in web/static (28 files → 3). The entries still referenced their old
imports, so every page 404'd and Alpine never started: the whole reader
and media system appeared dead after merely running the test suite.

Guard: skip the sweep when produced.size === 0 — not a build run, no
build output to clean. Verified: build → 28 files, test → 28 files;
back-to-back builds stay byte-identical.
2026-10-21 20:20:41 -04:00
John O'Keefe e5315f1b94 test(web): vitest suites for markdown, filters, password rules, api, storage, toast
Test infrastructure uses the vite-plus stack: vp test (bundled Vitest 5),
jsdom environment configured in vite.config.ts, colocated web/src/*.test.ts
files that tsc type-checks, vp fmt formats, and the bundler never imports.

- markdown.test.ts pins the notes XSS defenses: parser-level html escaping,
  DOMPurify FORBID_TAGS, scheme checks, footnote rendering, html->markdown
  round-trips incl. the headingless-table rule.
- filter_builder.test.ts pins the deep-link wire contract (ruleToFlatParam),
  smart-serialization rules (isFlatable), and pruneIncomplete semantics.
- password_validation.test.ts covers the rules fetch + fallback and the
  full submit-gating matrix through the real DOM flow (debounce included).
- api.test.ts pins response/error contracts incl. the HTTP-status and
  Unknown-error fallbacks and the 404-as-null progress contract.
- storage.test.ts covers token/theme/library round-trips, the __all__
  sentinel, and the SSR cookie.
- toast.test.ts pins config completeness for every ToastType (the missing
  'warning' entry regression) and render behavior.

Found + fixed while writing these: the no-useless-escape fix in
d7d14957 had de-escaped the ']' in hasSpecialChar's character class,
closing it early — special-character validation silently stopped
matching most specials. The new password suite caught it immediately.

jsdom chosen over happy-dom after empirical comparison: DOMPurify's
sanitizer output is mangled under happy-dom (forbidden tags survive),
which would have made the security suite test nothing.

Note: AGENTS.md gains a 'Web (TypeScript) tests' section documenting the
workflow and the happy-dom gotcha.
2026-10-21 15:34:29 -04:00
John O'Keefe 86c4940426 style(toast): warning toast uses the /90 opacity variant
Release / build-and-push (push) Successful in 3m6s
Matches the error/success/info entries visually. bg-yellow-500/90 is
generated by the Tailwind scanner from this file itself (content globs
include web/src/**/*.ts), same as the sibling bg-*-500/90 classes — no
safelist entry needed.
2026-10-21 14:46:26 -04:00
John O'Keefe d7d1495753 fix(types): clear web/src type and lint debt with real types
tsc --noEmit and oxlint web/src are now both clean; vp check passes.

- htmx: one canonical Htmx interface in shims.d.ts (ajax/process/trigger
  per the htmx 2.x API). alpine.ts had a second, conflicting Window.htmx
  declaration (TS2717) — removed; bookPicker/collections lose their
  (window as any) escapes now that Window.htmx is properly typed.
- tsconfig lib ES2020 -> ES2021 + DOM.Iterable: replaceAll exists
  (escapeHtml) and for..of over querySelectorAll results is legal.
  Type-checking only — vite emits at esnext regardless.
- custom-section-builder: selectedBooks holds identity fields only;
  typed as Pick<BookInfo, ...> instead of forcing partial API shapes.
  The API-critical BookInfo contract is untouched.
- toast: real bug found by the type system — setup.ts passes 'warning'
  but getToastConfig had no entry, so the warning toast threw TypeError
  at runtime instead of displaying. Added the type + config. Uses
  bg-yellow-500 (verified present in scanned templates => generated by
  Tailwind); the one intentional behavior change in this commit.
- book-detail: instanceof narrowing replaces an as-cast; unused
  AnnotationSource import removed.
- password_validation: explicit HTMLElement | null for the checklist
  list (querySelector('ul') infers HTMLUListElement, conflicting with
  the container-as-list branch).
- stats: tooltip parsed value typed via annotation (bar=scalar,
  line={x,y}); dead getActiveRange() removed (was already tree-shaken
  out of every bundle).
- library-switcher: onSwitch honestly typed void | Promise<void>
  (series.ts passes a sync navigator).
- 20 unused catch bindings converted to optional catch binding.

Verification: fresh build is byte-reproducible; every chunk is
identical to the pre-fix build except main.js (instanceof narrowing,
regex fix, stats refactor), reader.js (destructure), and the shared
storage chunk (renamed — carries toast.ts). reader/main diffs audited
hunk-by-hunk against the intended edits.
2026-10-21 14:17:02 -04:00
John O'Keefe 77cd268f4e style: format web/src with oxfmt defaults (via vp fmt)
One-time mechanical sweep, isolated from any functional change.
- oxfmt defaults: double quotes, semicolons, 2-space indent, LF,
  printWidth 100, trailing commas, bracket spacing, arrow parens.
- vite.config.ts gains an fmt.ignorePatterns block for web/static:
  vendored/minified assets (htmx.min.js, compiled css) must never
  be reformatted.
- Verified: rebuild after the sweep produces byte-identical JS/CSS
  bundles; only sourcemaps changed (they embed sourcesContent).
2026-10-21 13:18:24 -04:00
John O'Keefe 8aeecaa0ef Experiment: migrate web build to vite-plus 1.3.0
- vp migrate rewrote scripts (vite build -> vp build), aliased vite to
  @voidzero-dev/vite-plus-core 1.3.0 with an overrides pin, and switched
  the config import to vite-plus.
- Manual one-line fix migrate missed: minify "esbuild" -> true (Vite 8
  dropped bundled esbuild; oxc minifier is the replacement).
- Widened cleanStaleChunks sweep from .js/.js.map to .css/.mjs too:
  Rolldown renames emitted css chunks, and two stale vite-7-era css
  files (annotation_layer_builder, text_layer_builder) survived the
  old js-only regex.
- Verified: both custom plugins survive; prod/dev/watch builds OK;
  two consecutive builds idempotent at 289 files; vendor/pdfjs assets
  byte-identical; entry names (main.js/reader.js) unchanged.
2026-10-21 09:27:59 -04:00
85 changed files with 5393 additions and 2230 deletions

No files matched your search

+49
View File
@@ -71,6 +71,16 @@ were name-only across ALL libraries. Fixed; do not regress.
## Tests
- **THE DOCTRINE — tests are never run against a real/production
server.** The dev DB is the test environment, and the invariant every
test must uphold is: **leave the testuser experience alone** — its
settings, scans, and reading data must be exactly as the suite found
them. The helpers exist to enforce this; if a test needs protection
the helpers don't provide, EXPAND THE HELPERS rather than hand-rolling
(2026-10-21: `setSystemSettingRestored` — snapshot-and-restore for
system_settings — exists because a hardcoded ev restore could clobber
a deliberate dev bump, and the live dev server obeys that setting on
its next poll).
- Use `setupTestServer()` from `cmd/server/tests/test_helpers_test.go`
(see the Conventions section below for the pool/cleanup rules).
- Integration tests run against the SHARED dev database: never write
@@ -113,6 +123,23 @@ were name-only across ALL libraries. Fixed; do not regress.
tested with synthetic ffprobeReport values. Recipes:
docs/contributing/development.md → Media fixtures.
## Web (TypeScript) tests
- `npm run test` (vp test) runs the vitest suite: colocated `web/src/*.test.ts`
files, jsdom environment (configured in vite.config.ts `test`). Do NOT switch
to happy-dom — DOMPurify's sanitizer output is mangled under it, which makes
the markdown security suite test nothing (verified empirically; jsdom is the
documented fallback for exactly this reason).
- Static gates: `npx tsc --noEmit`, `npx vp check web/src` (format + lint),
`npx vp fmt web/src` to apply formatting.
- Tests are never bundled (build entries are main.ts/reader.ts only). When
touching build config, re-prove it: back-to-back `npm run build:ts` runs must
produce byte-identical hashes, and no test-only strings may appear in
web/static.
- markdown.test.ts pins the notes XSS defenses (FORBID_TAGS, scheme checks,
parser-level html escaping). Loosening any assertion there is a security
decision, not cleanup.
## Timezones
- The server owns display conversion: clients send instants (RFC3339),
@@ -206,3 +233,25 @@ of a server bug: stop and flag the user.
test pools use `max_conns=1`; never create a `pgxpool` per subtest
(connection exhaustion); use `t.Cleanup()`-based helpers; table-driven
subtests over duplicate functions.
7. **Scanner re-extraction is write-after-success** — a failed/empty
extraction must NEVER clear scan-derived columns. `updateMediaItem`
(generic full-row path) preserves `existing.AudioMetadata` when the
new extraction carries no audio payload, and `updateAudioBookItem`
refuses empty payloads over populated rows (both guarded 2026-10-21
after an ev-gated re-extract NULLed every multi-file audiobook's
audio_metadata — books displayed as 1 second long and lost their
track manifests). New scan-owned column writes need the same guard.
8. **templ double-escapes style attribute values** — the codegen wraps
style exprs in a sanitizer AND `templ.EscapeString`, so ANY quote
character (even inside `templ.SafeCSS`) reaches the DOM as literal
`&#39;` text and the CSS never parses (2026-10-21: every book-detail
hero backdrop broken + Firefox fetched `/media/&` → 500 spam).
Emit quote-free CSS: unquoted `url(...)` tokens via `SafeCSS` +
`cssURL()` in book_detail.templ.
9. **`vp test` runs the vite config** — plugins with build hooks fire
in the test context too. `cleanStaleChunks` guards on
`produced.size === 0` precisely because a bare sweep deleted every
hashed chunk in `web/static` when run under vitest (2026-10-21:
`npm run test` gutted the build; every page 404'd its imports).
Any future outDir-mutating plugin needs the same "am I a build?"
guard.
+172
View File
@@ -0,0 +1,172 @@
package main
// Playback speed preferences (player plan §8): a per-user PER-BOOK
// speed plus a user-level default, resolved at the chapters endpoint
// (per-book row → users.default_audio_speed → 1.0). All speed writes
// ride the fresh per-test regular user — the shared admin account is
// never touched (AGENTS doctrine). Fixture books live in test-named
// libraries that cascade away with the library delete.
import (
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
// writeTestTrackTone renders a short sine mp3 at a GIVEN frequency so
// multiple fixture books never collide on the scanner's content dedup.
// Falls back to dummy bytes (ingest is ffprobe-optional) like
// writeTestTrack.
func writeTestTrackTone(t *testing.T, path string, hz int) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
if ffmpeg, err := exec.LookPath("ffmpeg"); err == nil {
cmd := exec.Command(ffmpeg, "-y", "-v", "error", "-f", "lavfi",
"-i", fmt.Sprintf("sine=frequency=%d:duration=0.3", hz), "-codec:a", "libmp3lame", "-b:a", "32k", path)
if out, err := cmd.CombinedOutput(); err == nil {
return
} else {
t.Logf("ffmpeg track render failed (%s), falling back to dummy bytes: %s", err, out)
}
}
require.NoError(t, os.WriteFile(path, []byte(fmt.Sprintf("ID3 dummy audio %d", hz)), 0o644))
}
func TestAudioPlaybackSpeed(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
admin, _ := moveTestAdminID(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Speed %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
writeTestTrack(t, filepath.Join(folder, "Speed Book", "01 one.mp3"))
// 552 Hz (not the shared helper's 440) so the scanner's sha-256
// dedup doesn't skip the second book as a byte-duplicate.
writeTestTrackTone(t, filepath.Join(folder, "Speed Book Two", "01 one.mp3"), 552)
scanTestFolder(t, setup, admin, folder)
// Grouping depends on track count (single-track folders ingest as
// single-file items whose path IS the file; multi-track folders get
// the folder path) — find by the library-relative path CONTAINING
// the book folder so the test holds either way.
var bookPath, book2Path string
for _, item := range listLibraryAudioItems(t, setup, libID) {
p := filepath.ToSlash(item.FilePath)
t.Logf("ingested item path: %s", p)
if strings.Contains(p, "Speed Book Two") && book2Path == "" {
book2Path = p
} else if strings.Contains(p, "Speed Book/") && bookPath == "" {
bookPath = p
}
}
require.NotEmpty(t, bookPath, "Speed Book item must exist")
require.NotEmpty(t, book2Path, "Speed Book Two item must exist")
book, found := findAudioItemByPath(t, setup, libID, bookPath)
require.True(t, found)
bookID := uuid.UUID(book.ID.Bytes)
book2, found := findAudioItemByPath(t, setup, libID, book2Path)
require.True(t, found)
book2ID := uuid.UUID(book2.ID.Bytes)
client := &http.Client{}
token := setup.RegularToken
authed := func(method, url, body string) (*http.Response, func()) {
var req *http.Request
if body == "" {
req, _ = http.NewRequest(method, url, nil)
} else {
req, _ = http.NewRequest(method, url, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
require.NoError(t, err)
return resp, func() { _ = resp.Body.Close() }
}
getSpeeds := func(itemID uuid.UUID) (playback, def float64) {
resp, closer := authed("GET", setup.Server.URL+"/api/media-items/"+itemID.String()+"/chapters", "")
defer closer()
require.Equal(t, http.StatusOK, resp.StatusCode)
var data struct {
PlaybackSpeed float64 `json:"playback_speed"`
DefaultPlaybackSpeed float64 `json:"default_playback_speed"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&data))
return data.PlaybackSpeed, data.DefaultPlaybackSpeed
}
t.Run("unset resolves to 1x", func(t *testing.T) {
playback, def := getSpeeds(bookID)
require.Equal(t, 1.0, playback)
require.Equal(t, 1.0, def)
})
t.Run("put per-book speed upserts and resolves", func(t *testing.T) {
resp, closer := authed("PUT", setup.Server.URL+"/api/media-items/"+bookID.String()+"/audio-speed", `{"speed":1.5}`)
defer closer()
require.Equal(t, http.StatusOK, resp.StatusCode)
var saved struct {
PlaybackSpeed float64 `json:"playback_speed"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&saved))
require.Equal(t, 1.5, saved.PlaybackSpeed)
playback, def := getSpeeds(bookID)
require.Equal(t, 1.5, playback)
require.Equal(t, 1.0, def) // user default untouched
})
t.Run("out of range speed is rejected", func(t *testing.T) {
for _, body := range []string{`{"speed":99}`, `{"speed":0}`, `{"speed":0.1}`} {
resp, closer := authed("PUT", setup.Server.URL+"/api/media-items/"+bookID.String()+"/audio-speed", body)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
closer()
}
// The stored speed is untouched by the rejects.
playback, _ := getSpeeds(bookID)
require.Equal(t, 1.5, playback)
})
t.Run("profile default applies to books without their own speed", func(t *testing.T) {
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", `{"default_audio_speed":1.25}`)
defer closer()
require.Equal(t, http.StatusOK, resp.StatusCode)
// Per-book row wins over the default on book one...
playback, def := getSpeeds(bookID)
require.Equal(t, 1.5, playback)
require.Equal(t, 1.25, def)
// ...book two has no row and picks the default up.
playback2, _ := getSpeeds(book2ID)
require.Equal(t, 1.25, playback2)
// GET /profile carries it for the profile form.
resp2, closer2 := authed("GET", setup.Server.URL+"/api/auth/profile", "")
defer closer2()
var profile struct {
DefaultAudioSpeed float64 `json:"default_audio_speed"`
}
require.NoError(t, json.NewDecoder(resp2.Body).Decode(&profile))
require.Equal(t, 1.25, profile.DefaultAudioSpeed)
})
t.Run("updating the default re-satisfies unset books", func(t *testing.T) {
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", `{"default_audio_speed":0.75}`)
defer closer()
require.Equal(t, http.StatusOK, resp.StatusCode)
playback2, _ := getSpeeds(book2ID)
require.Equal(t, 0.75, playback2)
playback1, _ := getSpeeds(bookID)
require.Equal(t, 1.5, playback1) // per-book pin survives
})
}
@@ -47,6 +47,24 @@ func writeTestTrack(t *testing.T, path string) {
require.NoError(t, os.WriteFile(path, []byte("ID3 dummy audio"), 0o644))
}
// writeTestTrackM4B is writeTestTrack for the m4b (AAC/ipod muxer)
// container — the audiobook format whose rescan route is under guard.
// ffmpeg needs -f ipod for the .m4b extension; falls back to dummy bytes.
func writeTestTrackM4B(t *testing.T, path string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
if ffmpeg, err := exec.LookPath("ffmpeg"); err == nil {
cmd := exec.Command(ffmpeg, "-y", "-v", "error", "-f", "lavfi",
"-i", "sine=frequency=440:duration=0.3", "-codec:a", "aac", "-b:a", "32k", "-f", "ipod", path)
if out, err := cmd.CombinedOutput(); err == nil {
return
} else {
t.Logf("ffmpeg m4b render failed (%s), falling back to dummy bytes: %s", err, out)
}
}
require.NoError(t, os.WriteFile(path, []byte("dummy m4b audio"), 0o644))
}
// audioTestLibrary builds an audiobooks-type library with a fresh temp
// folder, returning its UUID and the folder path.
func audioTestLibrary(t *testing.T, setup *TestServerSetup, name string) (pgtype.UUID, string) {
+9 -12
View File
@@ -17,11 +17,9 @@ package main
// equal → stable (second corruption persists)
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
@@ -58,14 +56,11 @@ func corruptItemColumn(t *testing.T, setup *TestServerSetup, itemID pgtype.UUID,
func setExtractorVersion(t *testing.T, setup *TestServerSetup, v int) {
t.Helper()
body, _ := json.Marshal(map[string]string{"key": "scan_extractor_version", "value": strconv.Itoa(v)})
req, _ := http.NewRequest("PUT", setup.Server.URL+"/api/system/settings", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusOK, resp.StatusCode, "settings API is the only sanctioned write path")
// Restore-to-prior (not a hardcoded value): the shared dev DB's
// setting must survive the suite exactly as the test found it — if a
// parser-fix bump was in flight, a hardcoded 1 would silently cancel
// it and the live server would stop re-extracting.
setSystemSettingRestored(t, setup, "scan_extractor_version", strconv.Itoa(v))
}
func TestExtractorVersionReextract(t *testing.T) {
@@ -123,7 +118,8 @@ func TestExtractorVersionReextract(t *testing.T) {
// re-extract once: the corruption heals and the fresh manifest
// carries ev=2.
setExtractorVersion(t, setup, 2)
t.Cleanup(func() { setExtractorVersion(t, setup, 1) })
// Prior-value restore rides inside setExtractorVersion — never a
// hardcoded 1, so a deliberate dev bump survives the suite.
scanTestFolder(t, setup, admin, folder)
item, found = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Probe Author/EV Probe Book/EV Probe Book.epub"))
require.True(t, found)
@@ -168,7 +164,8 @@ func TestExtractorVersionMultifile(t *testing.T) {
// Bump → the ev gate fires through multifileSidecarChanged → the
// editorial apply re-derives the title from the folder basename.
setExtractorVersion(t, setup, 2)
t.Cleanup(func() { setExtractorVersion(t, setup, 1) })
// Prior-value restore rides inside setExtractorVersion — never a
// hardcoded 1, so a deliberate dev bump survives the suite.
scanTestFolder(t, setup, admin, folder)
book, found = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Audio Author/EV Audio Title"))
require.True(t, found)
+2 -2
View File
@@ -32,11 +32,11 @@ import (
// the default 1 for the test's duration. Prior interrupted runs can
// leave it bumped (an early test failure aborts before its restore
// cleanup registers), and every stamp assertion in these tests assumes
// the default. The cleanup restores 1 — the repo's steady state.
// the default. Restore-to-prior rides inside setExtractorVersion: the
// suite leaves the setting exactly as it found it.
func pinExtractorVersionOne(t *testing.T, setup *TestServerSetup) {
t.Helper()
setExtractorVersion(t, setup, 1)
t.Cleanup(func() { setExtractorVersion(t, setup, 1) })
}
// forceScanTestFolder runs a FORCE scan over one folder.
@@ -0,0 +1,182 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"path/filepath"
"testing"
"time"
"bookhoard/internal/services"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/require"
)
// TestRescanMediaItemPreservesAudioMetadata pins the 2026-10-21 incident:
// a per-item rescan of a multi-file audiobook routes book-level metadata
// (sidecars/tags — AudioMeta nil) through the generic row update, whose
// unguarded write NULLed audio_metadata. A NULL payload drops every
// track/chapter and the duration, so the book displays as 1 second long
// and the audio reader loses its track manifest. Re-extraction must be
// write-after-success: no fresh audio payload from the audio chain means
// the existing column survives byte-for-byte.
func TestRescanMediaItemPreservesAudioMetadata(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
ctx := context.Background()
admin, _ := moveTestAdminID(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Guard %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
writeTestTrack(t, filepath.Join(folder, "Guard Author", "Guard Book", "01 One.mp3"))
writeTestTrack(t, filepath.Join(folder, "Guard Author", "Guard Book", "02 Two.mp3"))
scanTestFolder(t, setup, admin, folder)
rel := filepath.ToSlash("Guard Author/Guard Book")
book, found := findAudioItemByPath(t, setup, libID, rel)
require.True(t, found, "folder book must import")
audioBytes := func(t *testing.T) []byte {
t.Helper()
var raw []byte
require.NoError(t, setup.DBPool.QueryRow(ctx,
`SELECT audio_metadata FROM media_items WHERE id = $1`, book.ID).Scan(&raw))
return raw
}
before := audioBytes(t)
require.NotNil(t, before, "initial scan must populate audio_metadata")
var payload services.AudioMetadataPayload
require.NoError(t, json.Unmarshal(before, &payload), "payload must be valid JSON")
require.Greater(t, payload.DurationSeconds, 0.0, "initial scan must carry a duration")
require.Len(t, payload.Tracks, 2, "initial scan must carry the track manifest")
scanner := services.NewMediaScanner(setup.DB)
defer func() { _ = scanner.Close() }()
scanner.SetAdminID(admin)
require.NoError(t, scanner.RescanMediaItem(ctx, book.ID, false), "per-item rescan must succeed")
after := audioBytes(t)
require.NotNil(t, after, "rescan must never NULL audio_metadata (2026-10-21 incident)")
var afterPayload services.AudioMetadataPayload
require.NoError(t, json.Unmarshal(after, &afterPayload), "surviving payload must stay valid JSON")
require.Greater(t, afterPayload.DurationSeconds, 0.0, "duration must survive the rescan")
require.Len(t, afterPayload.Tracks, 2, "track manifest must survive the rescan")
}
// TestMetadataEditorSavePreservesAudioMetadata pins the editor-side half
// of the incident: PUT /api/media-items/:id (the metadata editor) writes
// the FULL row, and audio_metadata has no editor field — an unguarded
// save NULLed every audiobook's payload on a plain metadata edit.
func TestMetadataEditorSavePreservesAudioMetadata(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
ctx := context.Background()
admin, _ := moveTestAdminID(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Editor Guard %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
writeTestTrack(t, filepath.Join(folder, "Editor Author", "Editor Book", "01 One.mp3"))
writeTestTrack(t, filepath.Join(folder, "Editor Author", "Editor Book", "02 Two.mp3"))
scanTestFolder(t, setup, admin, folder)
rel := filepath.ToSlash("Editor Author/Editor Book")
book, found := findAudioItemByPath(t, setup, libID, rel)
require.True(t, found, "folder book must import")
audioBytes := func(t *testing.T) []byte {
t.Helper()
var raw []byte
require.NoError(t, setup.DBPool.QueryRow(ctx,
`SELECT audio_metadata FROM media_items WHERE id = $1`, book.ID).Scan(&raw))
return raw
}
before := audioBytes(t)
require.NotNil(t, before, "initial scan must populate audio_metadata")
// A plain metadata edit through the editor endpoint (admin token —
// the editor is admin-only), no audio fields involved.
editorSave(t, setup, book.ID, map[string]interface{}{
"title": "Editor Book Renamed",
"cover_action": "keep",
})
after := audioBytes(t)
require.NotNil(t, after, "metadata edit must never NULL audio_metadata")
var afterPayload services.AudioMetadataPayload
require.NoError(t, json.Unmarshal(after, &afterPayload))
require.Greater(t, afterPayload.DurationSeconds, 0.0, "duration must survive the edit")
require.Len(t, afterPayload.Tracks, 2, "track manifest must survive the edit")
renamed, found := findAudioItemByPath(t, setup, libID, rel)
require.True(t, found)
require.Equal(t, "Editor Book Renamed", renamed.Title, "the edit itself must still apply")
}
// TestRescanMediaItemPreservesAudioMetadataSingleFileM4B is the incident
// class verbatim: the failing books were SINGLE-FILE m4b audiobooks
// (audio_metadata without a layout key), which RescanMediaItem routes
// through the generic per-file updater — the same writer whose unguarded
// pass NULLed their payloads. The whole flow must keep the m4b's
// duration and chapters intact.
func TestRescanMediaItemPreservesAudioMetadataSingleFileM4B(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
ctx := context.Background()
admin, _ := moveTestAdminID(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test M4B Guard %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
writeTestTrackM4B(t, filepath.Join(folder, "M4B Guard Author", "M4B Guard Book.m4b"))
scanTestFolder(t, setup, admin, folder)
rel := filepath.ToSlash("M4B Guard Author/M4B Guard Book.m4b")
book, found := findAudioItemByPath(t, setup, libID, rel)
require.True(t, found, "single-file m4b must import")
audioBytes := func(t *testing.T) []byte {
t.Helper()
var raw []byte
require.NoError(t, setup.DBPool.QueryRow(ctx,
`SELECT audio_metadata FROM media_items WHERE id = $1`, book.ID).Scan(&raw))
return raw
}
before := audioBytes(t)
require.NotNil(t, before, "initial scan must populate audio_metadata")
var beforePayload services.AudioMetadataPayload
require.NoError(t, json.Unmarshal(before, &beforePayload))
require.Greater(t, beforePayload.DurationSeconds, 0.0, "m4b probe must carry a real duration")
scanner := services.NewMediaScanner(setup.DB)
defer func() { _ = scanner.Close() }()
scanner.SetAdminID(admin)
require.NoError(t, scanner.RescanMediaItem(ctx, book.ID, false))
after := audioBytes(t)
require.NotNil(t, after, "m4b rescan must never NULL audio_metadata")
var afterPayload services.AudioMetadataPayload
require.NoError(t, json.Unmarshal(after, &afterPayload))
require.Greater(t, afterPayload.DurationSeconds, 0.0, "m4b duration must survive the rescan")
}
// editorSave drives PUT /api/media-items/:id as the admin (the metadata
// editor's endpoint; admin-only per the handler).
func editorSave(t *testing.T, setup *TestServerSetup, itemID pgtype.UUID, fields map[string]interface{}) {
t.Helper()
body, _ := json.Marshal(fields)
req, _ := http.NewRequest("PUT", setup.Server.URL+"/api/media-items/"+uuid.UUID(itemID.Bytes).String(), bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusOK, resp.StatusCode, "metadata save must succeed")
}
+49
View File
@@ -25,6 +25,7 @@ import (
"github.com/go-playground/validator/v10"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/labstack/echo/v5"
@@ -181,6 +182,54 @@ func getUploadPath() string {
return "./uploads" // Host path (left side of volume mount)
}
// setSystemSettingRestored writes a system setting through the sanctioned
// API path and registers a cleanup that restores the value the row had
// BEFORE the write. The testuser-experience rule: the suite must leave
// shared settings exactly as it found them — a hardcoded restore would
// silently clobber a deliberate dev bump (e.g. scan_extractor_version
// pinned mid-deploy), and the live dev server obeys this setting on its
// next poll, so what tests write here reaches real scans.
//
// Writes ride the settings API (never raw SQL) because the registry
// caches values and only the API path reloads it. If the row did not
// exist before the write, the test value is left in place and the test
// log says so — there is no sanctioned delete; settings rows that
// matter (scan_extractor_version) always exist.
func setSystemSettingRestored(t *testing.T, setup *TestServerSetup, key, value string) {
t.Helper()
var prior *string
err := setup.DBPool.QueryRow(context.Background(),
`SELECT setting_value FROM system_settings WHERE setting_key = $1`, key).Scan(&prior)
if err != nil && err != pgx.ErrNoRows {
require.NoError(t, err, "failed to read prior value of %s", key)
}
setSettingViaAPI(t, setup, key, value)
if prior == nil {
t.Logf("setSystemSettingRestored: %s had no prior row; leaving %q in place after the test", key, value)
return
}
priorValue := *prior
t.Cleanup(func() {
setSettingViaAPI(t, setup, key, priorValue)
})
}
// setSettingViaAPI PUTs one setting through /api/system/settings — the
// only sanctioned write path (the settings registry reloads here).
func setSettingViaAPI(t *testing.T, setup *TestServerSetup, key, value string) {
t.Helper()
body, _ := json.Marshal(map[string]string{"key": key, "value": value})
req, _ := http.NewRequest("PUT", setup.Server.URL+"/api/system/settings", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusOK, resp.StatusCode, "settings API is the only sanctioned write path")
}
// getCachePath returns the appropriate cache path based on runtime environment
func getCachePath() string {
// Check for explicit override first
+23
View File
@@ -37,6 +37,11 @@ CREATE TABLE IF NOT EXISTS users (
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- User-level default audiobook playback speed (player plan §8): books
-- without a per-book audio_playback_speed row pick this up. REAL
-- mirrors the per-book table's column.
ALTER TABLE users ADD COLUMN IF NOT EXISTS default_audio_speed REAL NOT NULL DEFAULT 1.0;
-- Create system_settings table
CREATE TABLE IF NOT EXISTS system_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -1563,6 +1568,24 @@ CREATE TABLE IF NOT EXISTS reading_speed (
CREATE INDEX IF NOT EXISTS idx_reading_speed_user ON reading_speed(user_id);
CREATE INDEX IF NOT EXISTS idx_reading_speed_item ON reading_speed(media_item_id);
-- Per-user PER-BOOK playback speed preference (player plan §8): a
-- preference, NOT a measurement (reading_speed holds measured rates),
-- and NOT a reading_progress column (a speed tap must not create a
-- "currently reading" row). Resolution lives at the chapters endpoint:
-- per-book row → users.default_audio_speed → 1.0.
CREATE TABLE IF NOT EXISTS audio_playback_speed (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
speed REAL NOT NULL DEFAULT 1.0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, media_item_id)
);
CREATE INDEX IF NOT EXISTS idx_audio_playback_speed_user ON audio_playback_speed(user_id);
CREATE INDEX IF NOT EXISTS idx_audio_playback_speed_item ON audio_playback_speed(media_item_id);
-- Dictionary cache (for offline use)
CREATE TABLE IF NOT EXISTS dictionary_cache (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+22 -12
View File
@@ -8,6 +8,15 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
type AudioPlaybackSpeed struct {
ID pgtype.UUID `db:"id" json:"id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
Speed float32 `db:"speed" json:"speed"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
type CollectionItems struct {
ID pgtype.UUID `db:"id" json:"id"`
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
@@ -575,16 +584,17 @@ type UserDashboardPreferencesGlobal struct {
}
type Users struct {
ID pgtype.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"`
Username string `db:"username" json:"username"`
PasswordHash string `db:"password_hash" json:"password_hash"`
FirstName pgtype.Text `db:"first_name" json:"first_name"`
LastName pgtype.Text `db:"last_name" json:"last_name"`
Role string `db:"role" json:"role"`
Theme pgtype.Text `db:"theme" json:"theme"`
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
Timezone pgtype.Text `db:"timezone" json:"timezone"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
ID pgtype.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"`
Username string `db:"username" json:"username"`
PasswordHash string `db:"password_hash" json:"password_hash"`
FirstName pgtype.Text `db:"first_name" json:"first_name"`
LastName pgtype.Text `db:"last_name" json:"last_name"`
Role string `db:"role" json:"role"`
Theme pgtype.Text `db:"theme" json:"theme"`
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
Timezone pgtype.Text `db:"timezone" json:"timezone"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
}
+4
View File
@@ -202,6 +202,8 @@ type Querier interface {
GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error)
GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error)
GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error)
// Per-book playback speed preference (player plan §8).
GetAudioPlaybackSpeed(ctx context.Context, arg GetAudioPlaybackSpeedParams) (float32, error)
// Lifetime session rows for ONE book (the book-detail stats card).
GetBookReadingSessions(ctx context.Context, arg GetBookReadingSessionsParams) ([]GetBookReadingSessionsRow, error)
GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error)
@@ -696,12 +698,14 @@ type Querier interface {
UpdateSystemSetting(ctx context.Context, arg UpdateSystemSettingParams) error
// Update universal progress
UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error)
UpdateUserDefaultAudioSpeed(ctx context.Context, arg UpdateUserDefaultAudioSpeedParams) error
UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDevicesParams) (Users, error)
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UpdateUserRoleRow, error)
UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error
UpdateUserTimezone(ctx context.Context, arg UpdateUserTimezoneParams) error
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
UpsertAudioPlaybackSpeed(ctx context.Context, arg UpsertAudioPlaybackSpeedParams) (float32, error)
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
UpsertGlobalDashboardPreferences(ctx context.Context, arg UpsertGlobalDashboardPreferencesParams) (UserDashboardPreferencesGlobal, error)
UpsertMediaReview(ctx context.Context, arg UpsertMediaReviewParams) (MediaReviews, error)
+71 -13
View File
@@ -3761,6 +3761,25 @@ func (q *Queries) GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsF
return items, nil
}
const GetAudioPlaybackSpeed = `-- name: GetAudioPlaybackSpeed :one
SELECT speed FROM audio_playback_speed
WHERE user_id = $1 AND media_item_id = $2
`
type GetAudioPlaybackSpeedParams struct {
UserID pgtype.UUID `db:"user_id" json:"user_id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
}
// Per-book playback speed preference (player plan §8).
func (q *Queries) GetAudioPlaybackSpeed(ctx context.Context, arg GetAudioPlaybackSpeedParams) (float32, error) {
row := q.db.QueryRow(ctx, GetAudioPlaybackSpeed, arg.UserID, arg.MediaItemID)
var speed float32
err := row.Scan(&speed)
return speed, err
}
const GetBookReadingSessions = `-- name: GetBookReadingSessions :many
SELECT
rh.id, rh.device_id, rh.progress_percentage,
@@ -9922,24 +9941,26 @@ SELECT
u.created_at,
u.updated_at,
u.timezone,
u.default_audio_speed,
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
FROM users u
WHERE u.id = $1
`
type GetUserRow struct {
ID pgtype.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"`
Username string `db:"username" json:"username"`
Theme pgtype.Text `db:"theme" json:"theme"`
FirstName pgtype.Text `db:"first_name" json:"first_name"`
LastName pgtype.Text `db:"last_name" json:"last_name"`
Role string `db:"role" json:"role"`
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
Timezone pgtype.Text `db:"timezone" json:"timezone"`
DeviceCount int64 `db:"device_count" json:"device_count"`
ID pgtype.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"`
Username string `db:"username" json:"username"`
Theme pgtype.Text `db:"theme" json:"theme"`
FirstName pgtype.Text `db:"first_name" json:"first_name"`
LastName pgtype.Text `db:"last_name" json:"last_name"`
Role string `db:"role" json:"role"`
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
Timezone pgtype.Text `db:"timezone" json:"timezone"`
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
DeviceCount int64 `db:"device_count" json:"device_count"`
}
func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error) {
@@ -9957,6 +9978,7 @@ func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, erro
&i.CreatedAt,
&i.UpdatedAt,
&i.Timezone,
&i.DefaultAudioSpeed,
&i.DeviceCount,
)
return i, err
@@ -17200,9 +17222,23 @@ func (q *Queries) UpdateUniversalProgress(ctx context.Context, arg UpdateUnivers
return i, err
}
const UpdateUserDefaultAudioSpeed = `-- name: UpdateUserDefaultAudioSpeed :exec
UPDATE users SET default_audio_speed = $2, updated_at = NOW() WHERE id = $1
`
type UpdateUserDefaultAudioSpeedParams struct {
ID pgtype.UUID `db:"id" json:"id"`
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
}
func (q *Queries) UpdateUserDefaultAudioSpeed(ctx context.Context, arg UpdateUserDefaultAudioSpeedParams) error {
_, err := q.db.Exec(ctx, UpdateUserDefaultAudioSpeed, arg.ID, arg.DefaultAudioSpeed)
return err
}
const UpdateUserMaxDevices = `-- name: UpdateUserMaxDevices :one
UPDATE users SET max_devices = $2, updated_at = NOW() WHERE id = $1
RETURNING id, email, username, password_hash, first_name, last_name, role, theme, max_devices, created_at, timezone, updated_at
RETURNING id, email, username, password_hash, first_name, last_name, role, theme, max_devices, created_at, timezone, updated_at, default_audio_speed
`
type UpdateUserMaxDevicesParams struct {
@@ -17226,6 +17262,7 @@ func (q *Queries) UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDev
&i.CreatedAt,
&i.Timezone,
&i.UpdatedAt,
&i.DefaultAudioSpeed,
)
return i, err
}
@@ -17316,6 +17353,27 @@ func (q *Queries) UpdateUsername(ctx context.Context, arg UpdateUsernameParams)
return err
}
const UpsertAudioPlaybackSpeed = `-- name: UpsertAudioPlaybackSpeed :one
INSERT INTO audio_playback_speed (user_id, media_item_id, speed)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, media_item_id)
DO UPDATE SET speed = EXCLUDED.speed, updated_at = NOW()
RETURNING speed
`
type UpsertAudioPlaybackSpeedParams struct {
UserID pgtype.UUID `db:"user_id" json:"user_id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
Speed float32 `db:"speed" json:"speed"`
}
func (q *Queries) UpsertAudioPlaybackSpeed(ctx context.Context, arg UpsertAudioPlaybackSpeedParams) (float32, error) {
row := q.db.QueryRow(ctx, UpsertAudioPlaybackSpeed, arg.UserID, arg.MediaItemID, arg.Speed)
var speed float32
err := row.Scan(&speed)
return speed, err
}
const UpsertDashboardPreferences = `-- name: UpsertDashboardPreferences :one
INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_collections, collection_order, items_per_section)
VALUES ($1, $2, $3, $4, $5)
+17
View File
@@ -28,6 +28,7 @@ SELECT
u.created_at,
u.updated_at,
u.timezone,
u.default_audio_speed,
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
FROM users u
WHERE u.id = $1;
@@ -511,6 +512,22 @@ RETURNING id, email, username, role;
-- name: UpdateUserTimezone :exec
UPDATE users SET timezone = $2, updated_at = NOW() WHERE id = $1;
-- name: UpdateUserDefaultAudioSpeed :exec
UPDATE users SET default_audio_speed = $2, updated_at = NOW() WHERE id = $1;
-- Per-book playback speed preference (player plan §8).
-- name: GetAudioPlaybackSpeed :one
SELECT speed FROM audio_playback_speed
WHERE user_id = $1 AND media_item_id = $2;
-- name: UpsertAudioPlaybackSpeed :one
INSERT INTO audio_playback_speed (user_id, media_item_id, speed)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, media_item_id)
DO UPDATE SET speed = EXCLUDED.speed, updated_at = NOW()
RETURNING speed;
-- name: GetSystemTimezone :one
SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone';
+32 -7
View File
@@ -11,6 +11,7 @@ import (
"errors"
"fmt"
"html/template"
"math"
"net/http"
"os"
"path/filepath"
@@ -120,6 +121,8 @@ type UserProfile struct {
LastName string `json:"last_name"`
Role string `json:"role"`
Theme string `json:"theme,omitempty"`
// User-level default audiobook playback speed (plan §8); 0 omits.
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty"`
}
type UpdateProfileRequest struct {
@@ -129,6 +132,9 @@ type UpdateProfileRequest struct {
LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
// Per the SPEEDS list bounds; the profile select always sends it,
// JSON clients omit it (0 = untouched).
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty" form:"default_audio_speed" validate:"omitempty,min=0.25,max=4"`
}
type AdminUpdateUserRequest struct {
@@ -139,6 +145,8 @@ type AdminUpdateUserRequest struct {
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
Role string `json:"role,omitempty" form:"role" validate:"omitempty,oneof=user admin"`
// Same semantics as UpdateProfileRequest.DefaultAudioSpeed.
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty" form:"default_audio_speed" validate:"omitempty,min=0.25,max=4"`
}
// Register handles POST /api/auth/register
@@ -538,13 +546,14 @@ func (h *AuthHandler) GetProfile(c *echo.Context) error {
lastName = user.LastName.String
}
return c.JSON(http.StatusOK, UserProfile{
ID: uuid.UUID(user.ID.Bytes).String(),
Email: user.Email,
Username: user.Username,
FirstName: firstName,
LastName: lastName,
Role: user.Role,
Theme: user.Theme.String,
ID: uuid.UUID(user.ID.Bytes).String(),
Email: user.Email,
Username: user.Username,
FirstName: firstName,
LastName: lastName,
Role: user.Role,
Theme: user.Theme.String,
DefaultAudioSpeed: float64(user.DefaultAudioSpeed),
})
}
@@ -693,6 +702,22 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
}
}
// Update the default audiobook playback speed (plan §8). The form
// select always sends it; JSON clients that omit it decode as 0 and
// leave the column untouched.
if req.DefaultAudioSpeed != 0 {
if req.DefaultAudioSpeed < 0.25 || req.DefaultAudioSpeed > 4 || math.IsNaN(req.DefaultAudioSpeed) || math.IsInf(req.DefaultAudioSpeed, 0) {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "default playback speed out of range"})
}
err := h.db.UpdateUserDefaultAudioSpeed(c.Request().Context(), database.UpdateUserDefaultAudioSpeedParams{
ID: targetUserUUID,
DefaultAudioSpeed: float32(req.DefaultAudioSpeed),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
// Browser path (htmx modal + profile page): refresh so every rendered
// value (timezone select, table rows, header) reflects the save and the
// modal closes. A bare JSON body swapped into #profile-result read as
+95 -6
View File
@@ -12,6 +12,7 @@ import (
"fmt"
"io"
"log"
"math"
"mime/multipart"
"net/http"
"net/url"
@@ -1610,6 +1611,14 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
Summary: pgtype.Text{String: req.Summary, Valid: req.Summary != ""},
}
// audio_metadata is scan-owned (track manifest, chapters, duration)
// and has no editor field: the full-row update writes it
// unconditionally, so an unguarded save NULLed every audiobook's
// payload — books dropped back to "1 second long" on a plain
// metadata edit (2026-10-21 incident class). Write-after-success:
// carry the existing bytes; only the audio chain replaces them.
params.AudioMetadata = existing.AudioMetadata
// Fields the user actually changed become overrides so future scans keep
// the custom values. An explicit cover upload/removal always overrides.
overrides := utils.DetectMetadataOverrides(params, existing)
@@ -2982,16 +2991,96 @@ func (mh *MediaHandler) GetAudioChapters(c *echo.Context) error {
})
}
// Playback speed (plan §8): resolve per-book row → user default →
// 1.0 IN the payload so the player applies it with zero extra round
// trips; default_playback_speed powers the dropdown's "(default)"
// marker. Speed is a preference, so this rides the authenticated
// user — anonymous readers just get 1.0.
playbackSpeed, defaultSpeed := 1.0, 1.0
if userID, ok := c.Get("user_id").(string); ok {
if userUUID, err := uuid.Parse(userID); err == nil {
userRow, err := mh.db.GetUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if err == nil && userRow.DefaultAudioSpeed > 0 {
defaultSpeed = float64(userRow.DefaultAudioSpeed)
}
bookSpeed, err := mh.db.GetAudioPlaybackSpeed(c.Request().Context(), database.GetAudioPlaybackSpeedParams{
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
})
if err == nil && bookSpeed > 0 {
playbackSpeed = float64(bookSpeed)
} else {
playbackSpeed = defaultSpeed
}
}
}
return c.JSON(http.StatusOK, map[string]interface{}{
"media_item_id": item.ID,
"chapters": payload.Chapters,
"duration_seconds": payload.DurationSeconds,
"codec_profile": payload.CodecProfile,
"layout": payload.Layout,
"tracks": tracks,
"media_item_id": item.ID,
"chapters": payload.Chapters,
"duration_seconds": payload.DurationSeconds,
"codec_profile": payload.CodecProfile,
"layout": payload.Layout,
"tracks": tracks,
"playback_speed": playbackSpeed,
"default_playback_speed": defaultSpeed,
})
}
// SetAudioSpeed handles PUT /api/media-items/:id/audio-speed — the
// per-user per-book playback speed upsert (plan §8). The client SPEEDS
// list is the real gate; the server sanity-checks the range.
func (mh *MediaHandler) SetAudioSpeed(c *echo.Context) error { mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
userID, ok := c.Get("user_id").(string)
if !ok || userID == "" {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "authentication required"})
}
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid user"})
}
var req struct {
Speed float64 `json:"speed"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if req.Speed < 0.25 || req.Speed > 4 || math.IsNaN(req.Speed) || math.IsInf(req.Speed, 0) {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "speed out of range"})
}
// The book must exist (and be visible): a preference for a hidden
// library must not be writable — family-lens rule.
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
}
if item.LibraryID.Valid {
visibleSet, _, err := mh.visibleLibrarySet(c)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check library access"})
}
if !visibleSet[uuid.UUID(item.LibraryID.Bytes).String()] {
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
}
}
speed, err := mh.db.UpsertAudioPlaybackSpeed(c.Request().Context(), database.UpsertAudioPlaybackSpeedParams{
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
Speed: float32(req.Speed),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save playback speed"})
}
return c.JSON(http.StatusOK, map[string]float64{"playback_speed": float64(speed)})
}
// GetAudioTrack handles GET /api/media-items/:id/audio/:trackIndex — the
// per-track byte stream for multi-file audiobooks (folder books serve the
// real file; zip books extract the entry in place). Library visibility is
+11 -10
View File
@@ -56,16 +56,17 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
}
return templates.User{
ID: userID,
Email: userEmail,
Username: userUsername,
Role: userRole,
Theme: userTheme,
Token: token,
Timezone: userTimezone,
FirstName: utils.PgTextString(userDB.FirstName),
LastName: utils.PgTextString(userDB.LastName),
CreatedAt: userDB.CreatedAt.Time,
ID: userID,
Email: userEmail,
Username: userUsername,
Role: userRole,
Theme: userTheme,
Token: token,
Timezone: userTimezone,
FirstName: utils.PgTextString(userDB.FirstName),
LastName: utils.PgTextString(userDB.LastName),
CreatedAt: userDB.CreatedAt.Time,
DefaultAudioSpeed: float64(userDB.DefaultAudioSpeed),
}, nil
}
+1
View File
@@ -22,6 +22,7 @@ func registerMediaRoutes(cfg *Config) {
// books stream through the existing /uploads Range-serving path;
// multi-file books additionally expose per-track streaming.
protected.GET("/media-items/:id/chapters", cfg.MediaHandler.GetAudioChapters)
protected.PUT("/media-items/:id/audio-speed", cfg.MediaHandler.SetAudioSpeed)
protected.GET("/media-items/:id/audio/:trackIndex", cfg.MediaHandler.GetAudioTrack)
// Media rating routes (all authenticated users)
+13 -1
View File
@@ -4049,6 +4049,18 @@ func (s *MediaScanner) updateMediaItemWithMetadata(ctx context.Context, existing
if metadata.AlternateInfo != "" {
alternateInfoBytes = []byte(metadata.AlternateInfo)
}
// A re-extraction that produced no audio payload must never wipe
// scan-derived audio_metadata (tracks/chapters/duration): the
// ev-gated generic pass runs for multi-file books too, where
// extractMetadata leaves AudioMeta nil — writing NULL here deleted
// every folder book's payload and broke playback (2026-10-21
// incident: all multi-file audiobooks showed as 1 second long). The
// audio chain (UpdateMediaItemAudioBookPayload) owns this column;
// without fresh data from THAT chain, keep what the row has.
audioMetadata := marshalAudioMetadata(metadata.AudioMeta)
if audioMetadata == nil {
audioMetadata = existing.AudioMetadata
}
params := database.UpdateMediaItemParams{
ID: existing.ID,
Title: metadata.Title,
@@ -4089,7 +4101,7 @@ func (s *MediaScanner) updateMediaItemWithMetadata(ctx context.Context, existing
AlternateInfo: alternateInfoBytes,
ScanInformation: pgtype.Text{String: metadata.ScanInformation, Valid: metadata.ScanInformation != ""},
Summary: pgtype.Text{String: metadata.Summary, Valid: metadata.Summary != ""},
AudioMetadata: marshalAudioMetadata(metadata.AudioMeta),
AudioMetadata: audioMetadata,
}
// Keep user-customized fields, and keep the override set itself intact.
@@ -0,0 +1,33 @@
package services
import (
"testing"
)
// TestAudioPayloadIsEmpty pins the write-after-success guard on
// updateAudioBookItem (2026-10-21 incident: multi-file audiobooks
// surfaced as 1 second long because a failed re-extraction overwrote a
// populated audio_metadata). An empty payload — nil, or no tracks, no
// chapters, no duration — is the signature of a failed extraction and
// must never replace existing audio data.
func TestAudioPayloadIsEmpty(t *testing.T) {
require := func(cond bool, msg string) {
if !cond {
t.Fatal(msg)
}
}
require(audioPayloadIsEmpty(nil), "nil payload must read as empty")
require(audioPayloadIsEmpty(&AudioMetadataPayload{}), "zero-value payload must read as empty")
require(audioPayloadIsEmpty(&AudioMetadataPayload{DurationSeconds: 0}), "zero-duration payload must read as empty")
// A real book always carries at least one of the audio facts.
require(!audioPayloadIsEmpty(&AudioMetadataPayload{DurationSeconds: 302.6}),
"duration-only payload (single-track book) is not empty")
require(!audioPayloadIsEmpty(&AudioMetadataPayload{
Tracks: []AudioTrack{{Index: 0, Path: "01.mp3"}},
}), "track manifest is not empty")
require(!audioPayloadIsEmpty(&AudioMetadataPayload{
Chapters: []AudioChapter{{Index: 0, Title: "Chapter 1"}},
}), "chapters are not empty")
}
@@ -1015,6 +1015,16 @@ func (s *MediaScanner) processAudioFolderBook(ctx context.Context, rootDir strin
// rescan saw the track set change (or the admin forced a rescan). Only
// scan-derived fields move — editorial columns keep admin edits.
func (s *MediaScanner) updateAudioBookItem(ctx context.Context, existing database.MediaItems, payload *AudioMetadataPayload, mimeType string, totalSize int64, chapterCount int) error {
// Write-after-success discipline (2026-10-21 incident: multi-file
// audiobooks surfaced as 1 second long because a re-extraction wrote
// a NULL/empty audio_metadata over a populated one). An empty payload
// is a FAILED extraction, not a book with no audio: when the row
// already carries audio data, keep it and skip the write — the next
// scan retries, and the reader keeps its manifest in the meantime.
if audioPayloadIsEmpty(payload) && existing.AudioMetadata != nil {
fmt.Printf("[AUDIO] WARNING: re-extraction of '%s' produced an empty audio payload; keeping the existing audio_metadata\n", existing.Title)
return nil
}
if _, err := s.db.UpdateMediaItemAudioBookPayload(ctx, database.UpdateMediaItemAudioBookPayloadParams{
ID: existing.ID,
FileSize: pgtype.Int8{Int64: totalSize, Valid: true},
@@ -1028,6 +1038,14 @@ func (s *MediaScanner) updateAudioBookItem(ctx context.Context, existing databas
return nil
}
// audioPayloadIsEmpty reports whether a payload carries no audio facts
// at all — no tracks, no chapters, no duration. Such a payload is the
// signature of a failed/absent extraction, never of a real book.
func audioPayloadIsEmpty(payload *AudioMetadataPayload) bool {
return payload == nil ||
(len(payload.Tracks) == 0 && len(payload.Chapters) == 0 && payload.DurationSeconds <= 0)
}
// multifileSidecarChanged runs the manifest ladder for a folder/zip book
// (faceted plan Phase 6): a size-stable multi-file book is changed by
// EDITING its sidecars — the track-count/size fingerprint never saw it,
+11 -5
View File
@@ -5,11 +5,12 @@
"scripts": {
"build:css": "tailwindcss -i ./web/static/input.css -o ./web/static/style.css --watch",
"build:css:prod": "tailwindcss -i ./web/static/input.css -o ./web/static/style.css --minify",
"build:ts": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vite build",
"build:ts:dev": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vite build --mode development",
"build:ts:watch": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vite build --watch",
"build:ts": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vp build",
"build:ts:dev": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vp build --mode development",
"build:ts:watch": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vp build --watch",
"build": "npm run build:ts && npm run build:css:prod",
"dev": "npm run build:ts:dev && npm run build:css"
"dev": "npm run build:ts:dev && npm run build:css",
"test": "vp test web/src"
},
"dependencies": {
"@bookhoard/foliate-js": "git+https://git.linuxhg.com/Bookhoard/foliate-js.git#f622841",
@@ -31,9 +32,14 @@
"@types/markdown-it": "^14.2.0",
"@types/turndown": "^5.0.6",
"autoprefixer": "^10.4.27",
"jsdom": "^30.1.3",
"playwright": "^1.64.1",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"vite": "^7.3.1"
"vite": "npm:@voidzero-dev/vite-plus-core@1.3.0",
"vite-plus": "1.3.0"
},
"overrides": {
"vite": "npm:@voidzero-dev/vite-plus-core@1.3.0"
}
}
+282 -19
View File
@@ -10,7 +10,7 @@ package templates
import "fmt"
templ AudioReader(user User, metadata ReaderMetadata) {
<!DOCTYPE html/>
<!DOCTYPE html>
<html lang="en">
<head>
<title>{ "Listening · " + metadata.Title } · Bookhoard</title>
@@ -18,6 +18,17 @@ templ AudioReader(user User, metadata ReaderMetadata) {
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link href="/static/style.css" rel="stylesheet"/>
<script type="module" src="/static/main.js"></script>
<style>
/* Layered scrubber (plan §1): the native range stays for
a11y (focus, arrow keys, aria) but its track is
transparent so the tick/buffer/fill layers beneath show
through. Static page CSS — templ owns the look. */
#audio-scrub { -webkit-appearance: none; appearance: none; background: transparent; cursor: pointer; }
#audio-scrub::-webkit-slider-runnable-track { background: transparent; border: none; }
#audio-scrub::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; height: 14px; width: 14px; border-radius: 9999px; background: var(--accent); border: 2px solid var(--bg-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.4); }
#audio-scrub::-moz-range-track { background: transparent; border: none; }
#audio-scrub::-moz-range-thumb { height: 14px; width: 14px; border-radius: 9999px; background: var(--accent); border: 2px solid var(--bg-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.4); }
</style>
</head>
<body
x-data="audioReader"
@@ -75,6 +86,86 @@ templ AudioReader(user User, metadata ReaderMetadata) {
<path d="m6 9 6 6 6-6"></path>
</svg>
</button>
<button
type="button"
@click="helpOpen = !helpOpen"
class="rounded-full p-2 hover:bg-surface-hover transition-colors"
style="color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);"
aria-label="Shortcuts &amp; interactions"
title="Shortcuts &amp; interactions (?)"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="2" y="4" width="20" height="16" rx="2"></rect>
<path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8"></path>
</svg>
</button>
<div class="relative" @click.outside="settingsOpen = false">
<button
type="button"
@click="settingsOpen = !settingsOpen"
class="rounded-full p-2 hover:bg-surface-hover transition-colors"
:class="settingsOpen ? 'opacity-100' : 'opacity-90'"
style="color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);"
aria-label="Player settings"
title="Settings"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</button>
<div
x-show="settingsOpen"
x-cloak
x-transition
class="absolute right-0 top-full mt-2 w-72 rounded-xl shadow-2xl p-3 z-50"
style="background-color: var(--bg-secondary); border: 1px solid var(--border);"
>
<div class="flex items-center justify-between mb-2 px-1">
<span class="text-sm font-semibold" style="color: var(--text-primary);">Player settings</span>
<button type="button" @click="settingsOpen = false" class="rounded-full p-1.5 hover:bg-surface-hover" style="color: var(--text-secondary);" aria-label="Close settings">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</div>
<!-- Option rows: autoplay first, panel structured for more -->
<div class="flex items-center justify-between gap-3 px-1 py-2 rounded-lg">
<span class="text-sm" style="color: var(--text-secondary);">Autoplay on open</span>
<button
type="button"
role="switch"
:aria-checked="autoplayOn ? 'true' : 'false'"
@click="toggleAutoplay()"
class="relative h-5 w-9 rounded-full transition-colors shrink-0"
:style="autoplayOn ? 'background-color: var(--accent);' : 'background-color: var(--surface-hover); border: 1px solid var(--border);'"
aria-label="Autoplay on open"
>
<span class="absolute top-0.5 left-0.5 h-4 w-4 rounded-full transition-transform" :class="autoplayOn ? 'translate-x-4' : ''" style="background-color: var(--text-primary);"></span>
</button>
</div>
<!-- §12: default speed — always visible (unlike the
Speed ▾ footer, which hides when the current rate
IS the default), same setDefaultSpeed PUT -->
<div class="flex items-center justify-between gap-3 px-1 py-2 rounded-lg">
<span class="text-sm" style="color: var(--text-secondary);">Default playback speed</span>
<select
class="text-xs rounded-md px-1.5 py-1"
style="background-color: var(--surface-hover); color: var(--text-primary); border: 1px solid var(--border);"
aria-label="Default playback speed"
@change="setDefaultSpeed(Number($event.target.value))"
>
for _, rate := range SpeedOptions {
<option
value={ fmt.Sprintf("%g", rate) }
:selected={ fmt.Sprintf("Number(defaultSpeed) === %g", rate) }
>{ fmt.Sprintf("%g×", rate) }</option>
}
</select>
</div>
</div>
</div>
</span>
</div>
@@ -101,21 +192,96 @@ templ AudioReader(user User, metadata ReaderMetadata) {
<!-- Scrubber + transports -->
<div class="w-full max-w-xl flex flex-col gap-2">
<div class="flex items-center gap-2 text-xs tabular-nums" style="color: var(--text-secondary);">
<span x-text="fmtTime(currentTime)">0:00</span>
<input
id="audio-scrub"
type="range"
min="0"
step="0.1"
:max="chaptersDuration() || 1"
:value="scrubbing ? scrubValue : currentTime"
class="flex-1 accent-current"
@input="scrubbing = true; scrubValue = Number($event.target.value)"
@pointerup="seekTo(scrubValue); scrubbing = false"
@change="seekTo(scrubValue); scrubbing = false"
style="z-index: 20;"
/>
<span x-text="fmtTime(chaptersDuration() || 1)">0:00</span>
<span class="inline-block min-w-[4.75rem]" x-text="fmtTime(scrubbing ? scrubValue : currentTime)">0:00</span>
<div
id="scrub-area"
class="relative flex-1 h-6 flex items-center cursor-pointer"
style="touch-action: none;"
@pointerdown="scrubPointerDown($event)"
@pointermove="scrubPointerMove($event)"
@pointerup="scrubPointerUp($event)"
@pointercancel="scrubPointerUp($event)"
@pointerleave="scrubPointerLeave()"
@wheel.prevent="onScrubWheel($event)"
>
<!-- Base track -->
<div class="absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none" style="background-color: var(--surface-hover);"></div>
<!-- Elapsed fill -->
<div class="absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none" :style="'left:0;width:' + scrubFillPct() + '%;background-color:var(--accent);'"></div>
<!-- Buffered span: the ACTIVE track's element buffer only -->
<template x-if="bufferSpan()">
<div class="absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none" :style="'left:' + bufferSpan().left + '%;width:' + bufferSpan().width + '%;background-color:var(--accent-muted);'"></div>
</template>
<!-- Chapter ticks: density-decimated, current chapter tinted -->
<div id="scrub-ticks" class="absolute inset-0 pointer-events-none">
<template x-for="idx in tickIndices" :key="'tick' + idx">
<div
class="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-2 w-0.5 rounded-full"
:class="currentChapterIdx === idx ? 'opacity-100' : 'opacity-40'"
:style="'left:' + (chapters[idx].start / (chaptersDuration() || 1) * 100) + '%;background-color:' + (currentChapterIdx === idx ? 'var(--accent)' : 'var(--text-secondary)') + ';'"
></div>
</template>
</div>
<!-- §9 magnet: the line a click would land on (hover
inside the pixel radius locks the tooltip here) -->
<div
x-show="hoverSnapped && !scrubbing"
x-cloak
class="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-4 w-px rounded-full pointer-events-none"
:style="'left:' + hoverPct + '%;background-color:var(--accent);'"
></div>
<input
id="audio-scrub"
type="range"
min="0"
step="0.1"
:max="chaptersDuration() || 1"
:value="scrubbing ? scrubValue : currentTime"
class="relative w-full"
@input="scrubbing = true; scrubValue = Number($event.target.value)"
@change="seekTo(scrubValue); scrubbing = false"
aria-label="Seek position"
/>
<!-- Hover tooltip: time + chapter under the pointer -->
<div
x-show="hoverSeconds !== null && !scrubbing"
x-cloak
class="absolute bottom-full mb-1 -translate-x-1/2 whitespace-nowrap rounded-md px-2 py-1 text-[11px] shadow-lg pointer-events-none"
:style="'left:' + hoverPct + '%;background-color:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);'"
>
<span x-text="hoverSeconds !== null ? fmtTime(hoverSeconds) : ''"></span>
<span style="color: var(--text-secondary);" x-text="hoverSeconds !== null ? ' — ' + chapterTitleAt(hoverSeconds) : ''"></span>
</div>
<!-- Precision rail (§9): Shift+drag on mouse, or a touch
drag pulled off the bar — ±5 min or tighter, free
mapping inside. Edge labels self-document the window
(floats over the transports, page never shifts) -->
<div
x-show="railVisible"
x-cloak
class="absolute top-full mt-1 inset-x-0 h-12 rounded-lg shadow-lg p-1.5 pointer-events-none"
:style="'background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;' + (railLiftY ? ' transform: translateY(' + railLiftY + 'px);' : '')"
>
<div class="relative h-full">
<div class="absolute inset-x-0 top-1/2 h-px" style="background-color: var(--border);"></div>
<template x-for="tick in railTickList" :key="'rtick' + tick.start">
<div class="absolute top-1 bottom-1 w-px" :style="'left:' + ((tick.start - railFrom) / Math.max(1, railTo - railFrom) * 100) + '%;background-color:var(--text-secondary);opacity:0.5;'"></div>
</template>
<div class="absolute top-0 bottom-0 w-0.5 -translate-x-1/2 rounded-full" :style="'left:' + railMarkerPct() + '%;background-color:var(--accent);'"></div>
<div class="absolute left-1 top-0.5 text-[10px] tabular-nums whitespace-nowrap" style="color: var(--text-primary);" x-text="fmtTime(scrubValue) + ' — ' + chapterTitleAt(scrubValue)"></div>
<div class="absolute left-1 bottom-0.5 text-[9px] tabular-nums" style="color: var(--text-secondary);" x-text="fmtTime(railFrom)"></div>
<div class="absolute right-1 bottom-0.5 text-[9px] tabular-nums" style="color: var(--text-secondary);" x-text="fmtTime(railTo)"></div>
</div>
</div>
</div>
<button
type="button"
class="inline-block min-w-[4.75rem] text-right cursor-pointer"
x-text="endTimeText()"
:aria-label="endTimeAria()"
:title="endTimeAria()"
@click="cycleEndTime()"
>0:00</button>
</div>
<div class="flex items-center justify-center gap-3 md:gap-5">
<button
@@ -219,7 +385,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[7.5rem]"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;"
>
<template x-for="secs in [1, 5, 10, 15, 30]" :key="'skip' + secs">
<template x-for="secs in skipChoices" :key="'skip' + secs">
<button
type="button"
@click="setSkip(secs); skipOpen = false"
@@ -249,16 +415,26 @@ templ AudioReader(user User, metadata ReaderMetadata) {
class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[7rem]"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;"
>
<template x-for="rate in [0.75, 1, 1.25, 1.5, 2]" :key="'speed' + rate">
<template x-for="rate in speeds" :key="'speed' + rate">
<button
type="button"
@click="setSpeed(rate); speedOpen = false"
class="w-full text-left px-3 py-1.5 rounded-md text-sm"
:class="Number(speed) === rate ? 'font-semibold' : ''"
:style="Number(speed) === rate ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'"
x-text="rate + '×'"
x-text="rate + '×' + (isDefaultSpeed(rate) ? ' · default' : '')"
></button>
</template>
<!-- §8: pin the current rate as the user default —
books without their own speed pick it up -->
<button
type="button"
x-show="!isDefaultSpeed(speed)"
@click="setDefaultSpeed(speed); speedOpen = false"
class="w-full text-left px-3 py-1.5 mt-1 rounded-md text-xs border-t"
style="border-color: var(--border); color: var(--text-secondary);"
x-text="'Make ' + speed + '× my default'"
></button>
</div>
</div>
<button
@@ -275,6 +451,38 @@ templ AudioReader(user User, metadata ReaderMetadata) {
</svg>
<span>Bookmark</span>
</button>
<div class="relative" @click.outside="sleepOpen = false">
<button
type="button"
@click="sleepOpen = !sleepOpen"
class="text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5"
:aria-label="'Sleep timer, currently ' + sleepLabelStr"
style="color: var(--text-secondary);"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"></path>
</svg>
<span class="font-semibold tabular-nums" style="color: var(--text-primary);" x-text="sleepLabelStr === 'Off' ? 'Sleep' : sleepLabelStr">Sleep</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"></path></svg>
</button>
<div
x-show="sleepOpen"
x-cloak
class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[8.5rem]"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;"
>
<template x-for="opt in sleepChoices" :key="opt.key">
<button
type="button"
@click="chooseSleep(opt); sleepOpen = false"
class="w-full text-left px-3 py-1.5 rounded-md text-sm"
:class="isSleepActive(opt.key) ? 'font-semibold' : ''"
:style="isSleepActive(opt.key) ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'"
x-text="opt.label"
></button>
</template>
</div>
</div>
<div class="relative" @click.outside="volumeOpen = false">
<button
type="button"
@@ -453,10 +661,12 @@ templ AudioReader(user User, metadata ReaderMetadata) {
>
<p class="text-sm font-semibold mb-2" style="color: var(--text-primary);">Bookmark at <span class="tabular-nums" x-text="fmtTime(currentTime)">0:00</span></p>
<textarea
id="bookmark-note"
x-model="bookmarkNote"
rows="3"
placeholder="Optional note — markdown welcome (e.g. **great quote**, list, [link](https://…))"
class="input mb-2 resize-y"
@keydown.enter="if (!$event.shiftKey) { $event.preventDefault(); saveBookmark() }"
></textarea>
<div class="flex justify-end gap-2">
<button type="button" @click="bookmarkCaptureOpen = false" class="btn btn-ghost text-sm px-3 py-1.5">Cancel</button>
@@ -464,6 +674,59 @@ templ AudioReader(user User, metadata ReaderMetadata) {
</div>
</div>
<!-- Volume/mute OSD: brief confirmation for keyboard-driven changes -->
<div
x-show="osdVisible"
x-cloak
x-transition.opacity.duration.200ms
class="fixed bottom-24 left-1/2 -translate-x-1/2 z-50 text-xs px-3 py-1.5 rounded-full shadow-lg tabular-nums"
style="background-color: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border);"
x-text="osdText"
></div>
<!-- Shortcut help (?): rows come from the shared KEYMAP
(audio-keys.ts) — the same table drives the keydown handler. -->
<div x-show="helpOpen" x-cloak class="fixed inset-0 z-40" style="background-color: color-mix(in srgb, black 32%, transparent);" @click="helpOpen = false"></div>
<div x-show="helpOpen" x-cloak x-transition class="fixed inset-0 z-50 grid place-items-center p-4" @click.outside="helpOpen = false">
<div class="w-full max-w-md rounded-2xl shadow-2xl p-4 max-h-[80vh] overflow-y-auto" style="background-color: var(--bg-secondary); border: 1px solid var(--border);">
<div class="flex items-center justify-between mb-3 px-1">
<span class="text-sm font-semibold" style="color: var(--text-primary);">Shortcuts &amp; interactions</span>
<button type="button" @click="helpOpen = false" class="rounded-full p-1.5 hover:bg-surface-hover" style="color: var(--text-secondary);" aria-label="Close shortcuts">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</div>
<template x-for="spec in visibleKeymap()" :key="spec.action">
<div class="flex items-center justify-between gap-3 px-2 py-1.5 rounded-lg">
<span class="text-sm" style="color: var(--text-secondary);" x-text="spec.description"></span>
<span class="flex items-center gap-1 shrink-0">
<template x-for="k in spec.keys" :key="k.label">
<kbd class="text-xs px-1.5 py-0.5 rounded tabular-nums" style="background-color: var(--surface-hover); color: var(--text-primary); border: 1px solid var(--border);" x-text="k.label"></kbd>
</template>
</span>
</div>
</template>
<!-- Pointer gestures (audio-keys.ts MOUSE_GUIDE/TOUCH_GUIDE):
two sections — the bar has two scrub systems -->
<div class="mt-3 mb-1 px-2 text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary);">Mouse</div>
<template x-for="row in mouseGuide" :key="'m' + row.label">
<div class="flex items-center justify-between gap-3 px-2 py-1.5 rounded-lg">
<span class="text-sm" style="color: var(--text-secondary);" x-text="row.description"></span>
<kbd class="text-xs px-1.5 py-0.5 rounded tabular-nums shrink-0" style="background-color: var(--surface-hover); color: var(--text-primary); border: 1px solid var(--border);" x-text="row.label"></kbd>
</div>
</template>
<div class="mt-3 mb-1 px-2 text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary);">Touch</div>
<template x-for="row in touchGuide" :key="'t' + row.label">
<div class="flex items-center justify-between gap-3 px-2 py-1.5 rounded-lg">
<span class="text-sm" style="color: var(--text-secondary);" x-text="row.description"></span>
<kbd class="text-xs px-1.5 py-0.5 rounded tabular-nums shrink-0" style="background-color: var(--surface-hover); color: var(--text-primary); border: 1px solid var(--border);" x-text="row.label"></kbd>
</div>
</template>
</div>
</div>
<audio id="audio-element" preload="auto" class="hidden"></audio>
</div>
</body>
+72 -23
View File
@@ -38,7 +38,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html/><html lang=\"en\"><head><title>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><title>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -51,7 +51,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " · Bookhoard</title><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><link href=\"/static/style.css\" rel=\"stylesheet\"><script type=\"module\" src=\"/static/main.js\"></script></head>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " · Bookhoard</title><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><link href=\"/static/style.css\" rel=\"stylesheet\"><script type=\"module\" src=\"/static/main.js\"></script><style>\n\t\t\t\t/* Layered scrubber (plan §1): the native range stays for\n\t\t\t\t a11y (focus, arrow keys, aria) but its track is\n\t\t\t\t transparent so the tick/buffer/fill layers beneath show\n\t\t\t\t through. Static page CSS — templ owns the look. */\n\t\t\t\t#audio-scrub { -webkit-appearance: none; appearance: none; background: transparent; cursor: pointer; }\n\t\t\t\t#audio-scrub::-webkit-slider-runnable-track { background: transparent; border: none; }\n\t\t\t\t#audio-scrub::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; height: 14px; width: 14px; border-radius: 9999px; background: var(--accent); border: 2px solid var(--bg-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.4); }\n\t\t\t\t#audio-scrub::-moz-range-track { background: transparent; border: none; }\n\t\t\t\t#audio-scrub::-moz-range-thumb { height: 14px; width: 14px; border-radius: 9999px; background: var(--accent); border: 2px solid var(--bg-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.4); }\n\t\t\t</style></head>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -80,7 +80,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.MediaItemID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 27, Col: 44}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 38, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
@@ -93,7 +93,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 28, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 39, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
@@ -106,7 +106,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 29, Col: 39}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 40, Col: 39}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
@@ -119,7 +119,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.FileURL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 30, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 41, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
@@ -134,67 +134,116 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
"/api/media-items/"+metadata.MediaItemID+"/chapters",
"/api/media-items/"+metadata.MediaItemID+"/progress"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 33, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 44, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"><div class=\"h-full flex flex-col items-center justify-center px-4 gap-6 select-none\"><!-- Header: back to the detail page (reader-style exit) and the\n\t\t\t chapter launcher. Floating drawer, never in-flow (A2b). --><div class=\"absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3\"><button type=\"button\" @click=\"backToDetail()\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"Back to book details\"><svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 12H5\"></path> <path d=\"m12 19-7-7 7-7\"></path></svg></button> <span class=\"inline-flex items-center gap-2\"><button type=\"button\" @click=\"bookmarksOpen = !bookmarksOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open bookmarks (' + bookmarks.length + ')'\" title=\"Bookmarks\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path></svg></button> <button type=\"button\" @click=\"chaptersOpen = !chaptersOpen\" class=\"text-sm font-semibold px-4 py-2 rounded-full hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open chapter list (currently chapter ' + (currentChapterIdx + 1) + ' of ' + (chapters.length || 1) + ')'\" title=\"Chapters\"><span>Chapter</span> <span class=\"tabular-nums\" x-text=\"(currentChapterIdx + 1) + '/' + (chapters.length || 1)\">1/1</span> <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button></span></div><!-- Cover + title --><div class=\"flex flex-col items-center gap-3 text-center\"><div class=\"w-48 h-72 md:w-56 md:h-80 rounded-2xl overflow-hidden shadow-lg shrink-0 bg-cover bg-center\" style=\"background-color: var(--surface-hover);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"><div class=\"h-full flex flex-col items-center justify-center px-4 gap-6 select-none\"><!-- Header: back to the detail page (reader-style exit) and the\n\t\t\t chapter launcher. Floating drawer, never in-flow (A2b). --><div class=\"absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3\"><button type=\"button\" @click=\"backToDetail()\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"Back to book details\"><svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 12H5\"></path> <path d=\"m12 19-7-7 7-7\"></path></svg></button> <span class=\"inline-flex items-center gap-2\"><button type=\"button\" @click=\"bookmarksOpen = !bookmarksOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open bookmarks (' + bookmarks.length + ')'\" title=\"Bookmarks\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path></svg></button> <button type=\"button\" @click=\"chaptersOpen = !chaptersOpen\" class=\"text-sm font-semibold px-4 py-2 rounded-full hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open chapter list (currently chapter ' + (currentChapterIdx + 1) + ' of ' + (chapters.length || 1) + ')'\" title=\"Chapters\"><span>Chapter</span> <span class=\"tabular-nums\" x-text=\"(currentChapterIdx + 1) + '/' + (chapters.length || 1)\">1/1</span> <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button> <button type=\"button\" @click=\"helpOpen = !helpOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Shortcuts &amp; interactions\" title=\"Shortcuts &amp; interactions (?)\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"2\" y=\"4\" width=\"20\" height=\"16\" rx=\"2\"></rect> <path d=\"M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8\"></path></svg></button><div class=\"relative\" @click.outside=\"settingsOpen = false\"><button type=\"button\" @click=\"settingsOpen = !settingsOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" :class=\"settingsOpen ? 'opacity-100' : 'opacity-90'\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Player settings\" title=\"Settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z\"></path> <circle cx=\"12\" cy=\"12\" r=\"3\"></circle></svg></button><div x-show=\"settingsOpen\" x-cloak x-transition class=\"absolute right-0 top-full mt-2 w-72 rounded-xl shadow-2xl p-3 z-50\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border);\"><div class=\"flex items-center justify-between mb-2 px-1\"><span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\">Player settings</span> <button type=\"button\" @click=\"settingsOpen = false\" class=\"rounded-full p-1.5 hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" aria-label=\"Close settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroLine truncated
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if metadata.CoverImagePath != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<img src=\"")
for _, rate := range SpeedOptions {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%g", rate))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 86, Col: 36}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 161, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" alt=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" :selected=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("Number(defaultSpeed) === %g", rate))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 87, Col: 27}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 162, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" class=\"w-full h-full object-cover\" onerror=\"this.style.display='none'\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%g×", rate))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 163, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</select></div></div></div></span></div><!-- Cover + title --><div class=\"flex flex-col items-center gap-3 text-center\"><div class=\"w-48 h-72 md:w-56 md:h-80 rounded-2xl overflow-hidden shadow-lg shrink-0 bg-cover bg-center\" style=\"background-color: var(--surface-hover);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if metadata.CoverImagePath != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 177, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 178, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" class=\"w-full h-full object-cover\" onerror=\"this.style.display='none'\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<span class=\"text-5xl flex items-center justify-center h-full\" style=\"color: var(--text-secondary);\">🎧</span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<span class=\"text-5xl flex items-center justify-center h-full\" style=\"color: var(--text-secondary);\">🎧</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div><div class=\"max-w-md px-4\"><h1 class=\"font-semibold text-lg leading-snug line-clamp-2\" style=\"color: var(--text-primary);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div><div class=\"max-w-md px-4\"><h1 class=\"font-semibold text-lg leading-snug line-clamp-2\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 96, Col: 117}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 187, Col: 117}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span x-text=\"fmtTime(currentTime)\">0:00</span> <input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"flex-1 accent-current\" @input=\"scrubbing = true; scrubValue = Number($event.target.value)\" @pointerup=\"seekTo(scrubValue); scrubbing = false\" @change=\"seekTo(scrubValue); scrubbing = false\" style=\"z-index: 20;\"> <span x-text=\"fmtTime(chaptersDuration() || 1)\">0:00</span></div><div class=\"flex items-center justify-center gap-3 md:gap-5\"><button type=\"button\" @click=\"stepChapter(-1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Previous chapter\" title=\"Previous chapter\" style=\"color: var(--text-primary);\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 20 9 12l10-8v16z\"></path> <path d=\"M5 19V5\"></path></svg></button> <button type=\"button\" @click=\"skip(-1)\" class=\"relative rounded-full h-14 w-14 grid place-items-center hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip back ' + skipSeconds + 's'\" :title=\"'Skip back ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><svg width=\"52\" height=\"52\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8\"></path> <path d=\"M3 3v5h5\"></path></svg> <span class=\"absolute inset-0 grid place-items-center text-[11px] font-semibold pointer-events-none\"><span x-text=\"skipSeconds\">10</span></span></button> <button id=\"audio-play\" type=\"button\" @click=\"togglePlay()\" class=\"rounded-full h-16 w-16 grid place-items-center\" style=\"background-color: var(--accent); color: var(--bg-primary);\" aria-label=\"Play or pause\"><span x-show=\"!playing\" class=\"grid place-items-center\"><svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\" style=\"margin-left: 2px;\"><path d=\"M8 5.2v13.6L19.2 12z\"></path></svg></span> <span x-show=\"playing\" class=\"grid place-items-center\" x-cloak><svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\"><rect x=\"6.2\" y=\"4.5\" width=\"4.2\" height=\"15\" rx=\"1.7\"></rect> <rect x=\"13.6\" y=\"4.5\" width=\"4.2\" height=\"15\" rx=\"1.7\"></rect></svg></span></button> <button type=\"button\" @click=\"skip(1)\" class=\"relative rounded-full h-14 w-14 grid place-items-center hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip forward ' + skipSeconds + 's'\" :title=\"'Skip forward ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><svg width=\"52\" height=\"52\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8\"></path> <path d=\"M21 3v5h-5\"></path></svg> <span class=\"absolute inset-0 grid place-items-center text-[11px] font-semibold pointer-events-none\"><span x-text=\"skipSeconds\">10</span></span></button> <button type=\"button\" @click=\"stepChapter(1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Next chapter\" title=\"Next chapter\" style=\"color: var(--text-primary);\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M5 4l10 8-10 8V4z\"></path> <path d=\"M19 5v14\"></path></svg></button></div></div><!-- Settings strip: Skip ▾ · Speed ▾ · Bookmark · Volume ▾ --><div class=\"w-full max-w-xl flex items-center justify-center gap-2\"><div class=\"relative\" @click.outside=\"skipOpen = false\"><button type=\"button\" @click=\"skipOpen = !skipOpen\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :aria-label=\"'Skip duration, currently ' + skipSeconds + ' seconds'\" style=\"color: var(--text-secondary);\"><span>Skip</span> <span class=\"font-semibold\" style=\"color: var(--text-primary);\" x-text=\"skipSeconds + 's'\">10s</span> <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></Line truncated
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span class=\"inline-block min-w-[4.75rem]\" x-text=\"fmtTime(scrubbing ? scrubValue : currentTime)\">0:00</span><div id=\"scrub-area\" class=\"relative flex-1 h-6 flex items-center cursor-pointer\" style=\"touch-action: none;\" @pointerdown=\"scrubPointerDown($event)\" @pointermove=\"scrubPointerMove($event)\" @pointerup=\"scrubPointerUp($event)\" @pointercancel=\"scrubPointerUp($event)\" @pointerleave=\"scrubPointerLeave()\" @wheel.prevent=\"onScrubWheel($event)\"><!-- Base track --><div class=\"absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" style=\"background-color: var(--surface-hover);\"></div><!-- Elapsed fill --><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:0;width:' + scrubFillPct() + '%;background-color:var(--accent);'\"></div><!-- Buffered span: the ACTIVE track's element buffer only --><template x-if=\"bufferSpan()\"><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:' + bufferSpan().left + '%;width:' + bufferSpan().width + '%;background-color:var(--accent-muted);'\"></div></template><!-- Chapter ticks: density-decimated, current chapter tinted --><div id=\"scrub-ticks\" class=\"absolute inset-0 pointer-events-none\"><template x-for=\"idx in tickIndices\" :key=\"'tick' + idx\"><div class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-2 w-0.5 rounded-full\" :class=\"currentChapterIdx === idx ? 'opacity-100' : 'opacity-40'\" :style=\"'left:' + (chapters[idx].start / (chaptersDuration() || 1) * 100) + '%;background-color:' + (currentChapterIdx === idx ? 'var(--accent)' : 'var(--text-secondary)') + ';'\"></div></template></div><!-- §9 magnet: the line a click would land on (hover\n\t\t\t\t\t\t inside the pixel radius locks the tooltip here) --><div x-show=\"hoverSnapped && !scrubbing\" x-cloak class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-4 w-px rounded-full pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--accent);'\"></div><input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"relative w-full\" @input=\"scrubbing = true; scrubValue = Number($event.target.value)\" @change=\"seekTo(scrubValue); scrubbing = false\" aria-label=\"Seek position\"><!-- Hover tooltip: time + chapter under the pointer --><div x-show=\"hoverSeconds !== null && !scrubbing\" x-cloak class=\"absolute bottom-full mb-1 -translate-x-1/2 whitespace-nowrap rounded-md px-2 py-1 text-[11px] shadow-lg pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);'\"><span x-text=\"hoverSeconds !== null ? fmtTime(hoverSeconds) : ''\"></span> <span style=\"color: var(--text-secondary);\" x-text=\"hoverSeconds !== null ? ' — ' + chapterTitleAt(hoverSeconds) : ''\"></span></div><!-- Precision rail (§9): Shift+drag on mouse, or a touch\n\t\t\t\t\t\t drag pulled off the bar — ±5 min or tighter, free\n\t\t\t\t\t\t mapping inside. Edge labels self-document the window\n\t\t\t\t\t\t (floats over the transports, page never shifts) --><div x-show=\"railVisible\" x-cloak class=\"absolute top-full mt-1 inset-x-0 h-12 rounded-lg shadow-lg p-1.5 pointer-events-none\" :style=\"'background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;' + (railLiftY ? ' transform: translateY(' + railLiftY + 'px);' : '')\"><div class=\"relative h-full\"><div class=\"absolute inset-x-0 top-1/2 h-px\" style=\"background-color: var(--border);\"></div><template x-for=\"tick in railTickList\" :key=\"'rtick' + tick.start\"><div class=\"absolute top-1 bottom-1 w-px\" :style=\"'left:' + ((tick.start - railFrom) / Math.max(1, railTo - railFrom) * 100) + '%;background-color:var(--text-secondary);opacity:0.5;'\"></div></template><div class=\"absolute top-0 bottom-0 w-0.5 -translate-x-1/2 rounded-full\" :style=\"'left:' + railMarkerPct() + '%;background-color:var(--accent);'\"></div><div class=\"absolute left-1 top-0.5 text-[10px] tabular-nums whitespace-nowrap\" style=\"color: var(--text-primary);\" x-text=\"fmtTime(scrubValue) + ' — ' + chapterTitleAt(scrubValue)\"></div><div class=\"absolute left-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railFrom)\"></div><div class=\"absolute right-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railTo)\"></div></div></div></div><button typeLine truncated
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+23 -1
View File
@@ -5,10 +5,32 @@ import (
"fmt"
"net/url"
// NOTE: do not import "github.com/a-h/templ" here — templ's generator
// already emits that import into the generated file, and a second copy
// is a compile error (redeclared block).
"github.com/microcosm-cc/bluemonday"
"strings"
)
// cssURL neutralizes characters that would break an UNQUOTED CSS url()
// token (whitespace, quotes, parens, backslash). The stored cover paths
// are already percent-encoded URL paths, so this is defensive only — but
// it must exist: templ's codegen HTML-escapes style attribute values a
// second time, so ANY quote character (SafeCSS included) reaches the DOM
// as literal &#39; text and the browser never parses the url() at all
// (2026-10-21: every hero backdrop broken + /media/& 500 spam, because
// Firefox resolved the mangled token relative to the page URL).
func cssURL(s string) string {
return strings.NewReplacer(
" ", "%20",
"(", "%28",
")", "%29",
"'", "%27",
"\"", "%22",
"\\", "%5C",
).Replace(s)
}
templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombstoneDays int) {
<!DOCTYPE html>
<html lang="en">
@@ -33,7 +55,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
<!-- Hero with blurred cover backdrop -->
<div class="relative overflow-hidden">
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
<div class="hero-backdrop" style={ "background-image: url('" + book.CoverImagePath.String + "')" }></div>
<div class="hero-backdrop" style={ templ.SafeCSS("background-image: url(" + cssURL(book.CoverImagePath.String) + ")") }></div>
}
<div class="relative z-10 xs:w-full md:mx-auto md:container px-4 sm:px-6 lg:px-8 pt-6 pb-8">
<a id="back-link" href="/dashboard" class="inline-flex items-center gap-1.5 text-sm font-medium hover:underline transition-colors mb-6" style="color: var(--text-secondary);">
+98 -76
View File
@@ -13,10 +13,32 @@ import (
"fmt"
"net/url"
// NOTE: do not import "github.com/a-h/templ" here — templ's generator
// already emits that import into the generated file, and a second copy
// is a compile error (redeclared block).
"github.com/microcosm-cc/bluemonday"
"strings"
)
// cssURL neutralizes characters that would break an UNQUOTED CSS url()
// token (whitespace, quotes, parens, backslash). The stored cover paths
// are already percent-encoded URL paths, so this is defensive only — but
// it must exist: templ's codegen HTML-escapes style attribute values a
// second time, so ANY quote character (SafeCSS included) reaches the DOM
// as literal &#39; text and the browser never parses the url() at all
// (2026-10-21: every hero backdrop broken + /media/& 500 spam, because
// Firefox resolved the mangled token relative to the page URL).
func cssURL(s string) string {
return strings.NewReplacer(
" ", "%20",
"(", "%28",
")", "%29",
"'", "%27",
"\"", "%22",
"\\", "%5C",
).Replace(s)
}
func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombstoneDays int) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
@@ -45,7 +67,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 18, Col: 22}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 40, Col: 22}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@@ -80,7 +102,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.FormatGroup)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 26, Col: 39}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 48, Col: 39}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
@@ -93,7 +115,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(uuidToString(book.LibraryID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 27, Col: 49}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 49, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
@@ -106,7 +128,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", getBookRating(book.Rating)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 28, Col: 62}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 50, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
@@ -119,7 +141,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflictID(book.ActiveConflict))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 29, Col: 53}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 51, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
@@ -132,7 +154,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflictWinnerSource(book.ActiveConflict))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 30, Col: 67}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 52, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
@@ -156,9 +178,9 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-image: url('" + book.CoverImagePath.String + "')")
templ_7745c5c3_Var10, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(templ.SafeCSS("background-image: url(" + cssURL(book.CoverImagePath.String) + ")"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 36, Col: 101}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 58, Col: 122}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -189,7 +211,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath.String)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 48, Col: 41}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 70, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
@@ -202,7 +224,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 49, Col: 25}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 71, Col: 25}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil {
@@ -220,7 +242,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 57, Col: 25}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 79, Col: 25}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil {
@@ -238,7 +260,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 65, Col: 117}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 87, Col: 117}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
@@ -256,7 +278,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(book.Subtitle.String)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 67, Col: 91}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 89, Col: 91}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
@@ -286,7 +308,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var16 templ.SafeURL
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs("/bookshelf?author_filter=" + url.QueryEscape(authorName))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 76, Col: 77}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 98, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
@@ -299,7 +321,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(authorName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 76, Col: 154}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 98, Col: 154}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
@@ -322,7 +344,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author.String)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 81, Col: 32}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 103, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
@@ -366,7 +388,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue("window.location.href = '/readers/" + uuidToString(book.ID) + "'")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 98, Col: 83}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 120, Col: 83}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil {
@@ -387,7 +409,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(book.ActionLabel())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 102, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 124, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
@@ -413,7 +435,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(book.MarkResetLabel())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 106, Col: 137}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 128, Col: 137}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
@@ -439,7 +461,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(book.MarkCompleteLabel())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 114, Col: 133}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 136, Col: 133}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
@@ -484,7 +506,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(len(book.DeletedAnnotations))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 131, Col: 134}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 153, Col: 134}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
@@ -515,7 +537,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(book.NotesCount + book.HighlightsCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 138, Col: 144}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 160, Col: 144}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
@@ -564,7 +586,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f / 10", book.CommunityRating.Float64))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 201, Col: 84}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 223, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
@@ -593,7 +615,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var26 templ.SafeURL
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs("/series/detail?name=" + url.QueryEscape(book.Series.String) + "&library_id=" + uuidToString(book.LibraryID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 211, Col: 125}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 233, Col: 125}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
@@ -606,7 +628,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(book.Series.String)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 215, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 237, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
@@ -624,7 +646,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(fmtNumber(book.SeriesNumber.Float64))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 217, Col: 50}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 239, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
if templ_7745c5c3_Err != nil {
@@ -644,7 +666,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(strings.ToUpper(book.ReadingDirection.String))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 222, Col: 142}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 244, Col: 142}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil {
@@ -690,7 +712,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var30 templ.SafeURL
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs("/tags/detail?name=" + url.QueryEscape(tag) + "&library_id=" + uuidToString(book.LibraryID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 238, Col: 109}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 260, Col: 109}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {
@@ -703,7 +725,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(tag)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 242, Col: 16}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 264, Col: 16}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil {
@@ -731,7 +753,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(book.Review.Review)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 255, Col: 61}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 277, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
if templ_7745c5c3_Err != nil {
@@ -782,7 +804,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(book.MetadataNotes.String)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 286, Col: 92}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 308, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
if templ_7745c5c3_Err != nil {
@@ -805,7 +827,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(book.ProgressHeader())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 292, Col: 95}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 314, Col: 95}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
if templ_7745c5c3_Err != nil {
@@ -836,7 +858,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("width: %.1f%%; background-color: var(--accent);", book.ReadingProgress.Percentage.Float64*100))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 303, Col: 124}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 325, Col: 124}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
if templ_7745c5c3_Err != nil {
@@ -854,7 +876,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(book.AudioCurrentLabel() + " / " + book.AudioDurationLabel())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 311, Col: 144}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 333, Col: 144}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
if templ_7745c5c3_Err != nil {
@@ -872,7 +894,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64*100))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 316, Col: 135}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 338, Col: 135}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil {
@@ -891,7 +913,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.CurrentPage.Int32)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 322, Col: 87}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 344, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
if templ_7745c5c3_Err != nil {
@@ -904,7 +926,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.TotalPages.Int32)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 322, Col: 131}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 344, Col: 131}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
if templ_7745c5c3_Err != nil {
@@ -923,7 +945,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var40 string
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(book.LastReadLabel())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 327, Col: 111}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 349, Col: 111}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
if templ_7745c5c3_Err != nil {
@@ -936,7 +958,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 328, Col: 140}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 350, Col: 140}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
if templ_7745c5c3_Err != nil {
@@ -955,7 +977,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var42 string
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.LastSyncSource.String)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 334, Col: 110}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 356, Col: 110}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
if templ_7745c5c3_Err != nil {
@@ -983,7 +1005,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(book.StatsHeader())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 343, Col: 92}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 365, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
if templ_7745c5c3_Err != nil {
@@ -996,7 +1018,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var44 string
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(book.TimeStatLabel())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 346, Col: 110}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 368, Col: 110}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
if templ_7745c5c3_Err != nil {
@@ -1014,7 +1036,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var45 string
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%dh %dmin", book.BookStats.TotalMinutes/60, book.BookStats.TotalMinutes%60))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 348, Col: 157}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 370, Col: 157}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
if templ_7745c5c3_Err != nil {
@@ -1032,7 +1054,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var46 string
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d min", book.BookStats.TotalMinutes))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 350, Col: 119}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 372, Col: 119}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
if templ_7745c5c3_Err != nil {
@@ -1050,7 +1072,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var47 string
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", book.BookStats.SessionCount))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 355, Col: 114}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 377, Col: 114}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
if templ_7745c5c3_Err != nil {
@@ -1068,7 +1090,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var48 string
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(book.TimeListenedLabel())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 360, Col: 106}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 382, Col: 106}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
if templ_7745c5c3_Err != nil {
@@ -1086,7 +1108,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var49 string
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", book.BookStats.TotalPages))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 365, Col: 113}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 387, Col: 113}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
if templ_7745c5c3_Err != nil {
@@ -1143,7 +1165,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var50 string
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(book.DatePublished.Time.Format("01-02-2006"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 392, Col: 92}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 414, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
if templ_7745c5c3_Err != nil {
@@ -1162,7 +1184,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var51 string
templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(book.Isbn.String)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 398, Col: 64}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 420, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
if templ_7745c5c3_Err != nil {
@@ -1221,7 +1243,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var52 string
templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(book.AudioDurationLabel())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 423, Col: 94}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 445, Col: 94}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
if templ_7745c5c3_Err != nil {
@@ -1260,7 +1282,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var53 string
templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(book.PageCount.Int32)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 441, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 463, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
if templ_7745c5c3_Err != nil {
@@ -1293,7 +1315,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var54 string
templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(book.SeriesCount.Int32)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 453, Col: 70}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 475, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
if templ_7745c5c3_Err != nil {
@@ -1312,7 +1334,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var55 string
templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(book.Volume.Int32)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 459, Col: 70}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 481, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
if templ_7745c5c3_Err != nil {
@@ -1345,7 +1367,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var56 string
templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(book.CopyrightYear.Int32)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 471, Col: 72}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 493, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
if templ_7745c5c3_Err != nil {
@@ -1364,7 +1386,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var57 string
templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(strings.ReplaceAll(book.MangaType.String, "_", " "))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 477, Col: 118}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 499, Col: 118}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
if templ_7745c5c3_Err != nil {
@@ -1383,7 +1405,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var58 string
templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(book.ScanInformation.String)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 483, Col: 94}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 505, Col: 94}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
if templ_7745c5c3_Err != nil {
@@ -1402,7 +1424,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var59 string
templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(altSeries)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 489, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 511, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
if templ_7745c5c3_Err != nil {
@@ -1421,7 +1443,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var60 string
templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(book.Contributors, ", "))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 495, Col: 85}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 517, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))
if templ_7745c5c3_Err != nil {
@@ -1439,7 +1461,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var61 string
templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(book.MimeType.String)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 500, Col: 67}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 522, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))
if templ_7745c5c3_Err != nil {
@@ -1457,7 +1479,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var62 string
templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(formatFileSize(book.FileSize.Int64))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 505, Col: 82}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 527, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
if templ_7745c5c3_Err != nil {
@@ -1476,7 +1498,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var63 string
templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(book.FileLocation)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 511, Col: 113}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 533, Col: 113}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))
if templ_7745c5c3_Err != nil {
@@ -1504,7 +1526,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var64 templ.SafeURL
templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("goodreads", book.GoodreadsID.String, book.Isbn, book.Title, book.Author))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 518, Col: 106}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 540, Col: 106}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64))
if templ_7745c5c3_Err != nil {
@@ -1522,7 +1544,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var65 templ.SafeURL
templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("goodreads", "", book.Isbn, book.Title, book.Author))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 520, Col: 85}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 542, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
if templ_7745c5c3_Err != nil {
@@ -1541,7 +1563,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var66 templ.SafeURL
templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("openlibrary", book.OpenlibraryID.String, book.Isbn, book.Title, book.Author))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 523, Col: 110}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 545, Col: 110}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66))
if templ_7745c5c3_Err != nil {
@@ -1559,7 +1581,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var67 templ.SafeURL
templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("openlibrary", "", book.Isbn, book.Title, book.Author))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 525, Col: 87}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 547, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67))
if templ_7745c5c3_Err != nil {
@@ -1578,7 +1600,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var68 templ.SafeURL
templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("googlebooks", book.GoogleBooksID.String, book.Isbn, book.Title, book.Author))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 528, Col: 110}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 550, Col: 110}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
if templ_7745c5c3_Err != nil {
@@ -1596,7 +1618,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var69 templ.SafeURL
templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("googlebooks", "", book.Isbn, book.Title, book.Author))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 530, Col: 87}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 552, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69))
if templ_7745c5c3_Err != nil {
@@ -1615,7 +1637,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var70 templ.SafeURL
templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("amazon", book.Asin.String, book.Isbn, book.Title, book.Author))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 533, Col: 96}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 555, Col: 96}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70))
if templ_7745c5c3_Err != nil {
@@ -1633,7 +1655,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var71 templ.SafeURL
templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinURLErrs(getExternalURL("amazon", "", book.Isbn, book.Title, book.Author))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 535, Col: 82}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 557, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71))
if templ_7745c5c3_Err != nil {
@@ -1652,7 +1674,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var72 templ.SafeURL
templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinURLErrs(book.WebUrl.String)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 538, Col: 36}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 560, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72))
if templ_7745c5c3_Err != nil {
@@ -1665,7 +1687,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var73 string
templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinStringErrs(getDomainName(book.WebUrl.String))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 538, Col: 238}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 560, Col: 238}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73))
if templ_7745c5c3_Err != nil {
@@ -1698,7 +1720,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var74 templ.SafeURL
templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinURLErrs("/collections/" + uuidToString(col.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 550, Col: 54}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 572, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74))
if templ_7745c5c3_Err != nil {
@@ -1711,7 +1733,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var75 string
templ_7745c5c3_Var75, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("border-color: " + col.Color.String + "; background-color: var(--bg-primary); text-decoration: none;")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 552, Col: 118}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 574, Col: 118}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75))
if templ_7745c5c3_Err != nil {
@@ -1724,7 +1746,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var76 string
templ_7745c5c3_Var76, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("color: " + col.Color.String)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 554, Col: 51}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 576, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
if templ_7745c5c3_Err != nil {
@@ -1737,7 +1759,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var77 string
templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon.String)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 554, Col: 71}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 576, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var77))
if templ_7745c5c3_Err != nil {
@@ -1750,7 +1772,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
var templ_7745c5c3_Var78 string
templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 555, Col: 61}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail.templ`, Line: 577, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78))
if templ_7745c5c3_Err != nil {
+21 -1
View File
@@ -1,5 +1,15 @@
package templates
import "fmt"
// effectiveAudioSpeed renders 0 (unset) as 1× — the schema default.
func effectiveAudioSpeed(user User) float64 {
if user.DefaultAudioSpeed == 0 {
return 1
}
return user.DefaultAudioSpeed
}
templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, showRoleField bool, showCancelButton bool) {
<form
hx-put={ actionURL }
@@ -66,6 +76,17 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
<option value="material-dark" selected?={ user.Theme == "material-dark" }>Material Dark</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Default Playback Speed</label>
<select
name="default_audio_speed"
class="input"
>
for _, rate := range SpeedOptions {
<option value={ fmt.Sprintf("%g", rate) } selected?={ effectiveAudioSpeed(user) == rate }>{ fmt.Sprintf("%g", rate) }×</option>
}
</select>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Timezone</label>
<select
@@ -125,7 +146,6 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
type="password"
name="current_password"
class="input"
required
/>
</div>
<div>
+153 -97
View File
@@ -8,6 +8,16 @@ package templates
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import "fmt"
// effectiveAudioSpeed renders 0 (unset) as 1× — the schema default.
func effectiveAudioSpeed(user User) float64 {
if user.DefaultAudioSpeed == 0 {
return 1
}
return user.DefaultAudioSpeed
}
func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showRoleField bool, showCancelButton bool) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
@@ -36,7 +46,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(actionURL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 5, Col: 20}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 15, Col: 20}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
@@ -57,7 +67,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Username)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 21, Col: 27}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 31, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
@@ -70,7 +80,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Email)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 30, Col: 24}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 40, Col: 24}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
@@ -83,7 +93,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.FirstName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 39, Col: 28}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 49, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
@@ -96,7 +106,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.LastName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 49, Col: 27}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 59, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
@@ -172,277 +182,323 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, ">Material Dark</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Timezone</label> <select name=\"timezone\" class=\"input\"><option value=\"UTC\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, ">Material Dark</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Default Playback Speed</label> <select name=\"default_audio_speed\" class=\"input\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, rate := range SpeedOptions {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%g", rate))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 86, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if effectiveAudioSpeed(user) == rate {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, ">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%g", rate))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 86, Col: 122}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "×</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Timezone</label> <select name=\"timezone\" class=\"input\"><option value=\"UTC\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "UTC" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, ">UTC (UTC+0)</option> <option value=\"Pacific/Honolulu\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Pacific/Honolulu" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, ">Hawaii (UTC-10)</option> <option value=\"America/Anchorage\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "America/Anchorage" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "America/Los_Angeles" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, ">Pacific (UTC-8/-7)</option> <option value=\"America/Denver\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, ">UTC (UTC+0)</option> <option value=\"Pacific/Honolulu\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "America/Denver" {
if user.Timezone == "Pacific/Honolulu" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, ">Mountain (UTC-7/-6)</option> <option value=\"America/Phoenix\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, ">Hawaii (UTC-10)</option> <option value=\"America/Anchorage\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "America/Phoenix" {
if user.Timezone == "America/Anchorage" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ">Mountain - no DST (UTC-7)</option> <option value=\"America/Chicago\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "America/Chicago" {
if user.Timezone == "America/Los_Angeles" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">Central (UTC-6/-5)</option> <option value=\"America/New_York\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">Pacific (UTC-8/-7)</option> <option value=\"America/Denver\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "America/New_York" {
if user.Timezone == "America/Denver" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">Eastern (UTC-5/-4)</option> <option value=\"America/Sao_Paulo\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">Mountain (UTC-7/-6)</option> <option value=\"America/Phoenix\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "America/Sao_Paulo" {
if user.Timezone == "America/Phoenix" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, ">Brasilia (UTC-3/-2)</option> <option value=\"Europe/London\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, ">Mountain - no DST (UTC-7)</option> <option value=\"America/Chicago\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Europe/London" {
if user.Timezone == "America/Chicago" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">British (UTC+0/+1)</option> <option value=\"Europe/Paris\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">Central (UTC-6/-5)</option> <option value=\"America/New_York\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Europe/Paris" {
if user.Timezone == "America/New_York" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, ">Central European (UTC+1/+2)</option> <option value=\"Europe/Helsinki\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, ">Eastern (UTC-5/-4)</option> <option value=\"America/Sao_Paulo\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Europe/Helsinki" {
if user.Timezone == "America/Sao_Paulo" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, ">Eastern European (UTC+2/+3)</option> <option value=\"Europe/Moscow\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, ">Brasilia (UTC-3/-2)</option> <option value=\"Europe/London\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Europe/Moscow" {
if user.Timezone == "Europe/London" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ">Moscow (UTC+3)</option> <option value=\"Asia/Tehran\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ">British (UTC+0/+1)</option> <option value=\"Europe/Paris\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Asia/Tehran" {
if user.Timezone == "Europe/Paris" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, ">Iran (UTC+3:30)</option> <option value=\"Asia/Dubai\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, ">Central European (UTC+1/+2)</option> <option value=\"Europe/Helsinki\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Asia/Dubai" {
if user.Timezone == "Europe/Helsinki" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, ">Gulf (UTC+4)</option> <option value=\"Asia/Karachi\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, ">Eastern European (UTC+2/+3)</option> <option value=\"Europe/Moscow\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Asia/Karachi" {
if user.Timezone == "Europe/Moscow" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, ">Pakistan (UTC+5)</option> <option value=\"Asia/Kolkata\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, ">Moscow (UTC+3)</option> <option value=\"Asia/Tehran\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Asia/Kolkata" {
if user.Timezone == "Asia/Tehran" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ">India (UTC+5:30)</option> <option value=\"Asia/Dhaka\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ">Iran (UTC+3:30)</option> <option value=\"Asia/Dubai\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Asia/Dhaka" {
if user.Timezone == "Asia/Dubai" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, ">Bangladesh (UTC+6)</option> <option value=\"Asia/Bangkok\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, ">Gulf (UTC+4)</option> <option value=\"Asia/Karachi\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Asia/Bangkok" {
if user.Timezone == "Asia/Karachi" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, ">Indochina (UTC+7)</option> <option value=\"Asia/Shanghai\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, ">Pakistan (UTC+5)</option> <option value=\"Asia/Kolkata\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Asia/Shanghai" {
if user.Timezone == "Asia/Kolkata" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, ">China (UTC+8)</option> <option value=\"Asia/Tokyo\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, ">India (UTC+5:30)</option> <option value=\"Asia/Dhaka\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Asia/Tokyo" {
if user.Timezone == "Asia/Dhaka" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, ">Japan/Korea (UTC+9)</option> <option value=\"Australia/Darwin\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, ">Bangladesh (UTC+6)</option> <option value=\"Asia/Bangkok\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Australia/Darwin" {
if user.Timezone == "Asia/Bangkok" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, ">Australian Central (UTC+9:30)</option> <option value=\"Australia/Sydney\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, ">Indochina (UTC+7)</option> <option value=\"Asia/Shanghai\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Australia/Sydney" {
if user.Timezone == "Asia/Shanghai" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, ">Australian Eastern (UTC+10/+11)</option> <option value=\"Pacific/Auckland\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, ">China (UTC+8)</option> <option value=\"Asia/Tokyo\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Pacific/Auckland" {
if user.Timezone == "Asia/Tokyo" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, ">New Zealand (UTC+12/+13)</option></select></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, ">Japan/Korea (UTC+9)</option> <option value=\"Australia/Darwin\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Australia/Darwin" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, ">Australian Central (UTC+9:30)</option> <option value=\"Australia/Sydney\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Australia/Sydney" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, ">Australian Eastern (UTC+10/+11)</option> <option value=\"Pacific/Auckland\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Pacific/Auckland" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, ">New Zealand (UTC+12/+13)</option></select></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if showRoleField {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Role</label> <select name=\"role\" class=\"input\"><option value=\"user\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Role</label> <select name=\"role\" class=\"input\"><option value=\"user\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Role == "user" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, ">User</option> <option value=\"admin\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, ">User</option> <option value=\"admin\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Role == "admin" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, ">Admin</option></select></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, ">Admin</option></select></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</div></div><div x-data=\"profilePasswordForm\"><div class=\"flex items-center gap-2 mb-4\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</div></div><div x-data=\"profilePasswordForm\"><div class=\"flex items-center gap-2 mb-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -450,12 +506,12 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Change Password</h3></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Change Password</h3></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if requireCurrentPassword {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<div class=\"space-y-4 max-w-md\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Current Password</label> <input type=\"password\" name=\"current_password\" class=\"input\" required></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" id=\"new-password\" name=\"new_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" id=\"confirm-password\" name=\"confirm_password\" class=\"input\"></div><div id=\"password-requirements\" class=\"text-xs\"></div><button type=\"button\" id=\"password-submit-btn\" hx-put=\"/api/auth/password\" hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"btn btn-primary\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<div class=\"space-y-4 max-w-md\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Current Password</label> <input type=\"password\" name=\"current_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" id=\"new-password\" name=\"new_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" id=\"confirm-password\" name=\"confirm_password\" class=\"input\"></div><div id=\"password-requirements\" class=\"text-xs\"></div><button type=\"button\" id=\"password-submit-btn\" hx-put=\"/api/auth/password\" hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -463,25 +519,25 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "Update Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "Update Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<p class=\"text-sm italic mb-4\" style=\"color: var(--text-secondary);\">As an admin, you can change this user's password without knowing their current password.</p><div class=\"space-y-4 max-w-md\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" id=\"new-password\" name=\"new_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" id=\"confirm-password\" name=\"confirm_password\" class=\"input\"></div><div id=\"password-requirements\" class=\"text-xs\"></div><button type=\"button\" id=\"password-submit-btn\" hx-put=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<p class=\"text-sm italic mb-4\" style=\"color: var(--text-secondary);\">As an admin, you can change this user's password without knowing their current password.</p><div class=\"space-y-4 max-w-md\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" id=\"new-password\" name=\"new_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" id=\"confirm-password\" name=\"confirm_password\" class=\"input\"></div><div id=\"password-requirements\" class=\"text-xs\"></div><button type=\"button\" id=\"password-submit-btn\" hx-put=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/password/" + user.ID)
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/password/" + user.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 191, Col: 46}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 211, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\" hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"btn btn-primary\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\" hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -489,22 +545,22 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "Reset Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "Reset Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</div><div class=\"flex justify-end gap-3 pt-6 border-t\" style=\"border-color: var(--border);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</div><div class=\"flex justify-end gap-3 pt-6 border-t\" style=\"border-color: var(--border);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if showCancelButton {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<button type=\"button\" @click=\"closeProfileModal()\" class=\"btn btn-secondary\">Cancel</button> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "<button type=\"button\" @click=\"closeProfileModal()\" class=\"btn btn-secondary\">Cancel</button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<button type=\"submit\" class=\"btn btn-primary\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<button type=\"submit\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -512,7 +568,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "Save Changes</button></div><div id=\"profile-result\" class=\"mt-2\"></div></form>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "Save Changes</button></div><div id=\"profile-result\" class=\"mt-2\"></div></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+3
View File
@@ -350,6 +350,7 @@ templ ReaderChrome(metadata ReaderMetadata) {
step="any"
list="tick-marks"
@input="goToFraction($event.target.value)"
@pointerup="$el.blur()"
class="grow"
/>
<datalist id="tick-marks"></datalist>
@@ -392,6 +393,7 @@ templ ReaderChrome(metadata ReaderMetadata) {
max="1"
step="any"
@input="goToFraction($event.target.value)"
@pointerup="$el.blur()"
class="grow min-w-[3rem]"
/>
<button @click="goRight()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Go Right (→)" aria-label="Go right">
@@ -482,6 +484,7 @@ templ ReaderChrome(metadata ReaderMetadata) {
max="1"
step="any"
@input="goToFraction($event.target.value)"
@pointerup="$el.blur()"
class="grow min-w-[3rem]"
/>
<button @click="goRight()" class="p-1.5 rounded-lg hover:bg-gray-700" title="Go Right (→)" aria-label="Go right">
+2 -2
View File
@@ -194,7 +194,7 @@ func ReaderChrome(metadata ReaderMetadata) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</h1><div class=\"flex items-center gap-1\"><button @click=\"addBookmark()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Bookmark this position (b)\">🏷️</button> <button x-show=\"(!isFixedLayout || isPDF) && !treatAsComic\" @click=\"toggleSearch()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Search (/)\">🔍</button> <button @click=\"toggleBookmarks()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Annotations (a)\">📝</button> <button @click=\"toggleSettings()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Settings (s)\">Aa</button> <button @click=\"toggleHelp()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700 text-sm font-bold\" title=\"Shortcuts & gestures (?)\" aria-label=\"Shortcuts and gestures\">?</button> <button @click=\"toggleFullscreen()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Fullscreen (f)\" aria-label=\"Toggle fullscreen\"><svg x-show=\"!isFullscreen\" class=\"reader-icon\" width=\"18\" height=\"18\" aria-hidden=\"true\"><path d=\"M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3\"></path></svg> <svg x-show=\"isFullscreen\" x-cloak class=\"reader-icon\" width=\"18\" height=\"18\" aria-hidden=\"true\"><path d=\"M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3\"></path></svg></button></div></div></div><!-- Bottom bar --><div id=\"reader-bottombar\" class=\"fixed bottom-0 left-0 right-0 border-t z-40 pb-[env(safe-area-inset-bottom)] reader-glass\"><!-- Reflowable row --><div x-show=\"!isFixedLayout\" class=\"flex flex-wrap items-center gap-x-1 gap-y-1 px-1.5 py-1.5 sm:px-2 sm:py-2 sm:gap-1\"><div class=\"flex items-center gap-0.5 grow min-w-[190px]\"><button @click=\"goLeft()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Left\" aria-label=\"Go left\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 15 6 L 9 12 L 15 18\"></path></svg></button> <button x-show=\"backStack.length > 0\" @click=\"goBackToLocation()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Return to previous location (Alt+←)\" aria-label=\"Return to previous location\"><svg class=\"reader-icon\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M 12 21 C 9 18 5 14.4 5 9.5 C 5 6.5 8 4 12 4 C 16 4 19 6.5 19 9.5 C 19 14.4 15 18 12 21 Z\"></path> <circle cx=\"12\" cy=\"9.5\" r=\"2\"></circle></svg></button> <input id=\"progress-slider\" type=\"range\" min=\"0\" max=\"1\" step=\"any\" list=\"tick-marks\" @input=\"goToFraction($event.target.value)\" class=\"grow\"> <datalist id=\"tick-marks\"></datalist> <button @click=\"goRight()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Right\" aria-label=\"Go right\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 9 6 L 15 12 L 9 18\"></path></svg></button></div><div class=\"flex items-center gap-1\"><div class=\"w-px h-6 reader-sep\"></div><div id=\"progress-display\" @click=\"cycleProgressMode()\" :title=\"progressTooltip()\" class=\"text-sm min-w-[4rem] max-w-[5rem] sm:max-w-none text-center cursor-pointer truncate whitespace-nowrap overflow-hidden\"><span class=\"hidden sm:inline\" x-text=\"progressLabel\"></span><span x-text=\"progressMain\">—</span></div><div class=\"w-px h-6 reader-sep\"></div><button @click=\"toggleTOC()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Table of Contents (t)\">📖</button></div></div><!-- Fixed-layout row (comics, PDFs, manga) --><div x-show=\"isFixedLayout\"><!-- Full toolbar: desktop/tablet (>= 768px, md). Wraps gracefully in between. --><div class=\"hidden md:flex flex-wrap items-center gap-x-1 gap-y-1 px-1.5 py-1.5 sm:px-2 sm:py-2 sm:gap-1\"><div class=\"flex items-center gap-0.5\"><button @click=\"goLeft()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Left (←)\" aria-label=\"Go left\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 15 6 L 9 12 L 15 18\"></path></svg></button> <button x-show=\"backStack.length > 0\" @click=\"goBackToLocation()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Return to previous location (Alt+←)\" aria-label=\"Return to previous location\"><svg class=\"reader-icon\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M 12 21 C 9 18 5 14.4 5 9.5 C 5 6.5 8 4 12 4 C 16 4 19 6.5 19 9.5 C 19 14.4 15 18 12 21 Z\"></path> <circle cx=\"12\" cy=\"9.5\" r=\"2\"></circle></svg></button></div><input id=\"progress-slider-fx\" type=\"range\" min=\"0\" max=\"1\" step=\"any\" @input=\"goToFraction($event.target.value)\" class=\"grow min-w-[3rem]\"> <button @click=\"goRight()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Right (→)\" aria-label=\"Go right\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\Line truncated
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</h1><div class=\"flex items-center gap-1\"><button @click=\"addBookmark()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Bookmark this position (b)\">🏷️</button> <button x-show=\"(!isFixedLayout || isPDF) && !treatAsComic\" @click=\"toggleSearch()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Search (/)\">🔍</button> <button @click=\"toggleBookmarks()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Annotations (a)\">📝</button> <button @click=\"toggleSettings()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Settings (s)\">Aa</button> <button @click=\"toggleHelp()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700 text-sm font-bold\" title=\"Shortcuts & gestures (?)\" aria-label=\"Shortcuts and gestures\">?</button> <button @click=\"toggleFullscreen()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Fullscreen (f)\" aria-label=\"Toggle fullscreen\"><svg x-show=\"!isFullscreen\" class=\"reader-icon\" width=\"18\" height=\"18\" aria-hidden=\"true\"><path d=\"M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3\"></path></svg> <svg x-show=\"isFullscreen\" x-cloak class=\"reader-icon\" width=\"18\" height=\"18\" aria-hidden=\"true\"><path d=\"M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3\"></path></svg></button></div></div></div><!-- Bottom bar --><div id=\"reader-bottombar\" class=\"fixed bottom-0 left-0 right-0 border-t z-40 pb-[env(safe-area-inset-bottom)] reader-glass\"><!-- Reflowable row --><div x-show=\"!isFixedLayout\" class=\"flex flex-wrap items-center gap-x-1 gap-y-1 px-1.5 py-1.5 sm:px-2 sm:py-2 sm:gap-1\"><div class=\"flex items-center gap-0.5 grow min-w-[190px]\"><button @click=\"goLeft()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Left\" aria-label=\"Go left\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 15 6 L 9 12 L 15 18\"></path></svg></button> <button x-show=\"backStack.length > 0\" @click=\"goBackToLocation()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Return to previous location (Alt+←)\" aria-label=\"Return to previous location\"><svg class=\"reader-icon\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M 12 21 C 9 18 5 14.4 5 9.5 C 5 6.5 8 4 12 4 C 16 4 19 6.5 19 9.5 C 19 14.4 15 18 12 21 Z\"></path> <circle cx=\"12\" cy=\"9.5\" r=\"2\"></circle></svg></button> <input id=\"progress-slider\" type=\"range\" min=\"0\" max=\"1\" step=\"any\" list=\"tick-marks\" @input=\"goToFraction($event.target.value)\" @pointerup=\"$el.blur()\" class=\"grow\"> <datalist id=\"tick-marks\"></datalist> <button @click=\"goRight()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Right\" aria-label=\"Go right\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 9 6 L 15 12 L 9 18\"></path></svg></button></div><div class=\"flex items-center gap-1\"><div class=\"w-px h-6 reader-sep\"></div><div id=\"progress-display\" @click=\"cycleProgressMode()\" :title=\"progressTooltip()\" class=\"text-sm min-w-[4rem] max-w-[5rem] sm:max-w-none text-center cursor-pointer truncate whitespace-nowrap overflow-hidden\"><span class=\"hidden sm:inline\" x-text=\"progressLabel\"></span><span x-text=\"progressMain\">—</span></div><div class=\"w-px h-6 reader-sep\"></div><button @click=\"toggleTOC()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Table of Contents (t)\">📖</button></div></div><!-- Fixed-layout row (comics, PDFs, manga) --><div x-show=\"isFixedLayout\"><!-- Full toolbar: desktop/tablet (>= 768px, md). Wraps gracefully in between. --><div class=\"hidden md:flex flex-wrap items-center gap-x-1 gap-y-1 px-1.5 py-1.5 sm:px-2 sm:py-2 sm:gap-1\"><div class=\"flex items-center gap-0.5\"><button @click=\"goLeft()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Left (←)\" aria-label=\"Go left\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 15 6 L 9 12 L 15 18\"></path></svg></button> <button x-show=\"backStack.length > 0\" @click=\"goBackToLocation()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Return to previous location (Alt+←)\" aria-label=\"Return to previous location\"><svg class=\"reader-icon\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M 12 21 C 9 18 5 14.4 5 9.5 C 5 6.5 8 4 12 4 C 16 4 19 6.5 19 9.5 C 19 14.4 15 18 12 21 Z\"></path> <circle cx=\"12\" cy=\"9.5\" r=\"2\"></circle></svg></button></div><input id=\"progress-slider-fx\" type=\"range\" min=\"0\" max=\"1\" step=\"any\" @input=\"goToFraction($event.target.value)\" @pointerup=\"$el.blur()\" class=\"grow min-w-[3rem]\"> <button @click=\"goRight()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Right (→)\" aria-label=\"Go right\"><svg class=\"reader-iLine truncated
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -230,7 +230,7 @@ func drawerHeader(title string) templ.Component {
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 580, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 583, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
+7
View File
@@ -17,8 +17,15 @@ type User struct {
CreatedAt time.Time
Token string
Timezone string
// User-level default audiobook playback speed (plan §8); 0 means
// "unset" and renders as the 1× option.
DefaultAudioSpeed float64
}
// SpeedOption is one entry of the default-playback-speed select — the
// server-side mirror of the web player's SPEEDS list (plan §5/§8).
var SpeedOptions = []float64{0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3}
type PageData struct {
User User
CurrentPage string
+1 -1
View File
@@ -2,7 +2,7 @@
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"lib": ["ES2020", "DOM"],
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"outDir": "./web/static",
"rootDir": "./web/src",
"moduleResolution": "bundler",
+22 -6
View File
@@ -1,4 +1,4 @@
import { defineConfig } from "vite";
import { defineConfig } from "vite-plus";
import { cpSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import { join } from "node:path";
@@ -6,8 +6,7 @@ const pdfjsAssets = () => ({
name: "pdfjs-assets",
closeBundle() {
const outDir = "web/static/vendor/pdfjs";
const srcDir =
"node_modules/@bookhoard/foliate-js/vendor/pdfjs";
const srcDir = "node_modules/@bookhoard/foliate-js/vendor/pdfjs";
mkdirSync(outDir, { recursive: true });
cpSync(join(srcDir, "standard_fonts"), join(outDir, "standard_fonts"), {
recursive: true,
@@ -19,7 +18,14 @@ const pdfjsAssets = () => ({
// emptyOutDir must stay false (web/static also holds tracked assets),
// so hashed chunks from previous builds would otherwise accumulate
// forever and leak into Docker images via the build context. Remove
// any *-<hash>.js(.map) that this build did not produce.
// any *-<hash>.js/.css/.mjs(.map) that this build did not produce.
//
// The produced.size === 0 guard is load-bearing: this config also drives
// `vp test`, and vitest runs closeBundle with generateBundle never having
// fired — an empty produced set means "not a build run", and sweeping
// then deleted every live chunk (the 2026-10-21 incident where
// `npm run test` gutted web/static down to the three entry files and
// every page 404'd its imports).
const cleanStaleChunks = () => {
const produced = new Set<string>();
return {
@@ -28,8 +34,9 @@ const cleanStaleChunks = () => {
for (const fileName of Object.keys(bundle)) produced.add(fileName);
},
closeBundle() {
if (produced.size === 0) return;
const outDir = "web/static";
const chunkRe = /-[A-Za-z0-9_-]{8}\.js(\.map)?$/;
const chunkRe = /-[A-Za-z0-9_-]{8}\.(js|css|mjs)(\.map)?$/;
for (const f of readdirSync(outDir)) {
if (chunkRe.test(f) && !produced.has(f)) {
rmSync(join(outDir, f));
@@ -40,6 +47,15 @@ const cleanStaleChunks = () => {
};
export default defineConfig({
// web/static holds vendored and build-output assets (htmx.min.js,
// compiled css) that must never be reformatted; only web/src is fmt scope.
fmt: {
ignorePatterns: ["web/static/**"],
},
test: {
environment: "jsdom",
include: ["web/src/**/*.test.ts"],
},
resolve: {
alias: {
"@": "/web/src",
@@ -63,6 +79,6 @@ export default defineConfig({
},
target: "esnext",
sourcemap: true,
minify: "esbuild",
minify: true,
},
});
+16 -53
View File
@@ -57,13 +57,8 @@ async function scanAllLibraries(): Promise<void> {
}
}
function showScanProgress(
jobIds: string[],
libraryNames: Record<string, string>,
): void {
const container = document.getElementById(
"scan-progress-container",
) as HTMLElement;
function showScanProgress(jobIds: string[], libraryNames: Record<string, string>): void {
const container = document.getElementById("scan-progress-container") as HTMLElement;
const list = document.getElementById("library-progress-list") as HTMLElement;
if (!container || !list) return;
@@ -113,10 +108,7 @@ function showScanProgress(
let scanPollInterval: ReturnType<typeof setInterval> | undefined = undefined;
function pollScanProgress(
jobIds: string[],
_libraryNames: Record<string, string>,
): void {
function pollScanProgress(jobIds: string[], _libraryNames: Record<string, string>): void {
const token = localStorage.getItem("token");
const startTime = Date.now();
const cancelledJobs = new Set<string>();
@@ -168,14 +160,9 @@ function pollScanProgress(
}
// status.progress arrives 0-1 from the server; render as percent.
const overallProgress =
progressJobs > 0 ? Math.round((totalProgress / progressJobs) * 100) : 0;
const progressBar = document.getElementById(
"scan-progress-bar",
) as HTMLElement;
const progressText = document.getElementById(
"scan-progress-text",
) as HTMLElement;
const overallProgress = progressJobs > 0 ? Math.round((totalProgress / progressJobs) * 100) : 0;
const progressBar = document.getElementById("scan-progress-bar") as HTMLElement;
const progressText = document.getElementById("scan-progress-text") as HTMLElement;
const statusText = document.getElementById("scan-status") as HTMLElement;
if (progressBar) progressBar.style.width = overallProgress + "%";
@@ -241,9 +228,7 @@ async function cancelScan(jobId: string): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
const btn = document.querySelector<HTMLButtonElement>(
`[data-cancel-job="${jobId}"]`,
);
const btn = document.querySelector<HTMLButtonElement>(`[data-cancel-job="${jobId}"]`);
if (btn) {
btn.disabled = true;
btn.textContent = "Cancelling...";
@@ -260,10 +245,7 @@ async function cancelScan(jobId: string): Promise<void> {
}
showToast("Scan cancellation requested", "success");
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to cancel scan",
"error",
);
showToast(e instanceof Error ? e.message : "Failed to cancel scan", "error");
if (btn) {
btn.disabled = false;
btn.textContent = "Cancel";
@@ -291,10 +273,7 @@ async function cancelAllScans(): Promise<void> {
await Promise.allSettled(jobs.map((job) => cancelScan(job.job_id)));
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to cancel scans",
"error",
);
showToast(e instanceof Error ? e.message : "Failed to cancel scans", "error");
}
}
@@ -361,9 +340,7 @@ function showScanResults(
cancelled: number,
): void {
const resultsDiv = document.getElementById("scan-results") as HTMLElement;
const contentDiv = document.getElementById(
"scan-results-content",
) as HTMLElement;
const contentDiv = document.getElementById("scan-results-content") as HTMLElement;
if (!resultsDiv || !contentDiv) return;
@@ -381,16 +358,12 @@ function showScanResults(
const statusText = document.getElementById("scan-status") as HTMLElement;
if (statusText) {
statusText.textContent =
cancelled > 0
? "Scan finished (some scans were cancelled)"
: "Scan complete!";
cancelled > 0 ? "Scan finished (some scans were cancelled)" : "Scan complete!";
}
}
function hideScanProgress(): void {
const container = document.getElementById(
"scan-progress-container",
) as HTMLElement;
const container = document.getElementById("scan-progress-container") as HTMLElement;
if (container) container.classList.add("hidden");
setCancelAllVisible(false);
}
@@ -447,16 +420,10 @@ async function purgeArchivedItems(): Promise<void> {
throw new Error(err.error || "Failed to purge archived items");
}
const data = await resp.json();
showToast(
`Purged ${data.purged} archived item${data.purged === 1 ? "" : "s"}`,
"success",
);
showToast(`Purged ${data.purged} archived item${data.purged === 1 ? "" : "s"}`, "success");
setTimeout(() => window.location.reload(), 700);
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to purge archived items",
"error",
);
showToast(e instanceof Error ? e.message : "Failed to purge archived items", "error");
}
}
@@ -551,9 +518,7 @@ document.querySelectorAll<HTMLElement>("[data-unarchive]").forEach((el) => {
el.addEventListener("click", () => unarchiveItem(el.dataset.unarchive || ""));
});
document.querySelectorAll<HTMLElement>("[data-delete-archived]").forEach((el) => {
el.addEventListener("click", () =>
deleteArchivedItem(el.dataset.deleteArchived || ""),
);
el.addEventListener("click", () => deleteArchivedItem(el.dataset.deleteArchived || ""));
});
document.querySelectorAll<HTMLElement>("[data-merge-source]").forEach((el) => {
el.addEventListener("click", () =>
@@ -631,9 +596,7 @@ function bindDeleteLibraryModalClose(): void {
?.addEventListener("htmx:afterRequest", function (evt) {
const detail = (evt as CustomEvent).detail as { successful?: boolean };
if (detail.successful) {
document
.getElementById("delete-library-modal")
?.classList.add("hidden");
document.getElementById("delete-library-modal")?.classList.add("hidden");
}
});
}
+3 -4
View File
@@ -1,12 +1,11 @@
import Alpine from "alpinejs";
// Extend Window interface to include Alpine
// Extend Window interface to include Alpine. The `htmx` member is declared
// once in shims.d.ts — redeclaring it here with a different shape is a
// compile error (TS2717) and split-brained typing.
declare global {
interface Window {
Alpine: typeof Alpine;
htmx: {
trigger: (element: HTMLElement | string, event: string) => void;
};
}
}
+10 -7
View File
@@ -43,7 +43,10 @@ interface AnnotationSource {
}
/** Friendly human location label; mirrors the app's positionHint(). */
function friendlyAnnotationLabel(position: string | null | undefined, fallbackText: string): string {
function friendlyAnnotationLabel(
position: string | null | undefined,
fallbackText: string,
): string {
const raw = (position ?? "").trim();
if (raw) {
// PDF/comic anchors: "page:N"
@@ -61,9 +64,12 @@ function friendlyAnnotationLabel(position: string | null | undefined, fallbackTe
}
}
// Spec fallback: the text's first few words (never a raw locator).
const words = stripNote(fallbackText || "").replace(/\s+/g, " ").trim();
const words = stripNote(fallbackText || "")
.replace(/\s+/g, " ")
.trim();
if (words) {
const cut = words.length > 42 ? words.slice(0, words.lastIndexOf(" ", 42)) || words.slice(0, 42) : words;
const cut =
words.length > 42 ? words.slice(0, words.lastIndexOf(" ", 42)) || words.slice(0, 42) : words;
return cut;
}
return "Location";
@@ -214,10 +220,7 @@ function buildAnnotationRows(
}
function escapeHtmlForRich(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
/** The export item text — identical in single and multi modes. */
+6 -18
View File
@@ -16,9 +16,7 @@ function showDocMode(mode: "mock" | "real"): void {
const responseStatus = document.getElementById("response-status");
const responseTime = document.getElementById("response-time");
const apiResponse = document.querySelector(".api-response");
const requestBody = document.getElementById(
"request-body",
) as HTMLTextAreaElement;
const requestBody = document.getElementById("request-body") as HTMLTextAreaElement;
const tryItOut = document.getElementById("try-it-out");
if (
@@ -58,12 +56,8 @@ function showDocMode(mode: "mock" | "real"): void {
}
async function tryDocEndpoint(): Promise<void> {
const methodSelect = document.getElementById(
"http-method",
) as HTMLSelectElement;
const requestBody = document.getElementById(
"request-body",
) as HTMLTextAreaElement;
const methodSelect = document.getElementById("http-method") as HTMLSelectElement;
const requestBody = document.getElementById("request-body") as HTMLTextAreaElement;
const responseBody = document.getElementById("response-body");
const responseStatus = document.getElementById("response-status");
const responseTime = document.getElementById("response-time");
@@ -113,9 +107,7 @@ async function tryDocEndpoint(): Promise<void> {
}
function copyDocRequest(): void {
const requestBody = document.getElementById(
"request-body",
) as HTMLTextAreaElement;
const requestBody = document.getElementById("request-body") as HTMLTextAreaElement;
if (requestBody) {
navigator.clipboard.writeText(requestBody.value);
}
@@ -129,12 +121,8 @@ function copyDocResponse(): void {
}
function generateDocCURL(): void {
const methodSelect = document.getElementById(
"http-method",
) as HTMLSelectElement;
const requestBody = document.getElementById(
"request-body",
) as HTMLTextAreaElement;
const methodSelect = document.getElementById("http-method") as HTMLSelectElement;
const requestBody = document.getElementById("request-body") as HTMLTextAreaElement;
if (!methodSelect || !requestBody) return;
+6 -18
View File
@@ -10,13 +10,9 @@ interface ApiExplorerRequest {
const requestHistory: ApiExplorerRequest[] = [];
function sendApiRequest(): void {
const method =
(document.getElementById("api-method") as HTMLSelectElement)?.value ||
"GET";
const endpoint =
(document.getElementById("api-endpoint") as HTMLInputElement)?.value || "";
const bodyText =
(document.getElementById("api-body") as HTMLTextAreaElement)?.value || "";
const method = (document.getElementById("api-method") as HTMLSelectElement)?.value || "GET";
const endpoint = (document.getElementById("api-endpoint") as HTMLInputElement)?.value || "";
const bodyText = (document.getElementById("api-body") as HTMLTextAreaElement)?.value || "";
const token = localStorage.getItem("token");
@@ -64,11 +60,7 @@ function sendApiRequest(): void {
});
}
function displayResponse(
response: Response,
data: unknown,
duration: number,
): void {
function displayResponse(response: Response, data: unknown, duration: number): void {
const container = document.getElementById("api-response");
if (!container) return;
@@ -150,12 +142,8 @@ function loadFromHistory(index: number): void {
const request = requestHistory[index];
if (!request) return;
const methodSelect = document.getElementById(
"api-method",
) as HTMLSelectElement;
const endpointInput = document.getElementById(
"api-endpoint",
) as HTMLInputElement;
const methodSelect = document.getElementById("api-method") as HTMLSelectElement;
const endpointInput = document.getElementById("api-endpoint") as HTMLInputElement;
const bodyInput = document.getElementById("api-body") as HTMLTextAreaElement;
if (methodSelect) methodSelect.value = request.method;
+89
View File
@@ -0,0 +1,89 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getReadingProgress, handleResponse, handleVoidResponse } from "./api";
// api.ts registers an Alpine component at import time; the toast import it
// carries installs a fetch interceptor. Neither interferes with the stubbed
// global fetch below, but both are exercised by importing the module.
const jsonResponse = (body: unknown, status = 200): Response =>
new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
describe("handleResponse", () => {
it("parses ok JSON bodies", async () => {
await expect(handleResponse(jsonResponse({ ok: true }))).resolves.toEqual({ ok: true });
});
it("throws the server's error message", async () => {
await expect(handleResponse(jsonResponse({ error: "Nope" }, 404))).rejects.toThrow("Nope");
});
it("falls back to the HTTP status when the body has no message", async () => {
await expect(handleResponse(jsonResponse({ error: "" }, 500))).rejects.toThrow("HTTP 500");
});
it("falls back to Unknown error when the error body is not JSON", async () => {
await expect(handleResponse(new Response("<html>502</html>", { status: 502 }))).rejects.toThrow(
"Unknown error",
);
});
});
describe("handleVoidResponse", () => {
it("resolves on ok regardless of body", async () => {
await expect(
handleVoidResponse(new Response("ignored", { status: 200 })),
).resolves.toBeUndefined();
});
it("throws the server's error message", async () => {
await expect(handleVoidResponse(jsonResponse({ error: "Denied" }, 403))).rejects.toThrow(
"Denied",
);
});
});
describe("getReadingProgress", () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("returns null on 404 (no progress yet)", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => jsonResponse({ error: "none" }, 404)),
);
await expect(getReadingProgress("m1")).resolves.toBeNull();
});
it("returns the progress payload otherwise", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
jsonResponse({ current_page: 3, total_pages: 10, last_read_at: "2026-01-01T00:00:00Z" }),
),
);
const progress = await getReadingProgress("m1");
expect(progress).toMatchObject({ current_page: 3 });
});
it("sends the auth header when a token is stored", async () => {
localStorage.setItem("token", "tok-9");
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) =>
jsonResponse({ error: "none" }, 404),
);
vi.stubGlobal("fetch", fetchMock);
await getReadingProgress("m1");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({ Authorization: "Bearer tok-9" });
});
});
+6 -19
View File
@@ -37,10 +37,7 @@ async function apiPut(url: string, data?: unknown): Promise<Response> {
});
}
async function apiDelete<T extends object>(
url: string,
data?: T,
): Promise<Response> {
async function apiDelete<T extends object>(url: string, data?: T): Promise<Response> {
return fetch(`/api${url}`, {
method: "DELETE",
headers: {
@@ -64,9 +61,7 @@ async function apiPatch(url: string, data?: unknown): Promise<Response> {
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ error: "Unknown error" }));
const errorData = await response.json().catch(() => ({ error: "Unknown error" }));
throw new Error(errorData.error || `HTTP ${response.status}`);
}
return response.json();
@@ -74,17 +69,14 @@ async function handleResponse<T>(response: Response): Promise<T> {
async function handleVoidResponse(response: Response): Promise<void> {
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ error: "Unknown error" }));
const errorData = await response.json().catch(() => ({ error: "Unknown error" }));
throw new Error(errorData.error || `HTTP ${response.status}`);
}
}
function handleError(error: unknown, context: string): void {
console.error(`${context}:`, error);
const message =
error instanceof Error ? error.message : "An unexpected error occurred";
const message = error instanceof Error ? error.message : "An unexpected error occurred";
showToast(message, "error");
}
@@ -106,16 +98,11 @@ async function updateReadingProgress(
mediaItemId: string,
progress: ReadingProgress,
): Promise<void> {
const response = await apiPut(
`/media-items/${mediaItemId}/progress`,
progress,
);
const response = await apiPut(`/media-items/${mediaItemId}/progress`, progress);
await handleVoidResponse(response);
}
async function getReadingProgress(
mediaItemId: string,
): Promise<ReadingProgress | null> {
async function getReadingProgress(mediaItemId: string): Promise<ReadingProgress | null> {
const response = await apiGet(`/media-items/${mediaItemId}/progress`);
if (response.status === 404) {
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import { KEYMAP, resolveShortcut, visibleKeymap } from "./audio-keys";
describe("resolveShortcut", () => {
it("resolves every in-page binding", () => {
expect(resolveShortcut(" ", false)).toBe("toggle-play");
expect(resolveShortcut("Enter", false)).toBe("toggle-play");
expect(resolveShortcut("h", false)).toBe("skip-back");
expect(resolveShortcut("H", false)).toBe("skip-back");
expect(resolveShortcut("ArrowLeft", false)).toBe("skip-back");
expect(resolveShortcut("l", false)).toBe("skip-forward");
expect(resolveShortcut("ArrowRight", false)).toBe("skip-forward");
expect(resolveShortcut("k", false)).toBe("prev-chapter");
expect(resolveShortcut("PageUp", false)).toBe("prev-chapter");
expect(resolveShortcut("j", false)).toBe("next-chapter");
expect(resolveShortcut("PageDown", false)).toBe("next-chapter");
expect(resolveShortcut("b", false)).toBe("bookmark");
expect(resolveShortcut("ArrowUp", false)).toBe("volume-up");
expect(resolveShortcut("ArrowDown", false)).toBe("volume-down");
expect(resolveShortcut("m", false)).toBe("toggle-mute");
expect(resolveShortcut("]", false)).toBe("cycle-skip-up");
expect(resolveShortcut("[", false)).toBe("cycle-skip-down");
expect(resolveShortcut("?", false)).toBe("toggle-help");
expect(resolveShortcut("Escape", false)).toBe("escape");
});
it("keeps Alt bindings distinct from their plain counterparts", () => {
expect(resolveShortcut("l", true)).toBe("speed-up");
expect(resolveShortcut("l", false)).toBe("skip-forward");
expect(resolveShortcut("h", true)).toBe("speed-down");
expect(resolveShortcut("ArrowRight", true)).toBe("speed-up");
expect(resolveShortcut("ArrowLeft", true)).toBe("speed-down");
// Alt on an unbound key claims nothing.
expect(resolveShortcut("ArrowUp", true)).toBeNull();
expect(resolveShortcut("m", true)).toBeNull();
});
it("resolves in-page media key events", () => {
expect(resolveShortcut("MediaPlayPause", false)).toBe("media-toggle-play");
expect(resolveShortcut("MediaPlay", false)).toBe("media-play");
expect(resolveShortcut("MediaPause", false)).toBe("media-pause");
expect(resolveShortcut("MediaStop", false)).toBe("media-pause");
expect(resolveShortcut("MediaTrackNext", false)).toBe("media-next");
expect(resolveShortcut("MediaTrackPrevious", false)).toBe("media-prev");
});
it("returns null for unknown keys", () => {
expect(resolveShortcut("x", false)).toBeNull();
expect(resolveShortcut("F5", false)).toBeNull();
expect(resolveShortcut("", false)).toBeNull();
});
});
describe("KEYMAP integrity", () => {
it("has exactly one spec per action", () => {
const actions = KEYMAP.map((spec) => spec.action);
expect(new Set(actions).size).toBe(actions.length);
});
it("has no conflicting (key, alt) combos across actions", () => {
const seen = new Map<string, string>();
for (const spec of KEYMAP) {
for (const k of spec.keys) {
const combo = `${k.alt ? "Alt+" : ""}${k.key}`;
const owner = seen.get(combo);
expect(owner, `${combo} bound by both ${owner} and ${spec.action}`).toBeUndefined();
seen.set(combo, spec.action);
}
}
});
it("gives every spec at least one key, a label and a description", () => {
for (const spec of KEYMAP) {
expect(spec.keys.length, spec.action).toBeGreaterThan(0);
expect(spec.label.length, spec.action).toBeGreaterThan(0);
expect(spec.description.length, spec.action).toBeGreaterThan(0);
}
});
it("hides only media-key rows from the dialog", () => {
const visible = new Set(visibleKeymap().map((spec) => spec.action));
for (const spec of KEYMAP) {
if (spec.action.startsWith("media-")) {
expect(visible.has(spec.action), spec.action).toBe(false);
} else {
expect(visible.has(spec.action), spec.action).toBe(true);
}
}
});
});
+258
View File
@@ -0,0 +1,258 @@
// Declarative keyboard map for the audio player (player plan §2).
// ONE table drives BOTH the document keydown dispatcher (audio.ts
// resolves events through resolveShortcut) and the templ-rendered
// shortcut dialog (x-for over visibleKeymap). Never hardcode a key
// binding in the handler or a row in the dialog — add it here.
//
// Hardware media keys are a separate layer: they ride the existing
// MediaSession action handlers even when the tab is unfocused. The
// Media* entries below cover browsers that ALSO deliver them as
// in-page keydown events; they resolve to the same methods the
// MediaSession handlers call (togglePlay / stepChapter).
export type KeyAction =
| "toggle-play"
| "skip-back"
| "skip-forward"
| "prev-chapter"
| "next-chapter"
| "bookmark"
| "speed-up"
| "speed-down"
| "volume-up"
| "volume-down"
| "toggle-mute"
| "cycle-skip-up"
| "cycle-skip-down"
| "toggle-help"
| "escape"
| "media-toggle-play"
| "media-play"
| "media-pause"
| "media-next"
| "media-prev";
export interface KeySpec {
// e.key value to match. Single characters are matched
// case-insensitively by resolveShortcut; multi-character key names
// ("Enter", "ArrowLeft", "MediaPlayPause") must match verbatim.
key: string;
// Requires the Alt modifier. Alt-qualified entries disambiguate the
// plain binding (Alt+L = speed up while L = skip forward).
alt?: boolean;
// Dialog badge label, e.g. "Space", "←", "Alt + →".
label: string;
}
export interface KeyActionSpec {
action: KeyAction;
// Dialog row label.
label: string;
description: string;
keys: readonly KeySpec[];
// Media-key entries are resolved but not shown in the dialog —
// hardware keys, not something a keyboard user types.
hidden?: boolean;
}
export const KEYMAP: readonly KeyActionSpec[] = [
{
action: "toggle-play",
label: "Play / pause",
description: "Play or pause (only when no control is focused)",
keys: [
{ key: " ", label: "Space" },
{ key: "Enter", label: "Enter" },
],
},
{
action: "skip-back",
label: "Skip back",
description: "Skip back by the skip duration",
keys: [
{ key: "h", label: "H" },
{ key: "ArrowLeft", label: "←" },
],
},
{
action: "skip-forward",
label: "Skip forward",
description: "Skip forward by the skip duration",
keys: [
{ key: "l", label: "L" },
{ key: "ArrowRight", label: "→" },
],
},
{
action: "prev-chapter",
label: "Previous chapter",
description: "Previous chapter",
keys: [
{ key: "k", label: "K" },
{ key: "PageUp", label: "PgUp" },
],
},
{
action: "next-chapter",
label: "Next chapter",
description: "Next chapter",
keys: [
{ key: "j", label: "J" },
{ key: "PageDown", label: "PgDn" },
],
},
{
action: "bookmark",
label: "Bookmark",
description: "Bookmark this position — Enter saves, Shift+Enter newline",
keys: [{ key: "b", label: "B" }],
},
{
action: "speed-up",
label: "Speed up",
description: "Step playback speed up",
keys: [
{ key: "ArrowRight", alt: true, label: "Alt + →" },
{ key: "l", alt: true, label: "Alt + L" },
],
},
{
action: "speed-down",
label: "Speed down",
description: "Step playback speed down",
keys: [
{ key: "ArrowLeft", alt: true, label: "Alt + ←" },
{ key: "h", alt: true, label: "Alt + H" },
],
},
{
action: "volume-up",
label: "Volume up",
description: "Volume up",
keys: [{ key: "ArrowUp", label: "↑" }],
},
{
action: "volume-down",
label: "Volume down",
description: "Volume down",
keys: [{ key: "ArrowDown", label: "↓" }],
},
{
action: "toggle-mute",
label: "Mute",
description: "Mute or unmute",
keys: [{ key: "m", label: "M" }],
},
{
action: "cycle-skip-up",
label: "Longer skip",
description: "Cycle the skip duration up",
keys: [{ key: "]", label: "]" }],
},
{
action: "cycle-skip-down",
label: "Shorter skip",
description: "Cycle the skip duration down",
keys: [{ key: "[", label: "[" }],
},
{
action: "toggle-help",
label: "Shortcuts",
description: "Show or hide this dialog",
keys: [{ key: "?", label: "?" }],
},
{
action: "escape",
label: "Close",
description: "Close dialog, cancel bookmark, close panels",
keys: [{ key: "Escape", label: "Esc" }],
},
// Media keys: same actions the MediaSession handlers expose.
{
action: "media-toggle-play",
label: "Media play/pause key",
description: "Hardware media play/pause",
keys: [{ key: "MediaPlayPause", label: "Media ⏯" }],
hidden: true,
},
{
action: "media-play",
label: "Media play key",
description: "Hardware media play",
keys: [{ key: "MediaPlay", label: "Media ▶" }],
hidden: true,
},
{
action: "media-pause",
label: "Media pause/stop key",
description: "Hardware media pause or stop",
keys: [
{ key: "MediaPause", label: "Media ⏸" },
{ key: "MediaStop", label: "Media ⏹" },
],
hidden: true,
},
{
action: "media-next",
label: "Media next key",
description: "Hardware media next (chapter step, like the notification)",
keys: [{ key: "MediaTrackNext", label: "Media ⏭" }],
hidden: true,
},
{
action: "media-prev",
label: "Media previous key",
description: "Hardware media previous (chapter step, like the notification)",
keys: [{ key: "MediaTrackPrevious", label: "Media ⏮" }],
hidden: true,
},
];
// visibleKeymap filters out the hardware-media rows for the dialog.
export function visibleKeymap(): KeyActionSpec[] {
return KEYMAP.filter((spec) => !spec.hidden);
}
// Pointer/wheel/touch interactions (plan §9) are not key events — the
// dispatcher never sees them — but they belong in the same dialog so
// every scrub gesture is discoverable in one place. TWO sections,
// because the bar really does have two scrub systems (plan §12): the
// desktop-pointer family and the touch family. templ x-fors these
// through the component's `mouseGuide`/`touchGuide` properties (same
// binding pattern as speeds/skipChoices).
export interface PointerGuideRow {
// Badge label, e.g. "Shift + drag".
label: string;
description: string;
}
export const MOUSE_GUIDE: readonly PointerGuideRow[] = [
{ label: "Click", description: "Seek — lands on a chapter line within a pixel's reach" },
{ label: "Drag", description: "Free scrub across the whole bar" },
{ label: "Shift + click", description: "Jump to the nearest chapter line" },
{ label: "Shift + drag", description: "Precision rail (±5 min) around the nearest line" },
{ label: "Wheel", description: "Nudge ±5s (Shift: ±60s)" },
];
export const TOUCH_GUIDE: readonly PointerGuideRow[] = [
{ label: "Drag", description: "Free scrub across the whole bar" },
{
label: "Pull away",
description: "Drag off the bar while holding — the further you pull, the finer it gets",
},
];
// resolveShortcut maps a keydown's (key, altKey) to an action, or null
// when nothing claims it. Single characters match case-insensitively;
// Alt-qualified bindings take precedence order for free: a plain key
// never matches an alt binding and vice versa, so L and Alt+L are
// distinct by construction.
export function resolveShortcut(key: string, altKey: boolean): KeyAction | null {
const normalized = key.length === 1 ? key.toLowerCase() : key;
for (const spec of KEYMAP) {
for (const k of spec.keys) {
if (k.key === normalized && (k.alt ?? false) === altKey) return spec.action;
}
}
return null;
}
+331
View File
@@ -0,0 +1,331 @@
import { describe, expect, it } from "vitest";
import {
applyDrag,
bufferedAheadEnd,
bufferedSpanPct,
chapterIndexFor,
denseTicks,
dragScale,
DRAG_REF_PX,
DRAG_SCALE_MIN,
nearestChapterLine,
railLiftPx,
railSecondsAtPointer,
railTicks,
railWindow,
RAIL_SPAN,
secondsAtPointer,
snapToChapterPx,
TOUCH_RAIL_LIFT_MAX_PX,
TOUCH_RAIL_MIN_DY_PX,
touchRailSpan,
} from "./audio-scrubber";
// An 86-hour book (KJV-shaped: 1189 even chapters) and a 26-hour
// single-file book (Gardens-shaped: 34 chapters).
const duration86h = 310_320;
const chapters1189 = Array.from({ length: 1189 }, (_, i) => ({ start: (i * duration86h) / 1189 }));
const duration26h = 93_807;
const chapters34 = Array.from({ length: 34 }, (_, i) => ({
start: (i * duration26h) / 34,
title: `Chapter ${i + 1}`,
}));
const rect = (width: number): DOMRect =>
({
left: 100,
right: 100 + width,
width,
top: 0,
bottom: 10,
height: 10,
x: 100,
y: 0,
toJSON: () => ({}),
}) as DOMRect;
describe("secondsAtPointer", () => {
it("maps the bar linearly and clamps outside it", () => {
const r = rect(800);
expect(secondsAtPointer(100, r, duration86h)).toBe(0);
expect(secondsAtPointer(500, r, duration86h)).toBeCloseTo(duration86h / 2, 5);
expect(secondsAtPointer(900, r, duration86h)).toBe(duration86h);
expect(secondsAtPointer(50, r, duration86h)).toBe(0);
expect(secondsAtPointer(1200, r, duration86h)).toBe(duration86h);
});
it("is safe with degenerate rects and durations", () => {
expect(secondsAtPointer(150, rect(0), duration86h)).toBe(0);
expect(secondsAtPointer(150, rect(800), 0)).toBe(0);
});
});
describe("chapterIndexFor", () => {
it("finds the containing chapter in both shapes", () => {
expect(chapterIndexFor(chapters1189, 0)).toBe(0);
expect(chapterIndexFor(chapters1189, chapters1189[500].start + 1)).toBe(500);
expect(chapterIndexFor(chapters34, chapters34[33].start + 60)).toBe(33);
expect(chapterIndexFor(chapters34, duration26h + 5000)).toBe(33);
});
it("returns -1 before the first chapter and for none at all", () => {
expect(chapterIndexFor(chapters1189, -1)).toBe(-1);
expect(chapterIndexFor([], 100)).toBe(-1);
});
});
describe("nearestChapterLine / snapToChapterPx (§9 pixel magnetism)", () => {
// 34-chapter book on an 800px bar: ~23.5px between lines — the
// radius discriminates. (On the KJV-shaped bar the 1189 lines sit
// ~0.4px apart — magnetism lands on SOME line from anywhere, which
// is correct for a book that dense.)
const pxSec = 800 / duration26h; // bar px per second
it("reports the closest line and its distance in bar pixels", () => {
const start = chapters34[5].start;
const near = nearestChapterLine(start + 5 / pxSec, chapters34, duration26h, 800);
expect(near!.index).toBe(5);
expect(near!.seconds).toBe(start);
expect(near!.distancePx).toBeCloseTo(5, 5);
expect(nearestChapterLine(42, [], duration26h, 800)).toBeNull();
});
it("finds sub-pixel lines on a book as dense as the KJV", () => {
// 1px ≈ 633s — a seconds threshold could never fire here; a pixel
// radius always can. This is the §9 root-cause fix.
const dense = nearestChapterLine(duration86h / 2, chapters1189, duration86h, 489);
expect(dense).not.toBeNull();
expect(dense!.distancePx).toBeLessThan(0.25);
});
it("snaps within the pixel radius and not beyond it", () => {
const start = chapters34[5].start;
expect(snapToChapterPx(start + 9 / pxSec, chapters34, duration26h, 800)).toEqual({
seconds: start,
snapped: true,
});
expect(snapToChapterPx(start + 11 / pxSec, chapters34, duration26h, 800).snapped).toBe(false);
expect(snapToChapterPx(start - 10 / pxSec, chapters34, duration26h, 800)).toEqual({
seconds: start,
snapped: true,
});
});
it("force snaps to the nearest line regardless of radius (Shift+click)", () => {
const a = chapters34[5].start;
const b = chapters34[6].start;
const mid = (a + b) / 2;
// Radius 0 + force must land where a wide radius lands: distance
// is ignored, nearest wins (mid+1 → the later line).
expect(snapToChapterPx(mid + 1, chapters34, duration26h, 800, 0, true)).toEqual({
seconds: b,
snapped: true,
});
// Past the book's last line, the nearest line is still reachable.
const last = chapters34[33].start;
expect(snapToChapterPx(last + 1000 / pxSec, chapters34, duration26h, 800, 0, true)).toEqual({
seconds: last,
snapped: true,
});
});
it("picks the NEAREST line when two are in radius", () => {
const a = chapters34[5].start;
const b = chapters34[6].start;
const mid = (a + b) / 2;
// Both lines sit ~11.8px from the midpoint; inside a 13px radius
// the closer (later) one must win.
const result = snapToChapterPx(mid + 1, chapters34, duration26h, 800, 13);
expect(result.snapped).toBe(true);
expect(result.seconds).toBe(b);
});
it("leaves the position alone with no chapters or a dead bar", () => {
expect(snapToChapterPx(42, [], duration26h, 800)).toEqual({ seconds: 42, snapped: false });
expect(snapToChapterPx(42, chapters34, duration26h, 0).snapped).toBe(false);
});
});
describe("dragScale / applyDrag (§9 touch progressive precision)", () => {
it("is 1:1 at the bar and decays an order per reference distance", () => {
expect(dragScale(0)).toBe(1);
expect(dragScale(DRAG_REF_PX)).toBeCloseTo(0.1, 5);
expect(dragScale(2 * DRAG_REF_PX)).toBeCloseTo(0.01, 5);
});
it("is symmetric in pull direction and monotonic in |dy|", () => {
expect(dragScale(-60)).toBe(dragScale(60));
expect(dragScale(30)).toBeGreaterThan(dragScale(60));
expect(dragScale(60)).toBeGreaterThan(dragScale(120));
expect(dragScale(10_000)).toBe(DRAG_SCALE_MIN);
});
it("degenerates to plain travel with a dead reference", () => {
expect(dragScale(500, 0)).toBe(1);
});
it("advances the cursor 1:1 at dy=0 (px → seconds at bar scale)", () => {
// 800px bar over 26h: 100px ≈ 11726s.
expect(applyDrag(0, 100, 0, 800 / duration26h, duration26h)).toBeCloseTo(
(100 / 800) * duration26h,
5,
);
});
it("shrinks travel by the current zoom (incremental, not absolute)", () => {
const pxPerSecond = 800 / duration26h;
const onePxSeconds = 1 / pxPerSecond;
// One reference-distance of pull = one order of magnitude finer.
expect(applyDrag(0, 100, DRAG_REF_PX, pxPerSecond, duration26h)).toBeCloseTo(
100 * onePxSeconds * 0.1,
5,
);
// Scale changes only affect SUBSEQUENT travel: the cursor never
// teleports when the finger pulls away mid-drag.
expect(applyDrag(5000, 0, 3 * DRAG_REF_PX, pxPerSecond, duration26h)).toBe(5000);
});
it("clamps to the book in both directions", () => {
const pxPerSecond = 800 / duration26h;
expect(applyDrag(duration26h, 100, 0, pxPerSecond, duration26h)).toBe(duration26h);
expect(applyDrag(1, -100, 0, pxPerSecond, duration26h)).toBe(0);
expect(applyDrag(50, 100, 0, 0, duration26h)).toBe(50);
});
});
describe("touch rail feedback (span shrink + lift)", () => {
it("starts at the full ±5-min span and shrinks with the zoom", () => {
expect(touchRailSpan(0)).toBe(RAIL_SPAN);
expect(touchRailSpan(DRAG_REF_PX)).toBeCloseTo(RAIL_SPAN * 0.1, 5);
expect(touchRailSpan(5 * DRAG_REF_PX)).toBe(2); // zoomed past the floor
expect(touchRailSpan(60)).toBeLessThan(RAIL_SPAN);
});
it("shows the rail only past the pull threshold", () => {
expect(TOUCH_RAIL_MIN_DY_PX).toBeGreaterThan(0);
});
it("lifts toward the finger, capped", () => {
expect(railLiftPx(20)).toBe(-20); // finger above the bar → rail rises
expect(railLiftPx(-30)).toBe(30); // finger below → rail follows down
expect(railLiftPx(500)).toBe(-TOUCH_RAIL_LIFT_MAX_PX);
expect(railLiftPx(-500)).toBe(TOUCH_RAIL_LIFT_MAX_PX);
});
});
describe("denseTicks", () => {
it("decimates an 1189-chapter book to a readable number of marks", () => {
const ticks = denseTicks(chapters1189, duration86h, 800, 4);
expect(ticks.length).toBeGreaterThan(50);
expect(ticks.length).toBeLessThan(300);
expect(ticks[0]).toBe(0);
// kept marks are strictly increasing and spaced by the gap rule
for (let i = 1; i < ticks.length; i += 1) {
const gapPx =
((chapters1189[ticks[i]].start - chapters1189[ticks[i - 1]].start) / duration86h) * 800;
expect(gapPx).toBeGreaterThanOrEqual(4);
}
});
it("keeps every chapter for a sparse 34-chapter book", () => {
const ticks = denseTicks(chapters34, duration26h, 800, 4);
expect(ticks).toHaveLength(34);
});
it("handles degenerate inputs", () => {
expect(denseTicks([], duration86h, 800)).toEqual([]);
expect(denseTicks(chapters34, 0, 800)).toEqual([]);
expect(denseTicks(chapters34, duration26h, 0)).toEqual([]);
});
});
describe("bufferedAheadEnd", () => {
it("returns the end of the range containing the playhead", () => {
const ranges = [
{ start: 0, end: 2 },
{ start: 92_719, end: 93_808 },
];
// A huge m4b's moov index at the tail is its own range — it must
// NOT read as "the whole book is buffered".
expect(bufferedAheadEnd(ranges, 0)).toBe(2);
expect(bufferedAheadEnd(ranges, 1.5)).toBe(2);
expect(bufferedAheadEnd(ranges, 92_800)).toBe(93_808);
});
it("walks into the next range when the playhead sits at a boundary", () => {
expect(
bufferedAheadEnd(
[
{ start: 0, end: 10 },
{ start: 10, end: 30 },
],
10,
),
).toBe(30);
});
it("returns null when the playhead is in a gap or ranges are empty", () => {
expect(bufferedAheadEnd([{ start: 50, end: 60 }], 10)).toBeNull();
expect(bufferedAheadEnd([], 10)).toBeNull();
});
});
describe("bufferedSpanPct", () => {
it("maps only the active track's segment on a multi-track book", () => {
// Track 8 of the KJV shape spans ~2088s..2344s of 310320s.
const track = { start: 2088, end: 2344 };
const span = bufferedSpanPct(track, 2344, duration86h);
expect(span).not.toBeNull();
expect(span!.left).toBeCloseTo((2088 / duration86h) * 100, 5);
expect(span!.width).toBeCloseTo(((2344 - 2088) / duration86h) * 100, 5);
// Element buffer beyond the track end is clamped to the track.
const clamped = bufferedSpanPct(track, 99999, duration86h);
expect(clamped!.width).toBeCloseTo(((2344 - 2088) / duration86h) * 100, 5);
});
it("spans the whole bar for a single-file book (start 0, end unknown)", () => {
const span = bufferedSpanPct({ start: 0, end: 0 }, 40_000, duration26h);
expect(span!.left).toBe(0);
expect(span!.width).toBeCloseTo((40_000 / duration26h) * 100, 5);
});
it("returns null with no track or no buffer", () => {
expect(bufferedSpanPct(undefined, 100, duration86h)).toBeNull();
expect(bufferedSpanPct({ start: 0, end: 0 }, null, duration86h)).toBeNull();
expect(bufferedSpanPct({ start: 0, end: 0 }, 0, duration86h)).toBeNull();
});
});
describe("railWindow / railSecondsAtPointer / railTicks", () => {
it("centers a ±5min window and clamps at the book edges", () => {
const mid = railWindow(duration26h / 2, duration26h);
expect(mid.from).toBeCloseTo(duration26h / 2 - RAIL_SPAN / 2, 5);
expect(mid.to).toBeCloseTo(duration26h / 2 + RAIL_SPAN / 2, 5);
const atStart = railWindow(0, duration26h);
expect(atStart.from).toBe(0);
expect(atStart.to).toBe(RAIL_SPAN);
const atEnd = railWindow(duration26h, duration26h);
expect(atEnd.to).toBe(duration26h);
expect(atEnd.to - atEnd.from).toBe(RAIL_SPAN);
// A book shorter than the window clamps to the whole book.
expect(railWindow(60, 120)).toEqual({ from: 0, to: 120 });
});
it("maps pointer X at minutes-per-pixel inside the window", () => {
const r = rect(600);
const window = { from: 3000, to: 3600 };
expect(railSecondsAtPointer(100, r, window)).toBe(3000);
expect(railSecondsAtPointer(400, r, window)).toBe(3300);
expect(railSecondsAtPointer(700, r, window)).toBe(3600);
});
it("lists the chapter starts inside the window with titles", () => {
// from is 1s past chapter 4's start, so chapter 4 is outside.
const ticks = railTicks(
{ from: chapters34[3].start + 1, to: chapters34[6].start + 1 },
chapters34,
);
expect(ticks.map((t) => t.title)).toEqual(["Chapter 5", "Chapter 6", "Chapter 7"]);
});
});
+227
View File
@@ -0,0 +1,227 @@
// Scrubber math (player plans §1 + §9). Pure and DOM-free except for
// passing rects in; the Alpine component owns the element, the pointer
// events and the state. Everything here is unit-tested — the pixel
// magnetism, the pull-away drag scaling and the precision-rail mapping
// are exactly the parts that must hold for an 86-hour, 1189-track book
// AND a 26-hour single-file m4b.
export interface ScrubChapter {
start: number;
title?: string;
}
// secondsAtPointer maps a pointer X within the scrub bar's rect to a
// global book position, clamped to [0, duration].
export function secondsAtPointer(clientX: number, rect: DOMRect, duration: number): number {
if (rect.width <= 0 || duration <= 0) return 0;
const fraction = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
return fraction * duration;
}
// chapterIndexFor returns the index of the chapter containing seconds —
// the last chapter whose start is at or before it. Empty chapters → -1.
export function chapterIndexFor(chapters: ScrubChapter[], seconds: number): number {
let found = -1;
for (let i = 0; i < chapters.length; i += 1) {
if (seconds >= chapters[i].start) found = i;
else break;
}
return found;
}
// ---- §9 magnetism: pixel radius, not a seconds threshold ----
// A seconds threshold goes sub-pixel on long books (1px ≈ 633s on the
// KJV bar — 5s could never fire); magnetism must be measured in PIXELS.
// MAGNET_RADIUS_PX is how close a click/hover must be to a chapter line,
// in bar pixels, for it to take over the landing position.
export const MAGNET_RADIUS_PX = 10;
// nearestChapterLine finds the chapter start closest to a position and
// reports its distance in bar pixels. Empty chapters → null.
export function nearestChapterLine(
seconds: number,
chapters: ScrubChapter[],
duration: number,
widthPx: number,
): { index: number; seconds: number; distancePx: number } | null {
if (chapters.length === 0 || duration <= 0 || widthPx <= 0) return null;
const pxPerSecond = widthPx / duration;
let best: { index: number; seconds: number; distancePx: number } | null = null;
for (let i = 0; i < chapters.length; i += 1) {
const distancePx = Math.abs(chapters[i].start - seconds) * pxPerSecond;
if (best === null || distancePx < best.distancePx)
best = { index: i, seconds: chapters[i].start, distancePx };
}
return best;
}
// snapToChapterPx applies the magnet: within radiusPx of a line the
// position lands ON the line; force (Shift+click) extends the pull to
// the nearest line regardless of distance. Otherwise unchanged.
export function snapToChapterPx(
seconds: number,
chapters: ScrubChapter[],
duration: number,
widthPx: number,
radiusPx = MAGNET_RADIUS_PX,
force = false,
): { seconds: number; snapped: boolean } {
const near = nearestChapterLine(seconds, chapters, duration, widthPx);
if (near === null) return { seconds, snapped: false };
if (force || near.distancePx <= radiusPx) return { seconds: near.seconds, snapped: true };
return { seconds, snapped: false };
}
// ---- §9 touch precision: vertical pull-away = progressive zoom ----
// The scale factor decays an order of magnitude for every DRAG_REF_PX of
// vertical distance from the bar: 1:1 at the bar, ~0.1× one screen-gap
// away, ~0.01× further. Exponential keeps the zoom continuous — any
// precision level is one gesture (plan §9 option C).
export const DRAG_REF_PX = 80;
export const DRAG_SCALE_MIN = 0.001;
export function dragScale(dyPx: number, refPx: number = DRAG_REF_PX): number {
if (refPx <= 0) return 1;
return Math.max(DRAG_SCALE_MIN, Math.pow(10, -Math.abs(dyPx) / refPx));
}
// applyDrag advances an INCREMENTAL drag cursor by one pointermove:
// horizontal travel (px) converts at the bar's scale, then shrinks by
// the current vertical offset. Incremental, not absolute — changing the
// scale mid-drag only affects subsequent travel, so pulling away never
// teleports the position. Clamped to the book.
export function applyDrag(
position: number,
dxPx: number,
dyPx: number,
pxPerSecond: number,
duration: number,
refPx: number = DRAG_REF_PX,
): number {
const clamped = Math.min(duration, Math.max(0, position));
if (pxPerSecond <= 0 || duration <= 0) return clamped;
return Math.min(duration, Math.max(0, clamped + (dxPx / pxPerSecond) * dragScale(dyPx, refPx)));
}
// Touch feedback thresholds: the rail appears once the finger is at
// least TOUCH_RAIL_MIN_DY_PX off the bar, and lifts toward the finger up
// to TOUCH_RAIL_LIFT_MAX_PX while the window shrinks with the zoom.
export const TOUCH_RAIL_MIN_DY_PX = 12;
export const TOUCH_RAIL_LIFT_MAX_PX = 40;
// touchRailSpan shrinks the rail window with the zoom (full ±5-min rail
// at 1:1, an order tighter per DRAG_REF_PX of pull) with a floor that
// keeps the edge labels readable.
export function touchRailSpan(dyPx: number, refPx: number = DRAG_REF_PX): number {
return Math.max(2, Math.min(RAIL_SPAN, RAIL_SPAN * dragScale(dyPx, refPx)));
}
// railLiftPx converts the vertical offset into the rail's translateY:
// toward the finger (negative = up), capped so the rail never chases
// the finger off the player.
export function railLiftPx(dyPx: number): number {
return -Math.max(-TOUCH_RAIL_LIFT_MAX_PX, Math.min(TOUCH_RAIL_LIFT_MAX_PX, dyPx));
}
// denseTicks decides which chapter starts get a rendered tick: a
// chapter earns a mark only when it is at least minGapPx from the last
// kept one — an 1189-chapter book would otherwise paint the bar solid.
// Returns chapter INDICES (into chapters), always including the first.
export function denseTicks(
chapters: ScrubChapter[],
duration: number,
widthPx: number,
minGapPx = 4,
): number[] {
if (chapters.length === 0 || duration <= 0 || widthPx <= 0) return [];
const kept: number[] = [0];
let lastPx = (chapters[0].start / duration) * widthPx;
for (let i = 1; i < chapters.length; i += 1) {
const px = (chapters[i].start / duration) * widthPx;
if (px - lastPx >= minGapPx) {
kept.push(i);
lastPx = px;
}
}
return kept;
}
// bufferedAheadEnd returns the end of the buffered range containing
// the playhead — how much audio is actually fetched AHEAD of where you
// are. A huge m4b's moov index at the file tail shows up as its own
// buffered range; counting it would claim the whole book is buffered.
export function bufferedAheadEnd(
ranges: Array<{ start: number; end: number }>,
position: number,
): number | null {
for (const r of ranges) {
if (position >= r.start && position < r.end) return r.end;
}
return null;
}
// bufferedSpanPct maps the ACTIVE track's buffered-ahead end into the
// global bar. Buffers are per-track: the span is drawn only across the
// active track's segment of the bar, never the whole bar. Returns null
// when there is nothing buffered (or the track is unknown).
export function bufferedSpanPct(
track: { start: number; end: number } | undefined,
elementBufferedEnd: number | null,
duration: number,
): { left: number; width: number } | null {
if (!track || elementBufferedEnd === null || elementBufferedEnd <= 0 || duration <= 0)
return null;
const bufferedEnd = track.end > 0 ? Math.min(elementBufferedEnd, track.end) : elementBufferedEnd;
const span = Math.max(0, bufferedEnd - track.start);
const left = Math.min(100, Math.max(0, (track.start / duration) * 100));
const width = Math.min(100 - left, Math.max(0, (span / duration) * 100));
return { left, width };
}
// RAIL_SPAN is the precision rail's total width in seconds: ±5 minutes
// around the drag anchor.
export const RAIL_SPAN = 600;
// railWindow clamps a ±span/2 window around the anchor to the book.
export function railWindow(
anchorSeconds: number,
duration: number,
spanSeconds = RAIL_SPAN,
): { from: number; to: number } {
const from = Math.max(
0,
Math.min(anchorSeconds - spanSeconds / 2, Math.max(0, duration - spanSeconds)),
);
const to = Math.min(duration, from + spanSeconds);
return { from, to };
}
// railSecondsAtPointer maps a pointer X to a position INSIDE the rail
// window — minutes-per-pixel instead of hours-per-pixel.
export function railSecondsAtPointer(
clientX: number,
rect: DOMRect,
window: { from: number; to: number },
): number {
if (rect.width <= 0) return window.from;
const fraction = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
return window.from + fraction * (window.to - window.from);
}
// railTicks returns the chapter starts (with titles) inside a rail
// window — readable density is the rail's whole point.
export function railTicks(
window: { from: number; to: number },
chapters: ScrubChapter[],
): Array<{ start: number; title: string }> {
const out: Array<{ start: number; title: string }> = [];
for (let i = 0; i < chapters.length; i += 1) {
const start = chapters[i].start;
if (start < window.from) continue;
if (start > window.to) break;
out.push({ start, title: chapters[i].title || `Chapter ${i + 1}` });
}
return out;
}
+106
View File
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import {
SLEEP_CHOICES,
SLEEP_OFF,
armEndOfChapter,
armMinutes,
fadeVolume,
formatRemaining,
sleepChapterFired,
sleepDue,
sleepLabel,
sleepRemainingMs,
} from "./audio-sleep";
describe("armMinutes / sleepDue / sleepRemainingMs", () => {
it("arms a wall-clock deadline", () => {
const state = armMinutes(1_000_000, 5);
expect(state.mode).toBe("minutes");
expect(state.endsAtMs).toBe(1_000_000 + 5 * 60_000);
expect(sleepDue(state, 1_000_000 + 5 * 60_000 - 1)).toBe(false);
expect(sleepDue(state, 1_000_000 + 5 * 60_000)).toBe(true);
expect(sleepDue(state, 1_000_000 + 9 * 60_000)).toBe(true);
});
it("reports remaining time clamped at zero", () => {
const state = armMinutes(0, 10);
expect(sleepRemainingMs(state, 0)).toBe(600_000);
expect(sleepRemainingMs(state, 599_000)).toBe(1000);
expect(sleepRemainingMs(state, 700_000)).toBe(0);
});
it("never fires in non-minutes modes", () => {
expect(sleepDue(SLEEP_OFF, Date.now())).toBe(false);
expect(sleepDue(armEndOfChapter(3), Date.now())).toBe(false);
expect(sleepRemainingMs(SLEEP_OFF, Date.now())).toBeNull();
});
});
describe("armEndOfChapter / sleepChapterFired", () => {
it("fires when the armed chapter is left, in either direction", () => {
const state = armEndOfChapter(7);
expect(sleepChapterFired(state, 7)).toBe(false);
expect(sleepChapterFired(state, 8)).toBe(true);
expect(sleepChapterFired(state, 6)).toBe(true);
});
it("does nothing in other modes", () => {
expect(sleepChapterFired(SLEEP_OFF, 8)).toBe(false);
expect(sleepChapterFired(armMinutes(0, 5), 8)).toBe(false);
});
});
describe("fadeVolume", () => {
it("holds the base at start, ramps linearly, reaches zero at fade end", () => {
expect(fadeVolume(0.5, 0)).toBe(0.5);
expect(fadeVolume(0.5, 2500)).toBeCloseTo(0.25, 10);
expect(fadeVolume(0.5, 5000)).toBe(0);
expect(fadeVolume(0.5, 9000)).toBe(0);
expect(fadeVolume(1, 1000)).toBeCloseTo(0.8, 10);
});
it("clamps negative elapsed to the base and out-of-range volumes", () => {
expect(fadeVolume(0.5, -5)).toBe(0.5);
expect(fadeVolume(2, 1000)).toBeLessThanOrEqual(1);
});
});
describe("formatRemaining / sleepLabel", () => {
it("formats mm:ss, rounding up partial seconds", () => {
expect(formatRemaining(45 * 60_000)).toBe("45:00");
expect(formatRemaining(1000)).toBe("0:01");
expect(formatRemaining(59_500)).toBe("1:00");
expect(formatRemaining(0)).toBe("0:00");
});
it("labels every mode", () => {
expect(sleepLabel(SLEEP_OFF, 0)).toBe("Off");
expect(sleepLabel(armEndOfChapter(2), 0)).toBe("End of chapter");
expect(sleepLabel({ mode: "fading", endsAtMs: null, chapterIndex: null }, 0)).toBe("Fading");
expect(sleepLabel(armMinutes(1000, 5), 1000 + 65_000)).toBe("3:55");
});
});
describe("SLEEP_CHOICES", () => {
it("is the agreed option list: off, 5/10/15/30/45/60, end-of-chapter", () => {
expect(SLEEP_CHOICES.map((c) => c.key)).toEqual([
"off",
"5",
"10",
"15",
"30",
"45",
"60",
"eoch",
]);
expect(SLEEP_CHOICES.filter((c) => c.mode === "minutes").map((c) => c.minutes)).toEqual([
5, 10, 15, 30, 45, 60,
]);
});
it("has unique keys and labels", () => {
expect(new Set(SLEEP_CHOICES.map((c) => c.key)).size).toBe(SLEEP_CHOICES.length);
expect(new Set(SLEEP_CHOICES.map((c) => c.label)).size).toBe(SLEEP_CHOICES.length);
});
});
+98
View File
@@ -0,0 +1,98 @@
// Sleep timer state machine (player plan §3). Pure and DOM-free — the
// Alpine component owns the timers/intervals and the audio element;
// this module owns the STATE rules. Per-session only: never persisted
// (the Android counterpart's persisted sleep setting is exactly the
// mistake plan §7 removes).
export type SleepMode = "off" | "minutes" | "end-of-chapter" | "fading";
export interface SleepState {
mode: SleepMode;
// Epoch ms deadline for "minutes" mode; null otherwise.
endsAtMs: number | null;
// Chapter index the end-of-chapter timer was armed at; null otherwise.
chapterIndex: number | null;
}
export const SLEEP_OFF: SleepState = { mode: "off", endsAtMs: null, chapterIndex: null };
export function armMinutes(nowMs: number, minutes: number): SleepState {
return { mode: "minutes", endsAtMs: nowMs + minutes * 60_000, chapterIndex: null };
}
export function armEndOfChapter(chapterIndex: number): SleepState {
return { mode: "end-of-chapter", endsAtMs: null, chapterIndex };
}
// sleepDue is true when a minutes-mode deadline has passed.
export function sleepDue(state: SleepState, nowMs: number): boolean {
return state.mode === "minutes" && state.endsAtMs !== null && nowMs >= state.endsAtMs;
}
// sleepChapterFired is true when an end-of-chapter timer's chapter has
// been left — ANY change from the armed index fires it (forward into
// the next chapter naturally, or a manual jump away from it).
export function sleepChapterFired(state: SleepState, currentIndex: number): boolean {
return (
state.mode === "end-of-chapter" &&
state.chapterIndex !== null &&
currentIndex !== state.chapterIndex
);
}
export function sleepRemainingMs(state: SleepState, nowMs: number): number | null {
if (state.mode !== "minutes" || state.endsAtMs === null) return null;
return Math.max(0, state.endsAtMs - nowMs);
}
// fadeVolume ramps linearly base → 0 across fadeMs and clamps beyond
// both ends.
export function fadeVolume(baseVolume: number, elapsedMs: number, fadeMs = 5000): number {
const base = Math.min(1, Math.max(0, baseVolume));
if (elapsedMs <= 0) return base;
if (elapsedMs >= fadeMs) return 0;
return Math.max(0, base * (1 - elapsedMs / fadeMs));
}
export function formatRemaining(ms: number): string {
const totalSeconds = Math.ceil(Math.max(0, ms) / 1000);
const m = Math.floor(totalSeconds / 60);
const s = totalSeconds % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
export function sleepLabel(state: SleepState, nowMs: number): string {
switch (state.mode) {
case "off":
return "Off";
case "end-of-chapter":
return "End of chapter";
case "fading":
return "Fading";
case "minutes": {
const remaining = sleepRemainingMs(state, nowMs);
return remaining === null ? "Off" : formatRemaining(remaining);
}
}
}
// Dropdown vocabulary — the templ sleep dropdown iterates this (the
// component exposes it; templ cannot import TS) and chooseSleep()
// consumes it. One source for both the rows and their behavior.
export interface SleepChoice {
key: string;
label: string;
mode: "off" | "minutes" | "end-of-chapter";
minutes?: number;
}
export const SLEEP_CHOICES: readonly SleepChoice[] = [
{ key: "off", label: "Off", mode: "off" },
{ key: "5", label: "5 minutes", mode: "minutes", minutes: 5 },
{ key: "10", label: "10 minutes", mode: "minutes", minutes: 10 },
{ key: "15", label: "15 minutes", mode: "minutes", minutes: 15 },
{ key: "30", label: "30 minutes", mode: "minutes", minutes: 30 },
{ key: "45", label: "45 minutes", mode: "minutes", minutes: 45 },
{ key: "60", label: "60 minutes", mode: "minutes", minutes: 60 },
{ key: "eoch", label: "End of chapter", mode: "end-of-chapter" },
];
+141
View File
@@ -0,0 +1,141 @@
import { describe, expect, it } from "vitest";
import {
SPEEDS,
SKIP_SECONDS,
TIME_DISPLAY_MODES,
cycleSkip,
cycleTimeDisplay,
endTimeLabel,
normalizeAutoplay,
normalizeSpeed,
normalizeTimeDisplay,
stepSpeed,
} from "./audio";
describe("SPEEDS", () => {
it("is the agreed list from the player plan (0.5 through 3)", () => {
expect(SPEEDS).toEqual([0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3]);
});
it("contains 1× exactly once and stays ascending", () => {
expect(SPEEDS.filter((r) => r === 1)).toHaveLength(1);
expect([...SPEEDS].sort((a, b) => a - b)).toEqual([...SPEEDS]);
});
});
describe("normalizeSpeed", () => {
it("passes through every listed rate", () => {
for (const rate of SPEEDS) {
expect(normalizeSpeed(String(rate))).toBe(rate);
}
});
it("falls back to 1× for null, garbage and out-of-list values", () => {
expect(normalizeSpeed(null)).toBe(1);
expect(normalizeSpeed("")).toBe(1);
expect(normalizeSpeed("not-a-number")).toBe(1);
expect(normalizeSpeed("0")).toBe(1);
expect(normalizeSpeed("2.2")).toBe(1);
expect(normalizeSpeed("10")).toBe(1);
});
it("accepts a stale pre-plan entry of 0.75 or 2", () => {
expect(normalizeSpeed("0.75")).toBe(0.75);
expect(normalizeSpeed("2")).toBe(2);
});
});
describe("stepSpeed", () => {
it("steps forward and backward through the list", () => {
expect(stepSpeed(1, 1)).toBe(1.25);
expect(stepSpeed(1, -1)).toBe(0.75);
expect(stepSpeed(0.5, 1)).toBe(0.75);
expect(stepSpeed(2, 1)).toBe(2.5);
});
it("clamps at both ends instead of wrapping", () => {
expect(stepSpeed(0.5, -1)).toBe(0.5);
expect(stepSpeed(3, 1)).toBe(3);
});
it("anchors unknown currents at 1× before stepping", () => {
expect(stepSpeed(2.2, 1)).toBe(1.25);
expect(stepSpeed(2.2, -1)).toBe(0.75);
});
});
describe("SKIP_SECONDS + cycleSkip", () => {
it("is the agreed skip list", () => {
expect(SKIP_SECONDS).toEqual([1, 5, 10, 15, 30]);
});
it("cycles forward and backward, clamping at the ends", () => {
expect(cycleSkip(10, 1)).toBe(15);
expect(cycleSkip(10, -1)).toBe(5);
expect(cycleSkip(1, -1)).toBe(1);
expect(cycleSkip(30, 1)).toBe(30);
});
it("anchors unknown currents at 10s before stepping", () => {
expect(cycleSkip(7, 1)).toBe(15);
expect(cycleSkip(7, -1)).toBe(5);
});
});
describe("normalizeAutoplay", () => {
it("is on only for explicitly-on values", () => {
expect(normalizeAutoplay("1")).toBe(true);
expect(normalizeAutoplay("true")).toBe(true);
});
it("defaults to off for null, off, and garbage", () => {
expect(normalizeAutoplay(null)).toBe(false);
expect(normalizeAutoplay("0")).toBe(false);
expect(normalizeAutoplay("")).toBe(false);
expect(normalizeAutoplay("on")).toBe(false);
expect(normalizeAutoplay("2")).toBe(false);
});
});
describe("time display mode (§10 end-label toggle)", () => {
it("cycles total → book remaining → chapter remaining → total", () => {
expect(TIME_DISPLAY_MODES).toEqual(["total", "book-left", "chapter-left"]);
expect(cycleTimeDisplay("total")).toBe("book-left");
expect(cycleTimeDisplay("book-left")).toBe("chapter-left");
expect(cycleTimeDisplay("chapter-left")).toBe("total");
});
it("normalizes persisted values, defaulting to total", () => {
expect(normalizeTimeDisplay("total")).toBe("total");
expect(normalizeTimeDisplay("book-left")).toBe("book-left");
expect(normalizeTimeDisplay("chapter-left")).toBe("chapter-left");
expect(normalizeTimeDisplay(null)).toBe("total");
expect(normalizeTimeDisplay("")).toBe("total");
expect(normalizeTimeDisplay("remaining")).toBe("total");
});
it("renders total as the book duration", () => {
// 93807s = 26:03:27 (Gardens-shaped)
expect(endTimeLabel("total", 0, 93_807, null)).toBe("26:03:27");
});
it("renders book remaining, clamped at zero", () => {
expect(endTimeLabel("book-left", 93_807 - 3_662, 93_807, null)).toBe("−1:01:02");
expect(endTimeLabel("book-left", 93_807, 93_807, null)).toBe("−0:00");
expect(endTimeLabel("book-left", 99_999, 93_807, null)).toBe("−0:00");
});
it("renders chapter remaining with minutes:seconds formatting", () => {
// 842s left in the chapter → −14:02
expect(endTimeLabel("chapter-left", 5_000, 93_807, 5_842)).toBe("−14:02");
});
it("falls back to book remaining when the book has no chapters", () => {
expect(endTimeLabel("chapter-left", 93_807 - 3_662, 93_807, null)).toBe("−1:01:02");
});
it("treats a chapter end in the past as zero remaining", () => {
expect(endTimeLabel("chapter-left", 6_000, 93_807, 5_842)).toBe("−0:00");
});
});
+757 -38
View File
@@ -15,6 +15,42 @@
import { Alpine } from "./alpine";
import { renderNoteHtml } from "./markdown";
import { showToast } from "./toast";
import {
MOUSE_GUIDE,
resolveShortcut,
TOUCH_GUIDE,
visibleKeymap,
type KeyAction,
} from "./audio-keys";
import {
SLEEP_CHOICES,
SLEEP_OFF,
armEndOfChapter,
armMinutes,
fadeVolume,
sleepChapterFired,
sleepDue,
sleepLabel,
type SleepChoice,
type SleepState,
} from "./audio-sleep";
import {
applyDrag,
bufferedAheadEnd,
bufferedSpanPct,
chapterIndexFor,
denseTicks,
MAGNET_RADIUS_PX,
nearestChapterLine,
railLiftPx,
railSecondsAtPointer,
railTicks,
railWindow,
secondsAtPointer,
snapToChapterPx,
TOUCH_RAIL_MIN_DY_PX,
touchRailSpan,
} from "./audio-scrubber";
type AudioChapter = { index: number; title: string; start: number; end: number };
@@ -43,11 +79,95 @@ export function localOffsetFor(tracks: AudioTrack[], index: number, globalSecond
// globalSecondsFor converts (track i, local seconds) back to the global
// position — the inverse of localOffsetFor.
export function globalSecondsFor(tracks: AudioTrack[], index: number, localSeconds: number): number {
export function globalSecondsFor(
tracks: AudioTrack[],
index: number,
localSeconds: number,
): number {
const track = tracks[index];
return (track ? track.start : 0) + Math.max(0, localSeconds);
}
// Playback speeds — ONE source. The templ dropdown iterates it through
// the component's `speeds` property, and keyboard stepping (audio-keys,
// player plan §2) walks the same array through stepSpeed. Never hardcode
// a rate list elsewhere.
export const SPEEDS: readonly number[] = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3];
// normalizeSpeed maps a persisted speed onto the SPEEDS list: missing,
// malformed or out-of-list values (e.g. a stale localStorage entry from
// before the list changed) fall back to 1×.
export function normalizeSpeed(value: string | null): number {
const n = Number(value);
return SPEEDS.includes(n) ? n : 1;
}
// stepSpeed moves through SPEEDS by delta, clamping at both ends; an
// unknown current anchors at 1× before stepping.
export function stepSpeed(current: number, delta: 1 | -1): number {
const idx = SPEEDS.indexOf(current);
const base = idx === -1 ? SPEEDS.indexOf(1) : idx;
return SPEEDS[Math.min(SPEEDS.length - 1, Math.max(0, base + delta))];
}
// Skip durations — ONE source (same pattern as SPEEDS). The templ
// dropdown iterates it through the component's `skipChoices` property,
// and [ / ] keyboard cycling walks it through cycleSkip.
export const SKIP_SECONDS: readonly number[] = [1, 5, 10, 15, 30];
// cycleSkip moves through SKIP_SECONDS by delta, clamping at both
// ends; an unknown current anchors at 10s (the historical default).
export function cycleSkip(current: number, delta: 1 | -1): number {
const idx = SKIP_SECONDS.indexOf(current);
const base = idx === -1 ? SKIP_SECONDS.indexOf(10) : idx;
return SKIP_SECONDS[Math.min(SKIP_SECONDS.length - 1, Math.max(0, base + delta))];
}
// Autoplay-on-open setting — persisted as "1"/"0" (default off). The
// browser's autoplay policy decides whether the attempt succeeds; a
// rejection is swallowed silently (maybeAutoplay).
export function normalizeAutoplay(value: string | null): boolean {
return value === "1" || value === "true";
}
// ---- End-time display mode (plan §10) ----
// Clicking the END time label cycles total → book remaining → chapter
// remaining, persisted per device (localStorage audio_time_display).
export type TimeDisplayMode = "total" | "book-left" | "chapter-left";
export const TIME_DISPLAY_MODES: readonly TimeDisplayMode[] = [
"total",
"book-left",
"chapter-left",
];
export function normalizeTimeDisplay(value: string | null): TimeDisplayMode {
return value === "book-left" || value === "chapter-left" ? value : "total";
}
export function cycleTimeDisplay(current: TimeDisplayMode): TimeDisplayMode {
const idx = TIME_DISPLAY_MODES.indexOf(current);
return TIME_DISPLAY_MODES[(idx + 1) % TIME_DISPLAY_MODES.length] ?? "total";
}
// endTimeLabel renders the END label for a mode. Position is the
// playhead (or the scrub cursor mid-drag) so remaining values track
// what you'd land on. chapterEnd is the current chapter's end — null
// when the book has no chapters, in which case chapter mode falls back
// to book remaining. Remainders clamp at 0.
export function endTimeLabel(
mode: TimeDisplayMode,
position: number,
duration: number,
chapterEnd: number | null,
): string {
const remain = (to: number) => `−${fmtTime(Math.max(0, to - position))}`;
if (mode === "book-left") return remain(duration);
if (mode === "chapter-left") return remain(chapterEnd === null ? duration : chapterEnd);
return fmtTime(duration);
}
const PROGRESS_INTERVAL_MS = 30_000;
interface AudioConfig {
@@ -81,12 +201,45 @@ Alpine.data("audioReader", () => ({
chaptersOpen: false,
currentTime: 0,
playing: false,
speed: Number(localStorage.getItem("audio_speed") || 1),
speed: normalizeSpeed(localStorage.getItem("audio_speed")),
// User-level default speed (plan §8): powers the dropdown's
// "(default)" marker; hydrated from the chapters payload.
defaultSpeed: 1,
// Exposed for the templ dropdown's x-for — templ cannot import the
// const directly; this property is the binding.
speeds: SPEEDS,
// Same binding pattern, for the dialog's two scrub-system sections.
mouseGuide: MOUSE_GUIDE,
touchGuide: TOUCH_GUIDE,
skipSeconds: Number(localStorage.getItem("audio_skip") || 10),
// End-label display mode (§10): total → book remaining → chapter
// remaining, persisted per device.
timeDisplay: normalizeTimeDisplay(localStorage.getItem("audio_time_display")),
// Scrub guard: while dragging, timeupdate must not fight the handle
scrubbing: false,
scrubValue: 0,
// Progress-bar overhaul (plan §1): chapter ticks, per-track buffer
// span, hover tooltip and the precision rail. Math lives in
// audio-scrubber.ts (pure, unit-tested).
tickIndices: [] as number[],
bufferedGlobal: null as number | null,
hoverSeconds: null as number | null,
hoverPct: 0,
// §9: hover locks to a chapter line inside the magnet radius (the
// highlight line + tooltip land where the click will).
hoverSnapped: false,
railFrom: 0,
railTo: 0,
railTickList: [] as Array<{ start: number; title: string }>,
// §9 interaction model: "free" = 1:1 mouse drag (no rail), "rail" =
// Shift+drag precision window, "touch" = incremental cursor with
// pull-away precision. The rail is only visible for the last two.
dragMode: null as null | "free" | "rail" | "touch",
railVisible: false,
railLiftY: 0,
touchLastX: 0,
// Track manifest: one AudioTrack per playable file. Single-file books
// (m4b/mp3) carry exactly one track whose URL is the whole file, so
// every code path below can treat playback as (track, local seconds)
@@ -112,6 +265,30 @@ Alpine.data("audioReader", () => ({
skipOpen: false,
speedOpen: false,
volumeOpen: false,
skipChoices: SKIP_SECONDS,
// Keyboard shortcuts (audio-keys.ts keymap): the ? dialog and the
// brief OSD confirming keyboard-driven volume/mute changes.
helpOpen: false,
osdVisible: false,
osdText: "",
osdTimer: null as ReturnType<typeof setTimeout> | null,
// Sleep timer (plan §3): session-scoped, NEVER persisted. The pure
// state machine lives in audio-sleep.ts; this holds the state, the
// 1s countdown ticker and the 5s fade interval.
sleep: SLEEP_OFF as SleepState,
sleepChoices: SLEEP_CHOICES,
sleepOpen: false,
sleepLabelStr: "Off",
sleepArmedChoiceKey: "off",
sleepTicker: null as ReturnType<typeof setInterval> | null,
sleepFadeTimer: null as ReturnType<typeof setInterval> | null,
// Settings popover (plan §4): header gear → panel. Autoplay-on-open
// is the first option; the panel is structured for more.
settingsOpen: false,
autoplayOn: normalizeAutoplay(localStorage.getItem("audio_autoplay")),
// Volume: level 0..1 + mute, persisted separately. prevVolume restores
// on unmute (spec: icon shows level arcs, crossed when muted).
@@ -139,6 +316,12 @@ Alpine.data("audioReader", () => ({
init() {
if (this.initialized) return; // Alpine can re-init the body tree
this.initialized = true;
// Heal a stale/out-of-list persisted speed so the storage matches
// what the player actually uses (normalizeSpeed already fixed the
// in-memory value at state creation).
if (localStorage.getItem("audio_speed") !== String(this.speed)) {
localStorage.setItem("audio_speed", String(this.speed));
}
window.addEventListener("error", (e) => {
document.title = "ERR: " + (e.message || "unknown");
});
@@ -159,14 +342,17 @@ Alpine.data("audioReader", () => ({
// src is set by restorePosition once the track manifest is known —
// for multi-file books data-file-url (a directory or a zip root) is
// NOT directly playable. The catch branch below falls back to it.
audio.playbackRate = Number(this.speed) || 1;
audio.playbackRate = this.speed;
audio.preload = "auto";
// Restored volume/mute from the settings strip's persisted state.
audio.volume = Math.min(1, Math.max(0, Number(this.volume)));
audio.muted = !!this.muted;
// Restore position: chapters + progress from the server.
this.restorePosition();
// Restore position: chapters + progress from the server. Autoplay
// (if enabled) starts AFTER the position is restored — the ?loc=
// deep link wins on position; autoplay only decides whether
// playback starts.
this.restorePosition().then(() => this.maybeAutoplay());
// Periodic progress push (playing-only; pause/hide push too)
this.startPushTimer();
// Bookmarks list (time-located; notes optional)
@@ -220,6 +406,9 @@ Alpine.data("audioReader", () => ({
audio.addEventListener("play", () => {
this.playing = true;
this.prevTick = performance.now();
// Manual play during a sleep fade aborts the expiry (the user
// is awake and asked for audio); stopSleep restores the volume.
if (this.sleep.mode === "fading") this.stopSleep();
this.updateMediaSessionState();
});
audio.addEventListener("pause", () => {
@@ -233,25 +422,30 @@ Alpine.data("audioReader", () => ({
this.updateMediaSessionState();
});
window.addEventListener("pagehide", () => this.pushProgress(true));
window.addEventListener("resize", () => this.recomputeTicks());
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") this.pushProgress(true);
});
// Buffer readout for the scrubber's per-track buffer span.
// 'progress' alone does not fire reliably for Range-served files
// that go network-idle while paused — ride the events that
// demonstrably do, and re-read the (idempotent) ranges on each.
for (const ev of [
"progress",
"loadedmetadata",
"loadeddata",
"canplay",
"playing",
"timeupdate",
]) {
audio.addEventListener(ev, () => this.updateBuffered());
}
// Spacebar = play/pause (the reader's keyboard contract, audio flavor).
document.addEventListener("keydown", (e) => {
const target = e.target as HTMLElement | null;
if (target?.tagName === "INPUT" || target?.tagName === "TEXTAREA") return;
if (e.code === "Space") {
e.preventDefault();
this.togglePlay();
} else if (e.code === "ArrowLeft") {
e.preventDefault();
this.skip(-1);
} else if (e.code === "ArrowRight") {
e.preventDefault();
this.skip(1);
}
});
// Keyboard shortcuts (plan §2): one dispatcher resolving through
// the declarative audio-keys.ts keymap. Hardware media keys ride
// the MediaSession handlers above; the Media* keydown codes land
// here and call the SAME methods.
document.addEventListener("keydown", (e) => this.handleKeydown(e));
},
audio(): HTMLAudioElement | null {
@@ -293,13 +487,15 @@ Alpine.data("audioReader", () => ({
if (!audio || index < 0 || index >= this.tracks.length) return;
const track = this.tracks[index];
this.activeTrack = index;
this.bufferedGlobal = null; // buffers are per-track; the next
// 'progress' event re-reads the freshly loading element's ranges.
if (audio.getAttribute("src") === track.url) {
this.pendingSeek = null;
audio.currentTime = Math.max(0, localSeconds);
} else {
this.pendingSeek = Math.max(0, localSeconds);
audio.setAttribute("src", track.url);
audio.playbackRate = Number(this.speed) || 1;
audio.playbackRate = this.speed;
audio.load();
}
if (resume) audio.play().catch(() => showToast("Playback failed to start", "error"));
@@ -309,19 +505,33 @@ Alpine.data("audioReader", () => ({
try {
const [chaptersResp, progress] = await Promise.all([
fetch(readConfig().chapterEndpoint, { headers: getAuthHeaders() }),
fetch(
`/api/media-items/${this.mediaItemId}/progress`,
{ headers: getAuthHeaders() },
),
fetch(`/api/media-items/${this.mediaItemId}/progress`, { headers: getAuthHeaders() }),
]);
if (chaptersResp.ok) {
const data = await chaptersResp.json();
this.chapters = (data.chapters || []) as AudioChapter[];
this.bookDuration = data.duration_seconds || this.chapterEndEstimate();
// §8: the server owns speed — per-book row, else the user's
// default. localStorage stays only as a first-paint cache; the
// payload value wins over it BEFORE the rate is (re)applied to
// the element below.
const serverSpeed = Number(data.playback_speed);
if (SPEEDS.includes(serverSpeed)) {
this.speed = serverSpeed;
localStorage.setItem("audio_speed", String(serverSpeed));
}
const serverDefault = Number(data.default_playback_speed);
if (SPEEDS.includes(serverDefault)) this.defaultSpeed = serverDefault;
this.buildTracks(data);
this.recomputeTicks();
const audio = this.audio();
if (audio && !audio.getAttribute("src") && this.tracks[0]) {
audio.setAttribute("src", this.tracks[0].url);
// Setting src resets playbackRate to the element default —
// re-apply, mirroring the seekTo() src-swap path. Without this
// the pill said 1.5× while audio actually played at 1× after
// every reload (surfaced by the speed-list browser check).
audio.playbackRate = this.speed;
}
}
if (progress.ok) {
@@ -350,6 +560,7 @@ Alpine.data("audioReader", () => ({
if (audio && !audio.getAttribute("src") && this.fileUrl) {
this.tracks = [{ url: this.fileUrl, start: 0, end: 0 }];
audio.setAttribute("src", this.fileUrl);
audio.playbackRate = this.speed;
}
}
},
@@ -367,6 +578,8 @@ Alpine.data("audioReader", () => ({
if (this.currentChapterIdx !== i) {
this.currentChapterIdx = i;
this.updateMediaSessionMetadata();
// Natural chapter crossing: fire an end-of-chapter sleep.
if (sleepChapterFired(this.sleep, i)) this.beginSleepFade();
}
return;
}
@@ -375,9 +588,7 @@ Alpine.data("audioReader", () => ({
chapterTitle(): string {
const ch = this.chapters[this.currentChapterIdx];
return ch
? ch.title || `Chapter ${ch.index + 1}`
: this.title;
return ch ? ch.title || `Chapter ${ch.index + 1}` : this.title;
},
currentChapter(): number {
@@ -407,7 +618,10 @@ Alpine.data("audioReader", () => ({
},
skip(direction: 1 | -1) {
const target = Math.max(0, this.globalPosition() + direction * (Number(this.skipSeconds) || 10));
const target = Math.max(
0,
this.globalPosition() + direction * (Number(this.skipSeconds) || 10),
);
this.seekTo(target);
},
@@ -421,6 +635,9 @@ Alpine.data("audioReader", () => ({
const ch = this.chapters[index];
if (ch) this.seekTo(ch.start);
this.currentChapterIdx = index;
// Manual jump: leaving the armed chapter fires an end-of-chapter
// sleep the same as a natural crossing.
if (sleepChapterFired(this.sleep, index)) this.beginSleepFade();
},
setSpeed(rate: number) {
@@ -428,6 +645,39 @@ Alpine.data("audioReader", () => ({
localStorage.setItem("audio_speed", String(rate));
const audio = this.audio();
if (audio) audio.playbackRate = rate;
// §8: the per-book speed lives server-side (localStorage is only a
// first-paint cache). Fire-and-forget; a failure toasts — the rate
// still applies to this session.
fetch(`/api/media-items/${this.mediaItemId}/audio-speed`, {
method: "PUT",
headers: { ...getAuthHeaders(), "content-type": "application/json" },
body: JSON.stringify({ speed: rate }),
})
.then((r) => {
if (!r.ok) throw new Error(String(r.status));
})
.catch(() => showToast("Speed saved for this session only — server rejected it", "error"));
},
// setDefaultSpeed pins a user-level default (plan §8): books without
// their own per-book speed pick it up. Rides the profile endpoint,
// same as the profile-page select.
setDefaultSpeed(rate: number) {
fetch("/api/auth/profile", {
method: "PUT",
headers: { ...getAuthHeaders(), "content-type": "application/json" },
body: JSON.stringify({ default_audio_speed: rate }),
})
.then((r) => {
if (!r.ok) throw new Error(String(r.status));
this.defaultSpeed = rate;
showToast(`Default playback speed set to ${rate}×`, "success");
})
.catch(() => showToast("Default speed not saved", "error"));
},
isDefaultSpeed(rate: number): boolean {
return Number(this.defaultSpeed) === rate;
},
setSkip(seconds: number) {
@@ -494,8 +744,7 @@ Alpine.data("audioReader", () => ({
.filter((b) => String(b.position || "").startsWith("audio:"))
.sort(
(a, b) =>
this.bookmarkSeconds(String(a.position)) -
this.bookmarkSeconds(String(b.position)),
this.bookmarkSeconds(String(a.position)) - this.bookmarkSeconds(String(b.position)),
);
} catch {
// bookmarks are non-critical UI
@@ -513,6 +762,210 @@ Alpine.data("audioReader", () => ({
return Number.isFinite(n) ? n : 0;
},
// ---- progress bar (audio-scrubber.ts math) ----
recomputeTicks() {
const area = document.getElementById("scrub-area");
const width = area ? area.getBoundingClientRect().width : 0;
this.tickIndices = denseTicks(
this.chapters,
this.chaptersDuration() || this.bookDuration || 1,
width,
);
},
updateBuffered() {
const audio = this.audio();
if (!audio || audio.buffered.length === 0) {
this.bufferedGlobal = null;
return;
}
// Buffered-ahead of the playhead (element-local): the end of the
// range containing currentTime. The moov-index range at a huge
// m4b's tail must not read as "the whole book is buffered".
const ranges = Array.from({ length: audio.buffered.length }, (_, i) => ({
start: audio.buffered.start(i),
end: audio.buffered.end(i),
}));
this.bufferedGlobal = bufferedAheadEnd(ranges, audio.currentTime);
},
chapterTitleAt(seconds: number): string {
const idx = chapterIndexFor(this.chapters, seconds);
if (idx === -1) return this.title;
const ch = this.chapters[idx];
return ch.title || `Chapter ${idx + 1}`;
},
// ---- end-time label (§10): total → book left → chapter left ----
endTimeText(): string {
const duration = this.chaptersDuration() || 1;
const pos = this.scrubbing ? this.scrubValue : Number(this.currentTime);
const idx = chapterIndexFor(this.chapters, pos);
const ch = this.chapters[idx === -1 ? 0 : idx];
return endTimeLabel(this.timeDisplay, pos, duration, ch ? ch.end : null);
},
endTimeAria(): string {
if (this.timeDisplay === "book-left")
return "Book time remaining — click to show chapter remaining";
if (this.timeDisplay === "chapter-left")
return "Chapter time remaining — click to show total duration";
return "Total duration — click to show book remaining";
},
cycleEndTime() {
this.timeDisplay = cycleTimeDisplay(this.timeDisplay);
localStorage.setItem("audio_time_display", this.timeDisplay);
},
scrubFillPct(): number {
const value = this.scrubbing ? this.scrubValue : Number(this.currentTime);
return Math.min(100, Math.max(0, (value / (this.chaptersDuration() || 1)) * 100));
},
bufferSpan(): { left: number; width: number } | null {
return bufferedSpanPct(
this.tracks[this.activeTrack],
this.bufferedGlobal,
this.chaptersDuration() || 1,
);
},
railMarkerPct(): number {
const span = Math.max(1, this.railTo - this.railFrom);
return Math.min(100, Math.max(0, ((this.scrubValue - this.railFrom) / span) * 100));
},
// Pointer interactions (plan §9): mouse drag is FREE FLOW (1:1, no
// rail, never sticks at chapter lines); clicks land with pixel
// magnetism on nearby chapter lines; Shift+click/drag opens the
// precision rail (±5 min, free mapping inside); touch drags are an
// incremental cursor whose scale tightens as the finger pulls off the
// bar (progressive precision — the rail is its feedback). The native
// range stays underneath for keyboard/aria — its drag default is
// prevented so the mappings never fight.
scrubPointerDown(e: PointerEvent) {
if (e.button !== 0 || this.scrubbing) return;
e.preventDefault();
const area = e.currentTarget as HTMLElement;
const rect = area.getBoundingClientRect();
try {
area.setPointerCapture(e.pointerId);
} catch {
/* synthetic or already-released pointer — drag still works via
the element-level listeners */
}
document.getElementById("audio-scrub")?.focus({ preventScroll: true });
const duration = this.chaptersDuration() || 1;
const at = secondsAtPointer(e.clientX, rect, duration);
if (e.pointerType === "touch") {
// Tap lands magnetically; the drag starts 1:1 and tightens as
// the finger pulls away (scrubPointerMove's incremental cursor).
this.dragMode = "touch";
this.touchLastX = e.clientX;
this.scrubValue = snapToChapterPx(at, this.chapters, duration, rect.width).seconds;
} else if (e.shiftKey) {
// Shift+click/drag: the precision rail, anchored to the NEAREST
// chapter line (Shift+click = snap regardless of radius).
this.dragMode = "rail";
const near = nearestChapterLine(at, this.chapters, duration, rect.width);
this.scrubValue = near ? near.seconds : at;
const window = railWindow(this.scrubValue, duration);
this.railFrom = window.from;
this.railTo = window.to;
this.railTickList = railTicks(window, this.chapters);
this.railVisible = true;
this.railLiftY = 0;
} else {
// Mouse click/drag: the CLICK's landing is magnetic; drags stay
// free-flow (scrubPointerMove overwrites with the raw position).
this.dragMode = "free";
this.scrubValue = snapToChapterPx(at, this.chapters, duration, rect.width).seconds;
}
this.scrubbing = true;
},
scrubPointerMove(e: PointerEvent) {
const area = e.currentTarget as HTMLElement;
const rect = area.getBoundingClientRect();
const duration = this.chaptersDuration() || 1;
if (!this.scrubbing) {
// Hover: time + chapter, locking to a chapter line inside the
// magnet radius so the tooltip shows the click's true landing.
const at = secondsAtPointer(e.clientX, rect, duration);
const near = nearestChapterLine(at, this.chapters, duration, rect.width);
const locked = near !== null && near.distancePx <= MAGNET_RADIUS_PX;
const hoverAt = locked && near ? near.seconds : at;
this.hoverSeconds = hoverAt;
this.hoverPct = rect.width > 0 ? Math.min(100, Math.max(0, (hoverAt / duration) * 100)) : 0;
this.hoverSnapped = locked;
return;
}
if (this.dragMode === "rail") {
this.scrubValue = railSecondsAtPointer(e.clientX, rect, {
from: this.railFrom,
to: this.railTo,
});
return;
}
if (this.dragMode === "touch") {
const dx = e.clientX - this.touchLastX;
this.touchLastX = e.clientX;
const dy = rect.top + rect.height / 2 - e.clientY;
this.scrubValue = applyDrag(this.scrubValue, dx, dy, rect.width / duration, duration);
if (Math.abs(dy) >= TOUCH_RAIL_MIN_DY_PX) {
// Feedback: the rail follows the cursor, its window shrinking
// with the zoom, lifting toward the finger.
const window = railWindow(this.scrubValue, duration, touchRailSpan(dy));
this.railFrom = window.from;
this.railTo = window.to;
this.railTickList = railTicks(window, this.chapters);
this.railVisible = true;
this.railLiftY = railLiftPx(dy);
} else {
this.railVisible = false;
this.railLiftY = 0;
}
return;
}
// Free flow: direct 1:1 mapping — dragging across chapter lines
// never sticks.
this.scrubValue = secondsAtPointer(e.clientX, rect, duration);
},
scrubPointerLeave() {
if (!this.scrubbing) {
this.hoverSeconds = null;
this.hoverSnapped = false;
}
},
scrubPointerUp(e: PointerEvent) {
if (!this.scrubbing) return;
const area = e.currentTarget as HTMLElement;
if (area.hasPointerCapture && area.hasPointerCapture(e.pointerId))
area.releasePointerCapture(e.pointerId);
this.seekTo(this.scrubValue);
this.scrubbing = false;
this.dragMode = null;
this.railVisible = false;
this.railLiftY = 0;
this.hoverSeconds = null;
this.hoverSnapped = false;
},
// Wheel over the bar: ±5s nudge, Shift = ±60s. Scroll up = forward.
onScrubWheel(e: WheelEvent) {
e.preventDefault();
const step = e.shiftKey ? 60 : 5;
const direction = e.deltaY > 0 ? -1 : 1;
const duration = this.chaptersDuration() || 1;
const target = Math.min(duration, Math.max(0, this.globalPosition() + direction * step));
this.seekTo(target);
},
toggleBookmarkCapture() {
if (this.bookmarkCaptureOpen) {
this.bookmarkCaptureOpen = false;
@@ -522,6 +975,274 @@ Alpine.data("audioReader", () => ({
this.bookmarkCaptureOpen = true;
},
// Keyboard B: open the capture pill and put the cursor in the note
// box (user's explicit flow — Enter then saves, even when blank).
openBookmarkCapture() {
if (!this.bookmarkCaptureOpen) this.toggleBookmarkCapture();
window.setTimeout(() => {
(document.getElementById("bookmark-note") as HTMLTextAreaElement | null)?.focus();
}, 0);
},
// ---- keyboard dispatcher (audio-keys.ts) ----
visibleKeymap() {
return visibleKeymap();
},
// Escape closes the top-most open surface — even while typing, since
// canceling the bookmark note mid-sentence is the expected Esc.
// Returns true when something actually closed.
closeTopSurface(): boolean {
if (this.helpOpen) {
this.helpOpen = false;
return true;
}
if (this.bookmarkCaptureOpen) {
this.bookmarkCaptureOpen = false;
return true;
}
if (this.editingBookmarkId !== "") {
this.editingBookmarkId = "";
return true;
}
if (this.settingsOpen) {
this.settingsOpen = false;
return true;
}
if (this.chaptersOpen) {
this.chaptersOpen = false;
return true;
}
if (this.bookmarksOpen) {
this.bookmarksOpen = false;
return true;
}
if (this.skipOpen || this.speedOpen || this.volumeOpen || this.sleepOpen) {
this.skipOpen = false;
this.speedOpen = false;
this.volumeOpen = false;
this.sleepOpen = false;
return true;
}
return false;
},
showOsd(text: string) {
this.osdText = text;
this.osdVisible = true;
if (this.osdTimer) clearTimeout(this.osdTimer);
this.osdTimer = setTimeout(() => {
this.osdVisible = false;
}, 1200);
},
handleKeydown(e: KeyboardEvent) {
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
const typing = tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable === true;
// Escape first: cancel the top-most surface even while typing.
if (e.key === "Escape") {
if (this.closeTopSurface()) {
e.preventDefault();
e.stopPropagation();
}
return;
}
// Typing in any input (bookmark notes!) never triggers shortcuts.
if (typing) return;
const action: KeyAction | null = resolveShortcut(e.key, e.altKey);
if (!action) return;
// Space/Enter activate a focused control — never hijack them for
// play/pause when the user is keyboard-driving a button.
if (action === "toggle-play" && (tag === "BUTTON" || tag === "SELECT" || tag === "A")) {
return;
}
e.preventDefault();
switch (action) {
case "toggle-play":
case "media-toggle-play":
this.togglePlay();
break;
case "media-play":
if (!this.playing) this.togglePlay();
break;
case "media-pause":
if (this.playing) this.togglePlay();
break;
case "skip-back":
this.skip(-1);
break;
case "skip-forward":
this.skip(1);
break;
case "prev-chapter":
case "media-prev":
this.stepChapter(-1);
break;
case "next-chapter":
case "media-next":
this.stepChapter(1);
break;
case "bookmark":
this.openBookmarkCapture();
break;
case "speed-up":
this.setSpeed(stepSpeed(this.speed, 1));
break;
case "speed-down":
this.setSpeed(stepSpeed(this.speed, -1));
break;
case "volume-up":
case "volume-down": {
const step = action === "volume-up" ? 0.05 : -0.05;
const next = Math.round((Number(this.volume) + step) * 100) / 100;
this.setVolume(next);
this.showOsd(`Volume ${Math.round(Number(this.volume) * 100)}%`);
break;
}
case "toggle-mute":
this.toggleMute();
this.showOsd(this.muted ? "Muted" : "Unmuted");
break;
case "cycle-skip-up":
this.setSkip(cycleSkip(this.skipSeconds, 1));
break;
case "cycle-skip-down":
this.setSkip(cycleSkip(this.skipSeconds, -1));
break;
case "toggle-help":
this.helpOpen = !this.helpOpen;
break;
case "escape":
this.closeTopSurface();
break;
}
},
// ---- sleep timer (audio-sleep.ts state machine) ----
isSleepActive(key: string): boolean {
return this.sleepArmedChoiceKey === key;
},
chooseSleep(choice: SleepChoice) {
this.sleepArmedChoiceKey = choice.key;
if (choice.mode === "off") {
this.stopSleep();
return;
}
if (choice.mode === "end-of-chapter") {
this.stopSleepTicker();
this.sleep = armEndOfChapter(this.currentChapterIdx);
} else {
this.stopSleepTicker();
this.sleep = armMinutes(Date.now(), choice.minutes ?? 30);
this.startSleepTicker();
}
this.sleepLabelStr = sleepLabel(this.sleep, Date.now());
},
startSleepTicker() {
this.stopSleepTicker();
this.sleepTicker = setInterval(() => {
if (this.sleep.mode !== "minutes") return;
const now = Date.now();
if (sleepDue(this.sleep, now)) {
this.stopSleepTicker();
this.beginSleepFade();
return;
}
this.sleepLabelStr = sleepLabel(this.sleep, now);
}, 1000);
},
stopSleepTicker() {
if (this.sleepTicker) {
clearInterval(this.sleepTicker);
this.sleepTicker = null;
}
},
// Expiry: fade the element volume over ~5s, then pause. The pause
// fires the SAME listener a manual pause does (progress push + media
// session state); the user's configured volume is restored right
// after, so the next play starts at full level.
beginSleepFade() {
if (this.sleep.mode === "fading") return;
const audio = this.audio();
if (!audio) {
this.stopSleep();
return;
}
this.sleep = { mode: "fading", endsAtMs: null, chapterIndex: null };
this.sleepLabelStr = "Fading";
if (this.muted || Number(this.volume) <= 0) {
// Inaudible anyway: skip the pantomime, just pause.
this.finishSleepPause();
return;
}
const base = Number(this.volume) || 0.5;
const startedAt = Date.now();
this.sleepFadeTimer = setInterval(() => {
const v = fadeVolume(base, Date.now() - startedAt);
audio.volume = v;
if (v <= 0) this.finishSleepPause();
}, 200);
},
finishSleepPause() {
if (this.sleepFadeTimer) {
clearInterval(this.sleepFadeTimer);
this.sleepFadeTimer = null;
}
this.stopSleepTicker();
const audio = this.audio();
if (audio && !audio.paused) audio.pause();
if (audio) audio.volume = Math.min(1, Math.max(0, Number(this.volume) || 0.5));
this.sleep = SLEEP_OFF;
this.sleepArmedChoiceKey = "off";
this.sleepLabelStr = "Off";
},
// Manual off / play-abort: undo any partial fade.
stopSleep() {
if (this.sleepFadeTimer) {
clearInterval(this.sleepFadeTimer);
this.sleepFadeTimer = null;
}
this.stopSleepTicker();
const audio = this.audio();
if (audio) audio.volume = Math.min(1, Math.max(0, Number(this.volume) || 0.5));
this.sleep = SLEEP_OFF;
this.sleepArmedChoiceKey = "off";
this.sleepLabelStr = "Off";
},
// ---- settings popover (plan §4) ----
toggleAutoplay() {
this.autoplayOn = !this.autoplayOn;
localStorage.setItem("audio_autoplay", this.autoplayOn ? "1" : "0");
},
// Attempt playback after restorePosition when the user opted in. A
// browser autoplay-policy block (NotAllowedError) — or any transient
// play rejection — is swallowed silently; the user just taps play.
maybeAutoplay() {
if (!this.autoplayOn) return;
const audio = this.audio();
if (!audio || !audio.paused) return;
audio.play().catch(() => {
/* autoplay blocked — stay silent */
});
},
async saveBookmark() {
const audio = this.audio();
if (!audio) return;
@@ -558,9 +1279,7 @@ Alpine.data("audioReader", () => ({
},
async saveNote() {
const b = this.bookmarks.find(
(x) => String(x.id) === this.editingBookmarkId,
);
const b = this.bookmarks.find((x) => String(x.id) === this.editingBookmarkId);
if (!b) return;
try {
const resp = await fetch(
@@ -585,10 +1304,10 @@ Alpine.data("audioReader", () => ({
async deleteBookmark(b: Record<string, unknown>) {
try {
const resp = await fetch(
`/api/media-items/${this.mediaItemId}/bookmarks/${b.id}`,
{ method: "DELETE", headers: getAuthHeaders() },
);
const resp = await fetch(`/api/media-items/${this.mediaItemId}/bookmarks/${b.id}`, {
method: "DELETE",
headers: getAuthHeaders(),
});
if (!resp.ok) throw new Error("delete failed");
await this.loadBookmarks();
} catch {
+54 -98
View File
@@ -9,7 +9,6 @@ import {
triggerDownload,
bannerBookTitle,
type AnnotationRow,
type AnnotationSource,
} from "./annotations-banner";
function getMediaId(): string {
@@ -69,8 +68,8 @@ function collectFormData(editorTags: string[]): Record<string, unknown> {
if (el.type === "number") {
const val = parseFloat(el.value);
data[el.name] = isNaN(val) ? 0 : val;
} else if (el.type === "checkbox") {
data[el.name] = (el as HTMLInputElement).checked;
} else if (el instanceof HTMLInputElement && el.type === "checkbox") {
data[el.name] = el.checked;
} else {
data[el.name] = el.value;
}
@@ -169,9 +168,7 @@ Alpine.data("bookDetail", () => {
const coverImg = document.querySelector(
"#metadata-editor-modal img#metadata-cover-preview",
) as HTMLImageElement | null;
const hasCover =
coverImg?.src &&
!coverImg.src.includes("placeholder-book.svg");
const hasCover = coverImg?.src && !coverImg.src.includes("placeholder-book.svg");
const initialTags: string[] = [];
const tagBadges = document.querySelectorAll("#metadata-editor-modal [data-editor-tag]");
@@ -293,7 +290,7 @@ Alpine.data("bookDetail", () => {
// HTML — no \n\n separators in it) and came up empty every time.
this.bannerEditRow = row;
this.bannerEditText = String(
this.bannerSources.find(s => s.row.key === row.key)?.source ?? "",
this.bannerSources.find((s) => s.row.key === row.key)?.source ?? "",
);
this.bannerEditOpen = true;
},
@@ -308,7 +305,7 @@ Alpine.data("bookDetail", () => {
// Full-replace PUT: title + position ride along from the row's
// stored values; the note is the editable part.
const bmId = row.key.slice("bookmark-".length);
const src = this.bannerSources.find(s => s.row.key === row.key);
const src = this.bannerSources.find((s) => s.row.key === row.key);
const resp = await fetch(`/api/media-items/${mediaId}/bookmarks/${bmId}`, {
method: "PUT",
headers: { Authorization: getAuthHeader(), "Content-Type": "application/json" },
@@ -323,8 +320,10 @@ Alpine.data("bookDetail", () => {
const noteId = row.key.slice("note-".length);
// The stored position rides along: the server's PUT hard-sets the
// position column.
const position = (row.loc || "").startsWith("cfi:") || (row.loc || "").startsWith("page:")
? row.loc : undefined;
const position =
(row.loc || "").startsWith("cfi:") || (row.loc || "").startsWith("page:")
? row.loc
: undefined;
const resp = await fetch(`/api/media-items/${mediaId}/notes/${noteId}`, {
method: "PUT",
headers: { Authorization: getAuthHeader(), "Content-Type": "application/json" },
@@ -335,7 +334,7 @@ Alpine.data("bookDetail", () => {
// Highlight-attached note: the full-replace highlight PUT; empty
// content clears the note (allowed by the validator).
const hlId = row.key.slice("hl-".length);
const src = this.bannerSources.find(s => s.row.key === row.key);
const src = this.bannerSources.find((s) => s.row.key === row.key);
if (content === String(src?.source ?? "")) return;
// The banner only ever changes note_text: read the highlight list,
// then full-replace the row with the new note text.
@@ -352,7 +351,8 @@ Alpine.data("bookDetail", () => {
headers: { Authorization: getAuthHeader(), "Content-Type": "application/json" },
body: JSON.stringify({
selection_text: typeof hl.selection_text === "string" ? hl.selection_text : "",
start_position: typeof (hl.start_position ?? "") === "string" ? hl.start_position : "",
start_position:
typeof (hl.start_position ?? "") === "string" ? hl.start_position : "",
end_position: typeof (hl.end_position ?? "") === "string" ? hl.end_position : "",
epubcfi_start: hl.epubcfi_start ?? "",
epubcfi_end: hl.epubcfi_end ?? "",
@@ -368,7 +368,7 @@ Alpine.data("bookDetail", () => {
}
await this.fetchBannerAnnotations();
showToast("Note saved", "success");
} catch (e) {
} catch {
showToast("Could not save the note", "error");
}
},
@@ -385,7 +385,7 @@ Alpine.data("bookDetail", () => {
// Tombstoned server-side: restorable from the Deleted modal.
showToast("Note deleted — restorable from Deleted", "success");
await this.fetchBannerAnnotations();
} catch (_e) {
} catch {
showToast("Could not delete the note", "error");
}
},
@@ -402,7 +402,7 @@ Alpine.data("bookDetail", () => {
// Tombstoned server-side: restorable from the Deleted modal.
showToast("Bookmark deleted — restorable from Deleted", "success");
await this.fetchBannerAnnotations();
} catch (_e) {
} catch {
showToast("Could not delete the bookmark", "error");
}
},
@@ -419,7 +419,7 @@ Alpine.data("bookDetail", () => {
// Tombstoned server-side: restorable from the Deleted modal.
showToast("Highlight deleted — restorable from Deleted", "success");
await this.fetchBannerAnnotations();
} catch (_e) {
} catch {
showToast("Could not delete the highlight", "error");
}
},
@@ -454,7 +454,7 @@ Alpine.data("bookDetail", () => {
);
}
showToast("Annotations exported", "success");
} catch (_e) {
} catch {
showToast("Export failed", "error");
}
},
@@ -479,10 +479,7 @@ Alpine.data("bookDetail", () => {
showToast("Conflict resolved successfully", "success");
setTimeout(() => window.location.reload(), 500);
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to resolve conflict",
"error",
);
showToast(e instanceof Error ? e.message : "Failed to resolve conflict", "error");
}
},
@@ -507,10 +504,7 @@ Alpine.data("bookDetail", () => {
showToast("Annotation restored", "success");
setTimeout(() => window.location.reload(), 500);
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to restore annotation",
"error",
);
showToast(e instanceof Error ? e.message : "Failed to restore annotation", "error");
}
},
@@ -538,10 +532,7 @@ Alpine.data("bookDetail", () => {
showToast("Annotation permanently deleted", "success");
setTimeout(() => window.location.reload(), 500);
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to delete annotation",
"error",
);
showToast(e instanceof Error ? e.message : "Failed to delete annotation", "error");
}
},
@@ -560,8 +551,7 @@ Alpine.data("bookDetail", () => {
const conflictId = document.body.getAttribute("data-conflict-id");
const conflictWinner = document.body.getAttribute("data-conflict-winner");
this.conflictId = conflictId && conflictId !== "null" ? conflictId : "";
this.conflictWinner =
conflictWinner && conflictWinner !== "null" ? conflictWinner : "";
this.conflictWinner = conflictWinner && conflictWinner !== "null" ? conflictWinner : "";
const link = document.getElementById("back-link");
if (!link) return;
@@ -628,10 +618,7 @@ Alpine.data("bookDetail", () => {
this.ratingHover = 0;
showToast("Rating saved", "success");
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to save rating",
"error",
);
showToast(e instanceof Error ? e.message : "Failed to save rating", "error");
} finally {
this.ratingSaving = false;
}
@@ -654,10 +641,7 @@ Alpine.data("bookDetail", () => {
this.ratingHover = 0;
showToast("Rating cleared", "success");
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to clear rating",
"error",
);
showToast(e instanceof Error ? e.message : "Failed to clear rating", "error");
} finally {
this.ratingSaving = false;
}
@@ -691,10 +675,7 @@ Alpine.data("bookDetail", () => {
// Reload so the server-rendered card (or its absence) matches.
window.location.reload();
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to save review",
"error",
);
showToast(e instanceof Error ? e.message : "Failed to save review", "error");
this.reviewSaving = false;
}
},
@@ -714,10 +695,7 @@ Alpine.data("bookDetail", () => {
}
window.location.reload();
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to delete review",
"error",
);
showToast(e instanceof Error ? e.message : "Failed to delete review", "error");
this.reviewSaving = false;
}
},
@@ -732,23 +710,18 @@ Alpine.data("bookDetail", () => {
// conflict. The winner only needs to be a valid source key; the final
// read/unread state is set by the progress write that follows.
if (this.conflictId && this.conflictWinner) {
const cr = await fetch(
`/api/conflicts/${this.conflictId}/resolve`,
{
method: "POST",
headers: {
Authorization: getAuthHeader(),
"Content-Type": "application/json",
},
body: JSON.stringify({ winner: this.conflictWinner }),
const cr = await fetch(`/api/conflicts/${this.conflictId}/resolve`, {
method: "POST",
headers: {
Authorization: getAuthHeader(),
"Content-Type": "application/json",
},
);
body: JSON.stringify({ winner: this.conflictWinner }),
});
// 400 means it was already resolved - treat as no conflict.
if (!cr.ok && cr.status !== 400) {
const err = await cr.json().catch(() => ({}));
throw new Error(
err.error || err.message || "Failed to clear sync conflict",
);
throw new Error(err.error || err.message || "Failed to clear sync conflict");
}
}
@@ -780,16 +753,10 @@ Alpine.data("bookDetail", () => {
}
}
showToast(
read ? "Marked as read" : "Marked as unread",
"success",
);
showToast(read ? "Marked as read" : "Marked as unread", "success");
setTimeout(() => window.location.reload(), 500);
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to update read state",
"error",
);
showToast(e instanceof Error ? e.message : "Failed to update read state", "error");
} finally {
this.readSaving = false;
}
@@ -805,9 +772,7 @@ Alpine.data("bookDetail", () => {
const reader = new FileReader();
reader.onload = (e) => {
const preview = document.getElementById(
"metadata-cover-preview",
) as HTMLImageElement;
const preview = document.getElementById("metadata-cover-preview") as HTMLImageElement;
if (preview && e.target?.result) {
preview.src = e.target.result as string;
}
@@ -820,9 +785,7 @@ Alpine.data("bookDetail", () => {
this.coverAction = "remove";
this.coverFile = null;
this.newCoverPreview = "";
const preview = document.getElementById(
"metadata-cover-preview",
) as HTMLImageElement;
const preview = document.getElementById("metadata-cover-preview") as HTMLImageElement;
if (preview) {
preview.src = "/static/placeholder-book.svg";
}
@@ -880,10 +843,7 @@ Alpine.data("bookDetail", () => {
showToast("Metadata saved successfully", "success");
setTimeout(() => window.location.reload(), 500);
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to save metadata",
"error",
);
showToast(e instanceof Error ? e.message : "Failed to save metadata", "error");
} finally {
this.saving = false;
}
@@ -905,10 +865,7 @@ Alpine.data("bookDetail", () => {
showToast("Book rescanned successfully", "success");
setTimeout(() => window.location.reload(), 500);
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to rescan book",
"error",
);
showToast(e instanceof Error ? e.message : "Failed to rescan book", "error");
} finally {
this.rescanning = false;
}
@@ -926,13 +883,10 @@ Alpine.data("bookDetail", () => {
this.resetting = true;
const mediaId = getMediaId();
try {
const resp = await fetch(
`/api/media-items/${mediaId}/rescan?reset_overrides=true`,
{
method: "POST",
headers: { Authorization: getAuthHeader() },
},
);
const resp = await fetch(`/api/media-items/${mediaId}/rescan?reset_overrides=true`, {
method: "POST",
headers: { Authorization: getAuthHeader() },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to reset metadata");
@@ -940,10 +894,7 @@ Alpine.data("bookDetail", () => {
showToast("Metadata reset to scanned defaults", "success");
setTimeout(() => window.location.reload(), 500);
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to reset metadata",
"error",
);
showToast(e instanceof Error ? e.message : "Failed to reset metadata", "error");
} finally {
this.resetting = false;
}
@@ -958,7 +909,9 @@ Alpine.data("bookDetail", () => {
}
const results = await searchTagSuggestions(this.tagSearch, libraryId);
const current = this.editorTags.map((t: string) => t.toLowerCase());
this.tagSuggestions = results.filter((r: TagSuggestion) => !current.includes(r.value.toLowerCase()));
this.tagSuggestions = results.filter(
(r: TagSuggestion) => !current.includes(r.value.toLowerCase()),
);
this.showTagDropdown = this.tagSuggestions.length > 0;
this.highlightedTagIndex = -1;
},
@@ -984,7 +937,9 @@ Alpine.data("bookDetail", () => {
},
hideEditorTagDropdown() {
setTimeout(() => { this.showTagDropdown = false; }, 200);
setTimeout(() => {
this.showTagDropdown = false;
}, 200);
},
onTagKeydown(event: KeyboardEvent) {
@@ -996,9 +951,10 @@ Alpine.data("bookDetail", () => {
} else if (event.key === "ArrowUp") {
event.preventDefault();
if (this.showTagDropdown && this.tagSuggestions.length > 0) {
this.highlightedTagIndex = this.highlightedTagIndex <= 0
? this.tagSuggestions.length - 1
: this.highlightedTagIndex - 1;
this.highlightedTagIndex =
this.highlightedTagIndex <= 0
? this.tagSuggestions.length - 1
: this.highlightedTagIndex - 1;
}
} else if (event.key === "Enter") {
event.preventDefault();
+9 -16
View File
@@ -51,9 +51,7 @@ Alpine.store("bookPicker", {
const filterDiv = document.getElementById("book-picker-filters");
if (!filterDiv) return;
const inputs = filterDiv.querySelectorAll(
'input[type="text"]',
) as NodeListOf<HTMLInputElement>;
const inputs = filterDiv.querySelectorAll('input[type="text"]') as NodeListOf<HTMLInputElement>;
inputs.forEach((input) => {
input.value = "";
});
@@ -90,10 +88,7 @@ Alpine.store("bookPicker", {
});
if (response.ok) {
showToast(
`Added ${this.selectedBooks.size} books to collection`,
"success",
);
showToast(`Added ${this.selectedBooks.size} books to collection`, "success");
this.close();
location.reload();
} else {
@@ -110,32 +105,30 @@ Alpine.store("bookPicker", {
// Provides methods for template to access store
Alpine.data("bookPicker", () => ({
get isOpen() {
return (window as any).Alpine?.$store.bookPicker.isOpen;
return window.Alpine?.$store.bookPicker.isOpen;
},
get selectedCount() {
return (window as any).Alpine?.$store.bookPicker.selectedCount;
return window.Alpine?.$store.bookPicker.selectedCount;
},
toggleBook(bookId: string) {
(window as any).Alpine?.$store.bookPicker.toggleBook(bookId);
window.Alpine?.$store.bookPicker.toggleBook(bookId);
},
isSelected(bookId: string): boolean {
return (window as any).Alpine?.$store.bookPicker.isSelected(bookId);
return window.Alpine?.$store.bookPicker.isSelected(bookId);
},
clearFilters() {
(window as any).Alpine?.$store.bookPicker.clearFilters();
window.Alpine?.$store.bookPicker.clearFilters();
},
close() {
(window as any).Alpine?.$store.bookPicker.close();
window.Alpine?.$store.bookPicker.close();
},
submit() {
(window as any).Alpine?.$store.bookPicker.submit();
window.Alpine?.$store.bookPicker.submit();
},
}));
export {};
+58 -81
View File
@@ -2,10 +2,7 @@
import { Alpine } from "./alpine";
import { showToast } from "./toast";
import type {
FilterGroupState,
FilterRuleState,
} from "./filter_builder";
import type { FilterGroupState, FilterRuleState } from "./filter_builder";
function clearFilters(): void {
// Clear all form state (reuses helper)
@@ -65,12 +62,8 @@ function clearFormWithoutSubmit(): void {
cleanupDynamicHiddenInputs();
// Reset pagination to defaults
const limitInput = filterForm.querySelector(
'input[name="limit"]',
) as HTMLInputElement;
const offsetInput = filterForm.querySelector(
'input[name="offset"]',
) as HTMLInputElement;
const limitInput = filterForm.querySelector('input[name="limit"]') as HTMLInputElement;
const offsetInput = filterForm.querySelector('input[name="offset"]') as HTMLInputElement;
if (limitInput) limitInput.value = "50";
if (offsetInput) offsetInput.value = "0";
@@ -100,22 +93,21 @@ Alpine.data("bookshelf", () => ({
// inputs are dropped outright) and re-apply the form. Chips refresh
// via the shared htmx:afterRequest hook.
removeFilterChip(param: string): void {
const filterForm = document.getElementById(
"filter-form",
) as HTMLFormElement;
const filterForm = document.getElementById("filter-form") as HTMLFormElement;
if (!filterForm) return;
// Tree chips (qf mode): "rule:<path>" — remove that rule from the
// builder tree, resync the wire inputs, re-apply.
if (param.startsWith("rule:")) {
const builder = builderData();
const path = param.slice(5).split("-").map((n) => parseInt(n, 10));
const path = param
.slice(5)
.split("-")
.map((n) => parseInt(n, 10));
builder?.removeNode(path);
window.htmx.trigger(filterForm, "submit");
return;
}
const input = filterForm.querySelector(
`[name="${param}"]`,
) as HTMLInputElement | null;
const input = filterForm.querySelector(`[name="${param}"]`) as HTMLInputElement | null;
if (input) {
if (input.type === "hidden") {
input.remove();
@@ -225,14 +217,10 @@ Alpine.data("bookshelf", () => ({
// Parse filters JSON (string → object)
const filterData: Record<string, string> =
typeof filter.filters === "string"
? JSON.parse(filter.filters)
: filter.filters;
typeof filter.filters === "string" ? JSON.parse(filter.filters) : filter.filters;
// Get the hidden filter form
const filterForm = document.getElementById(
"filter-form",
) as HTMLFormElement;
const filterForm = document.getElementById("filter-form") as HTMLFormElement;
if (!filterForm) {
showToast("Filter form not found", "error");
return;
@@ -250,9 +238,7 @@ Alpine.data("bookshelf", () => ({
// (qf wins over flat, the pinned precedence).
Object.entries(filterData).forEach(([key, value]) => {
if (!value) return; // Skip empty values
const visibleField = filterForm.querySelector(
`[name="${key}"]`,
) as HTMLInputElement;
const visibleField = filterForm.querySelector(`[name="${key}"]`) as HTMLInputElement;
if (visibleField) {
if (visibleField.type === "checkbox") {
visibleField.checked = value === "true";
@@ -336,9 +322,7 @@ function applyURLFilters(filterForm: HTMLFormElement | null): boolean {
// ignored — the tree IS the query.
const qf = params.get("qf");
if (qf) {
const input = filterForm.querySelector(
'[name="qf"]',
) as HTMLInputElement | null;
const input = filterForm.querySelector('[name="qf"]') as HTMLInputElement | null;
if (input) {
input.value = qf;
} else {
@@ -376,9 +360,7 @@ function applyURLFilters(filterForm: HTMLFormElement | null): boolean {
for (const name of filterNames) {
const value = params.get(name);
if (!value) continue;
const input = filterForm.querySelector(
`[name="${name}"]`,
) as HTMLInputElement | null;
const input = filterForm.querySelector(`[name="${name}"]`) as HTMLInputElement | null;
if (input) {
input.value = value;
applied = true;
@@ -413,14 +395,10 @@ interface FilterChip {
function prettifyGenreLabel(value: string): string {
const trimmed = value.trim();
if (!trimmed || trimmed !== trimmed.toLowerCase()) return trimmed;
const small = new Set([
"a", "an", "and", "of", "the", "in", "on", "for", "to", "or",
]);
const small = new Set(["a", "an", "and", "of", "the", "in", "on", "for", "to", "or"]);
return trimmed
.split(/\s+/)
.map((w, i) =>
i > 0 && small.has(w) ? w : w.charAt(0).toUpperCase() + w.slice(1),
)
.map((w, i) => (i > 0 && small.has(w) ? w : w.charAt(0).toUpperCase() + w.slice(1)))
.join(" ");
}
@@ -450,8 +428,7 @@ function facetChipLabel(name: string, value: string): string {
abridged_filter: "Abridged",
};
const label = names[name] ?? name;
const shown =
name === "genre_filter" ? prettifyGenreLabel(value) : value;
const shown = name === "genre_filter" ? prettifyGenreLabel(value) : value;
return `${label}: ${shown}`;
}
@@ -459,9 +436,7 @@ function facetChipLabel(name: string, value: string): string {
// the shareable URL (history.replaceState) — the single sync point for
// form applies, URL deep-links, sort changes, and clears.
function refreshFilterChips(): void {
const filterForm = document.getElementById(
"filter-form",
) as HTMLFormElement;
const filterForm = document.getElementById("filter-form") as HTMLFormElement;
const bookshelfElem = document.querySelector('[x-data*="bookshelf"]');
if (!filterForm || !bookshelfElem || !(bookshelfElem as any)._x_dataStack) {
return;
@@ -470,9 +445,7 @@ function refreshFilterChips(): void {
if (typeof data.activeFilters === "undefined") return;
const chips: FilterChip[] = [];
const qfInput = filterForm.querySelector(
'[name="qf"]',
) as HTMLInputElement | null;
const qfInput = filterForm.querySelector('[name="qf"]') as HTMLInputElement | null;
if (qfInput && qfInput.value) {
// Tree mode: one chip per rule (red for negations — the chips
@@ -482,24 +455,20 @@ function refreshFilterChips(): void {
try {
const tree = JSON.parse(qfInput.value) as FilterGroupState;
const walk = (group: FilterGroupState, path: number[]) => {
(group.children as (FilterRuleState | FilterGroupState)[]).forEach(
(child, idx) => {
const childPath = [...path, idx];
if ("children" in (child as FilterGroupState)) {
walk(child as FilterGroupState, childPath);
return;
}
const rule = child as FilterRuleState;
const label = builder
? builder.chipLabelFor(rule)
: `${rule.field} ${rule.op}`;
chips.push({
param: "rule:" + childPath.join("-"),
label,
negated: builder?.isNegated(rule) ?? false,
});
},
);
(group.children as (FilterRuleState | FilterGroupState)[]).forEach((child, idx) => {
const childPath = [...path, idx];
if ("children" in (child as FilterGroupState)) {
walk(child as FilterGroupState, childPath);
return;
}
const rule = child as FilterRuleState;
const label = builder ? builder.chipLabelFor(rule) : `${rule.field} ${rule.op}`;
chips.push({
param: "rule:" + childPath.join("-"),
label,
negated: builder?.isNegated(rule) ?? false,
});
});
};
walk(tree, []);
} catch {
@@ -527,12 +496,27 @@ function refreshFilterChips(): void {
// shareable (plan: chips bar is URL-synced).
const url = new URL(window.location.href);
const keep = new Set([
"author_filter", "series_filter", "genre_filter", "publisher_filter",
"tags_filter", "narrator_filter", "language_filter", "story_arc_filter",
"imprint_filter", "edition_filter", "age_rating_filter",
"format_group_filter", "manga_type_filter", "reading_direction_filter",
"explicit_filter", "abridged_filter", "year_min", "year_max",
"has_cover", "library_id", "qf",
"author_filter",
"series_filter",
"genre_filter",
"publisher_filter",
"tags_filter",
"narrator_filter",
"language_filter",
"story_arc_filter",
"imprint_filter",
"edition_filter",
"age_rating_filter",
"format_group_filter",
"manga_type_filter",
"reading_direction_filter",
"explicit_filter",
"abridged_filter",
"year_min",
"year_max",
"has_cover",
"library_id",
"qf",
]);
const next = new URLSearchParams();
url.searchParams.forEach((value, key) => {
@@ -551,9 +535,7 @@ function refreshFilterChips(): void {
}
function initBookshelf(): void {
const filterForm = document.getElementById(
"filter-form",
) as HTMLFormElement;
const filterForm = document.getElementById("filter-form") as HTMLFormElement;
// Active-filter chips refresh after every grid refetch — drawer
// applies, sort changes, and pagination all include the form's
@@ -565,9 +547,7 @@ function initBookshelf(): void {
// The live match count is scope-aware: switching the top library
// picker re-runs it (the count fetch reads the select at fetch time —
// this is what makes it fire).
const librarySelect = document.getElementById(
"library-select",
) as HTMLSelectElement | null;
const librarySelect = document.getElementById("library-select") as HTMLSelectElement | null;
librarySelect?.addEventListener("change", () => {
builderData()?.scheduleMatchCount();
});
@@ -587,13 +567,10 @@ function initBookshelf(): void {
// Check if books were already rendered server-side
const booksGrid = document.getElementById("books-grid");
const hasServerBooks =
booksGrid && booksGrid.querySelector("[data-book-id]") !== null;
const hasServerBooks = booksGrid && booksGrid.querySelector("[data-book-id]") !== null;
if (!hasServerBooks || urlApplied) {
const librarySelect = document.getElementById(
"library-select",
) as HTMLSelectElement;
const librarySelect = document.getElementById("library-select") as HTMLSelectElement;
librarySelect?.addEventListener("change", () => {
localStorage.setItem("selectedLibrary", librarySelect.value);
});
+18 -41
View File
@@ -146,24 +146,14 @@ async function handleCreateRule(event: Event): Promise<void> {
if (!token || !collectionId) return;
const fieldInput = document.getElementById("rule-field") as HTMLSelectElement;
const operatorInput = document.getElementById(
"rule-operator",
) as HTMLSelectElement;
const operatorInput = document.getElementById("rule-operator") as HTMLSelectElement;
const valueInput = document.getElementById("rule-value") as HTMLInputElement;
const enabledInput = document.getElementById(
"rule-enabled",
) as HTMLInputElement;
const enabledInput = document.getElementById("rule-enabled") as HTMLInputElement;
const priorityInput = document.querySelector(
'input[name="priority"]:checked',
) as HTMLInputElement;
if (
!fieldInput ||
!operatorInput ||
!valueInput ||
!enabledInput ||
!priorityInput
) {
if (!fieldInput || !operatorInput || !valueInput || !enabledInput || !priorityInput) {
showToast("Missing form fields", "error");
return;
}
@@ -204,9 +194,7 @@ async function testRule(): Promise<void> {
if (!token) return;
const fieldInput = document.getElementById("rule-field") as HTMLSelectElement;
const operatorInput = document.getElementById(
"rule-operator",
) as HTMLSelectElement;
const operatorInput = document.getElementById("rule-operator") as HTMLSelectElement;
const valueInput = document.getElementById("rule-value") as HTMLInputElement;
if (!fieldInput || !operatorInput || !valueInput) {
@@ -256,8 +244,7 @@ async function testRule(): Promise<void> {
'<p style="color: var(--text-secondary); font-size: 0.875rem;">Found ' +
result.matches.length +
" matching books:</p>";
html +=
'<div style="display: flex; flex-direction: column; gap: 0.5rem;">';
html += '<div style="display: flex; flex-direction: column; gap: 0.5rem;">';
result.matches.slice(0, 20).forEach((book: TestRuleMatch) => {
html +=
@@ -316,17 +303,14 @@ async function toggleRule(ruleId: string, enabled: boolean): Promise<void> {
if (!token || !collectionId) return;
try {
const response = await fetch(
`/api/collections/${collectionId}/rules/${ruleId}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ enabled }),
const response = await fetch(`/api/collections/${collectionId}/rules/${ruleId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
);
body: JSON.stringify({ enabled }),
});
if (response.ok) {
showToast("Rule updated", "success");
@@ -347,13 +331,10 @@ async function deleteRule(ruleId: string): Promise<void> {
if (!token || !collectionId) return;
try {
const response = await fetch(
`/api/collections/${collectionId}/rules/${ruleId}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
},
);
const response = await fetch(`/api/collections/${collectionId}/rules/${ruleId}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
showToast("Rule deleted", "success");
@@ -369,13 +350,9 @@ async function deleteRule(ruleId: string): Promise<void> {
function clearForm(): void {
const fieldInput = document.getElementById("rule-field") as HTMLSelectElement;
const operatorInput = document.getElementById(
"rule-operator",
) as HTMLSelectElement;
const operatorInput = document.getElementById("rule-operator") as HTMLSelectElement;
const valueInput = document.getElementById("rule-value") as HTMLInputElement;
const enabledInput = document.getElementById(
"rule-enabled",
) as HTMLInputElement;
const enabledInput = document.getElementById("rule-enabled") as HTMLInputElement;
const priorityInput = document.querySelector(
'input[name="priority"][value="2"]',
) as HTMLInputElement;
+55 -116
View File
@@ -1,10 +1,7 @@
import { Alpine } from "./alpine";
import { showToast } from "./toast";
import { createWebSocket } from "./websocket";
import {
initLibrarySwitcher,
switchWithTransition,
} from "./library-switcher";
import { initLibrarySwitcher, switchWithTransition } from "./library-switcher";
let collectionId: string | null = null;
@@ -18,10 +15,7 @@ function initializeCollectionWebSocket(): void {
createWebSocket({
onMessage: (message) => {
if (
message.type === "collection_updated" &&
message.data.collection_id === collectionId
) {
if (message.type === "collection_updated" && message.data.collection_id === collectionId) {
const actionText =
message.data.action === "books_added"
? `Added ${message.data.count || 0} book(s)`
@@ -100,10 +94,7 @@ function renderRules(rules: CollectionRule[]): void {
.join("");
}
async function createRule(
collectionId: string,
rule: Partial<CollectionRule>,
): Promise<void> {
async function createRule(collectionId: string, rule: Partial<CollectionRule>): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
@@ -137,13 +128,10 @@ async function deleteRule(collectionId: string, ruleId: string): Promise<void> {
if (!confirm("Are you sure you want to delete this rule?")) return;
try {
const response = await fetch(
`/api/collections/${collectionId}/rules/${ruleId}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
},
);
const response = await fetch(`/api/collections/${collectionId}/rules/${ruleId}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
showToast("Rule deleted", "success");
@@ -155,25 +143,19 @@ async function deleteRule(collectionId: string, ruleId: string): Promise<void> {
}
}
async function testRule(
collectionId: string,
rule: Partial<CollectionRule>,
): Promise<void> {
async function testRule(collectionId: string, rule: Partial<CollectionRule>): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const response = await fetch(
`/api/collections/${collectionId}/rules/test`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(rule),
const response = await fetch(`/api/collections/${collectionId}/rules/test`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
);
body: JSON.stringify(rule),
});
if (response.ok) {
const results = await response.json();
@@ -229,9 +211,7 @@ const borderClasses: Record<string, string> = {
};
function selectColor(color: string): void {
const colorInput = document.getElementById(
"collection-color",
) as HTMLInputElement;
const colorInput = document.getElementById("collection-color") as HTMLInputElement;
if (colorInput) {
colorInput.value = color;
}
@@ -239,9 +219,7 @@ function selectColor(color: string): void {
(btn as HTMLElement).style.outline = "none";
(btn as HTMLElement).style.outlineOffset = "0";
});
const selectedBtn = document.querySelector(
`.color-option[data-color="${color}"]`,
) as HTMLElement;
const selectedBtn = document.querySelector(`.color-option[data-color="${color}"]`) as HTMLElement;
if (selectedBtn) {
selectedBtn.style.outline = "3px solid var(--text-primary)";
selectedBtn.style.outlineOffset = "3px";
@@ -256,9 +234,7 @@ function closeCollectionModal(): void {
}
function initColorSelection(): void {
const colorInput = document.getElementById(
"collection-color",
) as HTMLInputElement;
const colorInput = document.getElementById("collection-color") as HTMLInputElement;
if (colorInput && colorInput.value) {
selectColor(colorInput.value);
}
@@ -291,9 +267,7 @@ function setupHTMXModalInit(): void {
function showAllIcons(): void {
populateIconGrid();
const searchInput = document.getElementById(
"icon-search",
) as HTMLInputElement;
const searchInput = document.getElementById("icon-search") as HTMLInputElement;
if (searchInput) {
searchInput.value = "";
}
@@ -359,12 +333,8 @@ function populateIconGrid(): void {
}
function selectIcon(icon: string): void {
const iconInput = document.getElementById(
"collection-icon",
) as HTMLInputElement;
const searchInput = document.getElementById(
"icon-search",
) as HTMLInputElement;
const iconInput = document.getElementById("collection-icon") as HTMLInputElement;
const searchInput = document.getElementById("icon-search") as HTMLInputElement;
if (iconInput) iconInput.value = icon;
if (searchInput) searchInput.value = icon;
@@ -374,9 +344,7 @@ function selectIcon(icon: string): void {
(btn as HTMLElement).style.backgroundColor = "";
});
const selectedBtn = document.querySelector(
`.icon-btn[data-emoji="${icon}"]`,
) as HTMLElement;
const selectedBtn = document.querySelector(`.icon-btn[data-emoji="${icon}"]`) as HTMLElement;
if (selectedBtn) {
selectedBtn.style.outline = "2px solid var(--text-primary)";
selectedBtn.style.backgroundColor = "var(--bg-secondary)";
@@ -385,9 +353,7 @@ function selectIcon(icon: string): void {
function filterIcons(searchTerm: string): void {
const iconGrid = document.getElementById("icon-grid");
const iconInput = document.getElementById(
"collection-icon",
) as HTMLInputElement;
const iconInput = document.getElementById("collection-icon") as HTMLInputElement;
if (iconInput) iconInput.value = searchTerm;
if (!iconGrid) return;
const buttons = iconGrid.querySelectorAll(".icon-btn");
@@ -400,9 +366,7 @@ function filterIcons(searchTerm: string): void {
const matches =
searchTerm === "" ||
emoji.includes(searchTerm) ||
keywords.some((keyword) =>
keyword.toLowerCase().includes(lowerSearchTerm),
);
keywords.some((keyword) => keyword.toLowerCase().includes(lowerSearchTerm));
(btn as HTMLElement).style.display = matches ? "" : "none";
});
@@ -427,11 +391,10 @@ function initCollectionsPage(): void {
// Rendering ownership: the grid is server-rendered markup. Swap
// in the templ partial instead of re-rendering from JSON.
const param = libraryId ? `?library_id=${encodeURIComponent(libraryId)}` : "";
await window.htmx.ajax(
"GET",
`/collections/${collId}/books-grid${param}`,
{ target: "#books-container", swap: "innerHTML" },
);
await window.htmx.ajax("GET", `/collections/${collId}/books-grid${param}`, {
target: "#books-container",
swap: "innerHTML",
});
const container = document.getElementById("books-container");
if (container) Alpine.initTree(container);
});
@@ -461,13 +424,10 @@ async function doRemoveBook(id: string): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const response = await fetch(
`/api/collections/${getCollectionId()}/books/${id}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
},
);
const response = await fetch(`/api/collections/${getCollectionId()}/books/${id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
showToast("Removed from collection", "success");
setTimeout(() => location.reload(), 500);
@@ -483,17 +443,14 @@ async function doBulkRemove(ids: string[]): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const response = await fetch(
`/api/collections/${getCollectionId()}/books/bulk-remove`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ book_ids: ids }),
const response = await fetch(`/api/collections/${getCollectionId()}/books/bulk-remove`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
);
body: JSON.stringify({ book_ids: ids }),
});
if (response.ok) {
showToast(`Removed ${ids.length} book(s)`, "success");
setTimeout(() => location.reload(), 500);
@@ -506,19 +463,14 @@ async function doBulkRemove(ids: string[]): Promise<void> {
}
function filterCollectionBooks(): void {
const input = document.getElementById(
"collection-search",
) as HTMLInputElement;
const input = document.getElementById("collection-search") as HTMLInputElement;
if (!input) return;
const query = input.value.toLowerCase();
const cards = document.querySelectorAll<HTMLElement>(
".collection-book-card",
);
const cards = document.querySelectorAll<HTMLElement>(".collection-book-card");
cards.forEach((card) => {
const title = (card.dataset.title || "").toLowerCase();
const author = (card.dataset.author || "").toLowerCase();
card.style.display =
title.includes(query) || author.includes(query) ? "" : "none";
card.style.display = title.includes(query) || author.includes(query) ? "" : "none";
});
}
@@ -601,19 +553,11 @@ Alpine.data("collections", () => ({
// resulting order. The DOM cards are the server-rendered truth for CURRENT
// order; the API is the truth for SAVED order. On failure the grid refresh
// from the templ partial so the UI never lies.
async function moveCollectionBook(
this: any,
mediaId: string,
direction: number,
): Promise<void> {
async function moveCollectionBook(this: any, mediaId: string, direction: number): Promise<void> {
const container = document.getElementById("books-container");
if (!container) return;
const cards = Array.from(
container.querySelectorAll<HTMLElement>(".collection-book-card"),
);
const index = cards.findIndex(
(c) => c.dataset.mediaId === mediaId,
);
const cards = Array.from(container.querySelectorAll<HTMLElement>(".collection-book-card"));
const index = cards.findIndex((c) => c.dataset.mediaId === mediaId);
const target = index + direction;
if (index < 0 || target < 0 || target >= cards.length) return;
@@ -624,32 +568,27 @@ async function moveCollectionBook(
container.insertBefore(cards[target], cards[index]);
}
const orderedIds = Array.from(
container.querySelectorAll<HTMLElement>(".collection-book-card"),
)
const orderedIds = Array.from(container.querySelectorAll<HTMLElement>(".collection-book-card"))
.map((c) => c.dataset.mediaId)
.filter((id): id is string => Boolean(id));
const token = localStorage.getItem("token");
if (!token) return;
try {
const response = await fetch(
`/api/collections/${getCollectionId()}/items/order`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ item_ids: orderedIds }),
const response = await fetch(`/api/collections/${getCollectionId()}/items/order`, {
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
);
body: JSON.stringify({ item_ids: orderedIds }),
});
if (!response.ok) {
showToast("Failed to save the new order", "error");
// Rendering ownership: the templ partial re-renders the true order.
const gridTarget = document.getElementById("books-container");
if (gridTarget && (window as any).htmx) {
(window as any).htmx.ajax(
if (gridTarget && window.htmx) {
window.htmx.ajax(
"GET",
`/collections/${getCollectionId()}/books-grid${window.location.search.startsWith("?") ? window.location.search : ""}`,
{ target: "#books-container", swap: "innerHTML" },
+8 -24
View File
@@ -24,9 +24,7 @@ async function refreshConflicts(): Promise<void> {
// ownership: JS decides what shows; templ rendered what they look like).
function filterConflicts(): void {
const select = document.getElementById(
"status-filter",
) as HTMLSelectElement | null;
const select = document.getElementById("status-filter") as HTMLSelectElement | null;
const filter = select?.value ?? "unresolved";
let total = 0;
let visible = 0;
@@ -44,9 +42,7 @@ function filterConflicts(): void {
}
function toggleAllConflicts(): void {
const selectAll = document.getElementById(
"select-all-conflicts",
) as HTMLInputElement | null;
const selectAll = document.getElementById("select-all-conflicts") as HTMLInputElement | null;
if (!selectAll) return;
document.querySelectorAll<HTMLElement>(".conflict-item").forEach((item) => {
if (item.style.display === "none") return;
@@ -57,9 +53,7 @@ function toggleAllConflicts(): void {
}
function getSelectedConflictIds(): string[] {
return Array.from(
document.querySelectorAll<HTMLInputElement>(".conflict-checkbox:checked"),
)
return Array.from(document.querySelectorAll<HTMLInputElement>(".conflict-checkbox:checked"))
.map((box) => box.dataset.conflictId || "")
.filter((id) => id !== "");
}
@@ -75,12 +69,8 @@ function updateBulkActions(): void {
function resolveSelected(): void {
const ids = getSelectedConflictIds();
if (ids.length === 0) return;
const strategyEl = document.getElementById(
"bulk-strategy",
) as HTMLSelectElement | null;
const strategy = (strategyEl?.value ?? "most_recent") as
| "most_recent"
| "highest_progress";
const strategyEl = document.getElementById("bulk-strategy") as HTMLSelectElement | null;
const strategy = (strategyEl?.value ?? "most_recent") as "most_recent" | "highest_progress";
void bulkResolve(strategy, ids);
}
@@ -195,9 +185,7 @@ async function dismissAllResolved(): Promise<void> {
function showResolveModal(conflictId: string): void {
const modal = document.getElementById("resolve-modal");
const conflictIdInput = document.getElementById(
"resolve-conflict-id",
) as HTMLInputElement;
const conflictIdInput = document.getElementById("resolve-conflict-id") as HTMLInputElement;
if (modal && conflictIdInput) {
conflictIdInput.value = conflictId;
@@ -216,12 +204,8 @@ function handleResolveSubmit(event: Event): void {
event.preventDefault();
const form = event.target as HTMLFormElement;
const conflictId = (
form.querySelector("#resolve-conflict-id") as HTMLInputElement
)?.value;
const winner = (
form.querySelector('input[name="winner"]:checked') as HTMLInputElement
)?.value;
const conflictId = (form.querySelector("#resolve-conflict-id") as HTMLInputElement)?.value;
const winner = (form.querySelector('input[name="winner"]:checked') as HTMLInputElement)?.value;
if (!conflictId || !winner) {
showToast("Please select a winner", "error");
+27 -77
View File
@@ -174,16 +174,7 @@ const FILTER_FIELDS: FilterField[] = [
{ id: "in", label: "In", requiresValue: true },
],
valueType: "select",
options: [
"English",
"Spanish",
"French",
"German",
"Japanese",
"Chinese",
"Russian",
"Other",
],
options: ["English", "Spanish", "French", "German", "Japanese", "Chinese", "Russian", "Other"],
},
{
id: "format",
@@ -219,7 +210,11 @@ const FILTER_FIELDS: FilterField[] = [
];
let ruleCounter = 0;
let selectedBooks: Map<string, BookInfo> = new Map();
// Books picked in the builder only ever carry identity fields; the API's
// BookInfo conflict/format/badge metadata is attached server-side later.
type SelectedBook = Pick<BookInfo, "media_item_id" | "title" | "author" | "cover_image_path">;
let selectedBooks: Map<string, SelectedBook> = new Map();
let customSectionTimeout: number | null = null;
function initCustomSectionBuilder(): void {
@@ -300,45 +295,28 @@ function addFilterRule(): void {
container.appendChild(ruleElement);
const fieldSelect = ruleElement.querySelector(
".field-select",
) as HTMLSelectElement;
const removeBtn = ruleElement.querySelector(
".remove-rule-btn",
) as HTMLButtonElement;
const fieldSelect = ruleElement.querySelector(".field-select") as HTMLSelectElement;
const removeBtn = ruleElement.querySelector(".remove-rule-btn") as HTMLButtonElement;
fieldSelect.addEventListener("change", () => onFieldChange(ruleElement));
removeBtn.addEventListener("click", () => removeFilterRule(ruleId));
}
function onFieldChange(ruleElement: HTMLElement): void {
const fieldSelect = ruleElement.querySelector(
".field-select",
) as HTMLSelectElement;
const operatorSelect = ruleElement.querySelector(
".operator-select",
) as HTMLSelectElement;
const valueInput = ruleElement.querySelector(
".value-input",
) as HTMLInputElement;
const fieldSelect = ruleElement.querySelector(".field-select") as HTMLSelectElement;
const operatorSelect = ruleElement.querySelector(".operator-select") as HTMLSelectElement;
const valueInput = ruleElement.querySelector(".value-input") as HTMLInputElement;
const fieldId = fieldSelect.value;
const field = FILTER_FIELDS.find((f) => f.id === fieldId);
operatorSelect.innerHTML = field
? field.operators
.map((op) => `<option value="${op.id}">${op.label}</option>`)
.join("")
? field.operators.map((op) => `<option value="${op.id}">${op.label}</option>`).join("")
: '<option value="">Select field first...</option>';
operatorSelect.disabled = !field;
if (
field &&
field.operators.some(
(op) => op.id === operatorSelect.value && op.requiresValue,
)
) {
if (field && field.operators.some((op) => op.id === operatorSelect.value && op.requiresValue)) {
valueInput.classList.remove("hidden");
if (field.valueType === "select" && field.options) {
@@ -356,9 +334,7 @@ function onFieldChange(ruleElement: HTMLElement): void {
}
operatorSelect.addEventListener("change", () => {
const selectedOp = field?.operators.find(
(op) => op.id === operatorSelect.value,
);
const selectedOp = field?.operators.find((op) => op.id === operatorSelect.value);
if (selectedOp?.requiresValue) {
valueInput.classList.remove("hidden");
} else {
@@ -384,15 +360,9 @@ function onBookSearchInput(): void {
}
async function searchBooks(): Promise<void> {
const searchInput = document.getElementById(
"book-search",
) as HTMLInputElement;
const librarySelect = document.getElementById(
"section-library",
) as HTMLSelectElement;
const resultsContainer = document.getElementById(
"search-results",
) as HTMLElement;
const searchInput = document.getElementById("book-search") as HTMLInputElement;
const librarySelect = document.getElementById("section-library") as HTMLSelectElement;
const resultsContainer = document.getElementById("search-results") as HTMLElement;
const query = searchInput?.value.trim();
const libraryId = librarySelect?.value;
@@ -426,9 +396,7 @@ async function searchBooks(): Promise<void> {
}
function displaySearchResults(books: BookInfo[]): void {
const resultsContainer = document.getElementById(
"search-results",
) as HTMLElement;
const resultsContainer = document.getElementById("search-results") as HTMLElement;
if (!resultsContainer) return;
if (books.length === 0) {
@@ -458,11 +426,7 @@ function displaySearchResults(books: BookInfo[]): void {
resultsContainer.classList.remove("hidden");
}
const addBookToSelection = (
bookId: string,
title: string,
author: string,
): void => {
const addBookToSelection = (bookId: string, title: string, author: string): void => {
if (selectedBooks.has(bookId)) {
showToast("Book already selected", "error");
return;
@@ -508,12 +472,8 @@ function updateSelectedBooksDisplay(): void {
}
async function loadPreview(): Promise<void> {
const previewContainer = document.getElementById(
"preview-container",
) as HTMLElement;
const librarySelect = document.getElementById(
"section-library",
) as HTMLSelectElement;
const previewContainer = document.getElementById("preview-container") as HTMLElement;
const librarySelect = document.getElementById("section-library") as HTMLSelectElement;
const libraryId = librarySelect?.value;
if (!libraryId) {
@@ -543,8 +503,7 @@ async function loadPreview(): Promise<void> {
}
} catch (error) {
console.error("Preview error:", error);
previewContainer.innerHTML =
'<p class="text-center text-red-500">Failed to load preview</p>';
previewContainer.innerHTML = '<p class="text-center text-red-500">Failed to load preview</p>';
}
}
@@ -556,15 +515,9 @@ function gatherFilterRules(): FilterRule[] {
const rules: FilterRule[] = [];
ruleElements.forEach((element, index) => {
const fieldSelect = element.querySelector(
".field-select",
) as HTMLSelectElement;
const operatorSelect = element.querySelector(
".operator-select",
) as HTMLSelectElement;
const valueInput = element.querySelector(
".value-input",
) as HTMLInputElement;
const fieldSelect = element.querySelector(".field-select") as HTMLSelectElement;
const operatorSelect = element.querySelector(".operator-select") as HTMLSelectElement;
const valueInput = element.querySelector(".value-input") as HTMLInputElement;
if (fieldSelect.value && operatorSelect.value) {
rules.push({
@@ -581,9 +534,7 @@ function gatherFilterRules(): FilterRule[] {
}
function displayPreview(items: BookInfo[]): void {
const previewContainer = document.getElementById(
"preview-container",
) as HTMLElement;
const previewContainer = document.getElementById("preview-container") as HTMLElement;
if (!previewContainer) return;
if (items.length === 0) {
@@ -626,8 +577,7 @@ async function saveCustomSection(event: Event): Promise<void> {
const name = formData.get("name") as string;
const icon = formData.get("icon") as string;
const description = formData.get("description") as string;
const matchType = (document.getElementById("match-type") as HTMLSelectElement)
.value;
const matchType = (document.getElementById("match-type") as HTMLSelectElement).value;
if (!libraryId || !name) {
showToast("Please fill in required fields", "error");
+46 -115
View File
@@ -7,9 +7,7 @@ const SCROLL_AMOUNT = 300;
let scanListenerRegistered = false;
function scrollCarousel(collectionId: string, direction: number): void {
const track = document.getElementById(
`carousel-track-${collectionId}`,
) as HTMLElement;
const track = document.getElementById(`carousel-track-${collectionId}`) as HTMLElement;
if (!track) return;
const scrollAmount = direction * SCROLL_AMOUNT;
@@ -17,20 +15,15 @@ function scrollCarousel(collectionId: string, direction: number): void {
}
async function openDashboardSettings(): Promise<void> {
const librarySelect = document.getElementById(
"library-select",
) as HTMLSelectElement;
const librarySelect = document.getElementById("library-select") as HTMLSelectElement;
const libraryId = librarySelect?.value || "";
// Empty select = the All Libraries view: its preferences are the GLOBAL
// scope — the server returns that row when library_id is absent.
const query = libraryId ? `?library_id=${encodeURIComponent(libraryId)}` : "";
const response = await fetch(
`/api/dashboard/preferences${query}`,
{
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
},
);
const response = await fetch(`/api/dashboard/preferences${query}`, {
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
});
if (response.ok) {
const prefs = await response.json();
@@ -38,11 +31,8 @@ async function openDashboardSettings(): Promise<void> {
// Scope indicator: make it unmistakable what a save will customize.
const scopeEl = document.getElementById("dashboard-scope-label");
if (scopeEl) {
const optionText =
librarySelect?.selectedOptions[0]?.textContent || libraryId;
scopeEl.textContent = (
libraryId ? optionText : optionText || "All Libraries"
)
const optionText = librarySelect?.selectedOptions[0]?.textContent || libraryId;
scopeEl.textContent = (libraryId ? optionText : optionText || "All Libraries")
.replace(/\s*\(\d+\)$/, "")
.trim();
}
@@ -52,16 +42,12 @@ async function openDashboardSettings(): Promise<void> {
return;
}
const modal = document.getElementById(
"dashboard-settings-modal",
) as HTMLElement;
const modal = document.getElementById("dashboard-settings-modal") as HTMLElement;
modal?.classList.remove("hidden");
}
function applyPreferencesToModal(prefs: any): void {
const collectionList = document.getElementById(
"collection-list",
) as HTMLElement;
const collectionList = document.getElementById("collection-list") as HTMLElement;
if (!collectionList) return;
const slider = document.querySelector(
@@ -73,16 +59,12 @@ function applyPreferencesToModal(prefs: any): void {
display.textContent = String(prefs.items_per_section);
}
const items = collectionList.querySelectorAll(
"[data-collection-id]",
) as NodeListOf<HTMLElement>;
const items = collectionList.querySelectorAll("[data-collection-id]") as NodeListOf<HTMLElement>;
const orderedItems: HTMLElement[] = [];
items.forEach((item) => {
const collectionId = item.dataset.collectionId;
const checkbox = item.querySelector(
'input[type="checkbox"]',
) as HTMLInputElement;
const checkbox = item.querySelector('input[type="checkbox"]') as HTMLInputElement;
const isHidden = prefs.hidden_collections.includes(collectionId);
if (checkbox) checkbox.checked = !isHidden;
@@ -99,32 +81,25 @@ function applyPreferencesToModal(prefs: any): void {
}
function closeDashboardSettings(): void {
const modal = document.getElementById(
"dashboard-settings-modal",
) as HTMLElement;
const modal = document.getElementById("dashboard-settings-modal") as HTMLElement;
modal?.classList.add("hidden");
}
async function fetchAndRenderSections(libraryId: string): Promise<void> {
const param = libraryId ? `library_id=${libraryId}` : "";
const response = await fetch(
`/api/dashboard/sections?${param}`,
{
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
"Content-Type": "application/json",
},
const response = await fetch(`/api/dashboard/sections?${param}`, {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
"Content-Type": "application/json",
},
);
});
if (!response.ok) throw new Error("Failed to load sections");
const data = await response.json();
renderDashboardCollections(data.sections);
}
async function saveDashboardSettings(): Promise<void> {
const collectionList = document.getElementById(
"collection-list",
) as HTMLElement;
const collectionList = document.getElementById("collection-list") as HTMLElement;
if (!collectionList) return;
const collectionItems = collectionList.querySelectorAll(
"[data-collection-id]",
@@ -133,9 +108,7 @@ async function saveDashboardSettings(): Promise<void> {
const collectionOrder: string[] = [];
collectionItems.forEach((item) => {
const collectionId = item.dataset.collectionId;
const checkbox = item.querySelector(
'input[type="checkbox"]',
) as HTMLInputElement;
const checkbox = item.querySelector('input[type="checkbox"]') as HTMLInputElement;
if (collectionId) {
collectionOrder.push(collectionId);
if (checkbox && !checkbox.checked) {
@@ -144,12 +117,9 @@ async function saveDashboardSettings(): Promise<void> {
}
});
const itemsPerCollection =
(document.querySelector("#items-count-display") as HTMLElement)
?.textContent || "20";
(document.querySelector("#items-count-display") as HTMLElement)?.textContent || "20";
try {
const librarySelect = document.getElementById(
"library-select",
) as HTMLSelectElement;
const librarySelect = document.getElementById("library-select") as HTMLSelectElement;
const libraryId = librarySelect?.value || "";
const response = await apiPut("/dashboard/preferences", {
// Empty select = the All Libraries view: an explicit JSON null is
@@ -255,9 +225,7 @@ function renderSectionHTML(section: SectionData): string {
style="scrollbar-width: none; -ms-overflow-style: none;">
${
section.items.length > 0
? section.items
.map((item) => renderBookCard(item))
.join("")
? section.items.map((item) => renderBookCard(item)).join("")
: '<div class="text-center py-8 w-full" style="color: var(--text-secondary);"><p>No items in this collection</p></div>'
}
</div>
@@ -279,9 +247,7 @@ function renderSectionHTML(section: SectionData): string {
}
function renderDashboardCollections(sections: SectionData[]): void {
const container = document.getElementById(
"collections-container",
) as HTMLElement;
const container = document.getElementById("collections-container") as HTMLElement;
if (!container) return;
const currentWood = container.getAttribute("data-wood");
@@ -342,7 +308,6 @@ function badgeTitleFor(badge: string): string {
return "";
}
function renderBookCard(book: BookInfo): string {
const coverUrl = book.cover_image_path || "/static/placeholder-book.svg";
const actionHref = book.has_conflict
@@ -354,11 +319,7 @@ function renderBookCard(book: BookInfo): string {
: isAudio
? `Listen to ${book.title}`
: `Read ${book.title}`;
const actionTitle = book.has_conflict
? "Resolve progress conflict"
: isAudio
? "Listen"
: "Read";
const actionTitle = book.has_conflict ? "Resolve progress conflict" : isAudio ? "Listen" : "Read";
const actionIcon = isAudio ? HEADPHONES_ICON : BOOK_OPEN_ICON;
const badgeIcon = badgeIconFor(book.badge || "");
@@ -393,20 +354,13 @@ function renderBookCard(book: BookInfo): string {
}
async function reloadPage(): Promise<void> {
const librarySelect = document.getElementById(
"library-select",
) as HTMLSelectElement;
const librarySelect = document.getElementById("library-select") as HTMLSelectElement;
const libraryId = librarySelect?.value || "";
await switchWithTransition("collections-container", () =>
fetchAndRenderSections(libraryId),
);
await switchWithTransition("collections-container", () => fetchAndRenderSections(libraryId));
}
function updateItemsCount(
input: HTMLInputElement,
displayTarget: string,
): void {
function updateItemsCount(input: HTMLInputElement, displayTarget: string): void {
const displayElement = document.getElementById(displayTarget) as HTMLElement;
if (displayElement) {
displayElement.textContent = input.value;
@@ -414,9 +368,7 @@ function updateItemsCount(
}
function initDragAndDrop(): void {
const collectionList = document.getElementById(
"collection-list",
) as HTMLElement;
const collectionList = document.getElementById("collection-list") as HTMLElement;
if (!collectionList) return;
let draggedItem: HTMLElement | null = null;
@@ -440,11 +392,7 @@ function initDragAndDrop(): void {
collectionList.addEventListener("dragover", (e: Event) => {
e.preventDefault();
const target = e.target as HTMLElement;
if (
target.classList.contains("collection-item") &&
target !== draggedItem &&
draggedItem
) {
if (target.classList.contains("collection-item") && target !== draggedItem && draggedItem) {
const rect = target.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if ((e as DragEvent).clientY < midY) {
@@ -509,8 +457,7 @@ function initDashboard() {
switch (action) {
case "scroll-carousel": {
const collectionId =
target.dataset.collectionId || actionElem?.dataset.collectionId;
const collectionId = target.dataset.collectionId || actionElem?.dataset.collectionId;
const direction = parseInt(
target.dataset.direction || actionElem?.dataset.direction || "0",
);
@@ -531,8 +478,7 @@ function initDashboard() {
break;
case "restore-system-collection": {
const colName =
actionElem?.dataset.collectionName || target.dataset.collectionName;
const colName = actionElem?.dataset.collectionName || target.dataset.collectionName;
const colTitle =
actionElem?.dataset.collectionTitle ||
target.dataset.collectionTitle ||
@@ -565,55 +511,40 @@ function initDashboard() {
if (!scanListenerRegistered) {
scanListenerRegistered = true;
window.addEventListener("bookhoard:scan-complete", async () => {
const librarySelect = document.getElementById(
"library-select",
) as HTMLSelectElement;
const librarySelect = document.getElementById("library-select") as HTMLSelectElement;
const libraryId = librarySelect?.value || "";
try {
const param = libraryId ? `library_id=${libraryId}` : "";
const response = await fetch(
`/api/dashboard/sections?${param}`,
{
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
"Content-Type": "application/json",
},
const response = await fetch(`/api/dashboard/sections?${param}`, {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
"Content-Type": "application/json",
},
);
});
if (!response.ok) return;
const data = await response.json();
const container = document.getElementById(
"collections-container",
) as HTMLElement;
const container = document.getElementById("collections-container") as HTMLElement;
if (!container) return;
const freshSectionIds = new Set(
(data.sections as SectionData[]).map((s) => s.id),
);
const freshSectionIds = new Set((data.sections as SectionData[]).map((s) => s.id));
for (const section of data.sections as SectionData[]) {
const track = document.getElementById(
`carousel-track-${section.id}`,
);
const track = document.getElementById(`carousel-track-${section.id}`);
if (!track) {
container.insertAdjacentHTML(
"beforeend",
renderSectionHTML(section),
);
container.insertAdjacentHTML("beforeend", renderSectionHTML(section));
continue;
}
track.innerHTML = section.items.length > 0
? section.items.map((item) => renderBookCard(item)).join("")
: '<div class="text-center py-8 w-full" style="color: var(--text-secondary);"><p>No items in this collection</p></div>';
track.innerHTML =
section.items.length > 0
? section.items.map((item) => renderBookCard(item)).join("")
: '<div class="text-center py-8 w-full" style="color: var(--text-secondary);"><p>No items in this collection</p></div>';
}
const existingSections = container.querySelectorAll<HTMLElement>(
".dashboard-collection",
);
const existingSections = container.querySelectorAll<HTMLElement>(".dashboard-collection");
existingSections.forEach((sec) => {
const secId = sec.getAttribute("data-collection-id");
if (secId && !freshSectionIds.has(secId)) {
+53 -148
View File
@@ -30,15 +30,9 @@ async function handleAddDevice(event: Event): Promise<void> {
const token = getToken();
if (!token) return;
const deviceNameInput = document.getElementById(
"device-name",
) as HTMLInputElement;
const deviceTypeInput = document.getElementById(
"device-type",
) as HTMLSelectElement;
const deviceIdentifierInput = document.getElementById(
"device-identifier",
) as HTMLInputElement;
const deviceNameInput = document.getElementById("device-name") as HTMLInputElement;
const deviceTypeInput = document.getElementById("device-type") as HTMLSelectElement;
const deviceIdentifierInput = document.getElementById("device-identifier") as HTMLInputElement;
if (!deviceNameInput || !deviceTypeInput || !deviceIdentifierInput) return;
@@ -59,10 +53,7 @@ async function handleAddDevice(event: Event): Promise<void> {
});
if (response.ok) {
showToast(
"Device registered! Check your device for sync instructions.",
"success",
);
showToast("Device registered! Check your device for sync instructions.", "success");
hideAddDeviceModal();
window.location.reload();
} else {
@@ -76,24 +67,12 @@ async function handleAddDevice(event: Event): Promise<void> {
function showDeviceSettings(deviceId: string): void {
const modal = document.getElementById("device-settings-modal");
const deviceIdInput = document.getElementById(
"settings-device-id",
) as HTMLInputElement;
const deviceTypeInput = document.getElementById(
"settings-device-type",
) as HTMLInputElement;
const deviceNameInput = document.getElementById(
"settings-device-name",
) as HTMLInputElement;
const syncEnabledInput = document.getElementById(
"settings-sync-enabled",
) as HTMLInputElement;
const autoSyncInput = document.getElementById(
"settings-auto-sync",
) as HTMLInputElement;
const syncFrequencyInput = document.getElementById(
"settings-sync-frequency",
) as HTMLInputElement;
const deviceIdInput = document.getElementById("settings-device-id") as HTMLInputElement;
const deviceTypeInput = document.getElementById("settings-device-type") as HTMLInputElement;
const deviceNameInput = document.getElementById("settings-device-name") as HTMLInputElement;
const syncEnabledInput = document.getElementById("settings-sync-enabled") as HTMLInputElement;
const autoSyncInput = document.getElementById("settings-auto-sync") as HTMLInputElement;
const syncFrequencyInput = document.getElementById("settings-sync-frequency") as HTMLInputElement;
if (!modal || !deviceIdInput) return;
@@ -111,8 +90,7 @@ function showDeviceSettings(deviceId: string): void {
.then((device) => {
if (deviceTypeInput) deviceTypeInput.value = device.device_type || "";
if (deviceNameInput) deviceNameInput.value = device.device_name || "";
if (syncEnabledInput)
syncEnabledInput.checked = device.sync_enabled || false;
if (syncEnabledInput) syncEnabledInput.checked = device.sync_enabled || false;
if (autoSyncInput) autoSyncInput.checked = device.auto_sync || false;
if (syncFrequencyInput)
syncFrequencyInput.value = String(device.sync_frequency_minutes || 60);
@@ -129,32 +107,22 @@ function showDeviceSettings(deviceId: string): void {
const viewSettings = firstCollection.view_settings || {};
const deviceSettings = viewSettings[deviceType] || {};
const viewModeInput = document.getElementById(
"settings-view-mode",
) as HTMLInputElement;
const sortOrderInput = document.getElementById(
"settings-sort-order",
) as HTMLInputElement;
const viewModeInput = document.getElementById("settings-view-mode") as HTMLInputElement;
const sortOrderInput = document.getElementById("settings-sort-order") as HTMLInputElement;
const itemsPerPageInput = document.getElementById(
"settings-items-per-page",
) as HTMLInputElement;
const showCoversInput = document.getElementById(
"settings-show-covers",
) as HTMLInputElement;
const showCoversInput = document.getElementById("settings-show-covers") as HTMLInputElement;
const showProgressInput = document.getElementById(
"settings-show-progress",
) as HTMLInputElement;
if (viewModeInput)
viewModeInput.value = deviceSettings.view_mode || "grid";
if (sortOrderInput)
sortOrderInput.value = deviceSettings.sort_order || "name";
if (viewModeInput) viewModeInput.value = deviceSettings.view_mode || "grid";
if (sortOrderInput) sortOrderInput.value = deviceSettings.sort_order || "name";
if (itemsPerPageInput)
itemsPerPageInput.value = String(deviceSettings.items_per_page || 24);
if (showCoversInput)
showCoversInput.checked = deviceSettings.show_covers !== false;
if (showProgressInput)
showProgressInput.checked = deviceSettings.show_progress || false;
if (showCoversInput) showCoversInput.checked = deviceSettings.show_covers !== false;
if (showProgressInput) showProgressInput.checked = deviceSettings.show_progress || false;
}
})
.catch((error) => {
@@ -165,9 +133,7 @@ function showDeviceSettings(deviceId: string): void {
}
function showShelfMappings(deviceId: string): void {
const mappingsDeviceIdInput = document.getElementById(
"mappings-device-id",
) as HTMLInputElement;
const mappingsDeviceIdInput = document.getElementById("mappings-device-id") as HTMLInputElement;
const modal = document.getElementById("shelf-mappings-modal");
if (mappingsDeviceIdInput) mappingsDeviceIdInput.value = deviceId;
@@ -225,42 +191,29 @@ async function loadShelfMappings(deviceId: string): Promise<void> {
container.innerHTML =
'<p style="color: var(--text-secondary)">No shelf mappings configured. Click "Add Mapping" to create one.</p>';
}
} catch (error) {
container.innerHTML =
'<p style="color: var(--error)">Failed to load mappings</p>';
} catch {
container.innerHTML = '<p style="color: var(--error)">Failed to load mappings</p>';
}
}
function showAddMappingModal(): void {
const mappingsDeviceIdInput = document.getElementById(
"mappings-device-id",
) as HTMLInputElement;
const mappingIdInput = document.getElementById(
"mapping-id",
) as HTMLInputElement;
const mappingCollectionInput = document.getElementById(
"mapping-collection",
) as HTMLSelectElement;
const mappingShelfNameInput = document.getElementById(
"mapping-shelf-name",
) as HTMLInputElement;
const mappingsDeviceIdInput = document.getElementById("mappings-device-id") as HTMLInputElement;
const mappingIdInput = document.getElementById("mapping-id") as HTMLInputElement;
const mappingCollectionInput = document.getElementById("mapping-collection") as HTMLSelectElement;
const mappingShelfNameInput = document.getElementById("mapping-shelf-name") as HTMLInputElement;
const mappingSyncDirectionInput = document.getElementById(
"mapping-sync-direction",
) as HTMLSelectElement;
const modal = document.getElementById("add-mapping-modal");
if (mappingsDeviceIdInput) {
const mappingDeviceIdInput = document.getElementById(
"mapping-device-id",
) as HTMLInputElement;
if (mappingDeviceIdInput)
mappingDeviceIdInput.value = mappingsDeviceIdInput.value;
const mappingDeviceIdInput = document.getElementById("mapping-device-id") as HTMLInputElement;
if (mappingDeviceIdInput) mappingDeviceIdInput.value = mappingsDeviceIdInput.value;
}
if (mappingIdInput) mappingIdInput.value = "";
if (mappingCollectionInput) mappingCollectionInput.value = "";
if (mappingShelfNameInput) mappingShelfNameInput.value = "";
if (mappingSyncDirectionInput)
mappingSyncDirectionInput.value = "bidirectional";
if (mappingSyncDirectionInput) mappingSyncDirectionInput.value = "bidirectional";
loadCollections();
if (modal) modal.classList.remove("hidden");
@@ -274,9 +227,7 @@ function hideAddMappingModal(): void {
}
async function loadCollections(): Promise<void> {
const select = document.getElementById(
"mapping-collection",
) as HTMLSelectElement;
const select = document.getElementById("mapping-collection") as HTMLSelectElement;
const token = getToken();
if (!select || !token) return;
@@ -304,15 +255,9 @@ function editMapping(
shelfName: string,
syncDirection: string,
): void {
const mappingIdInput = document.getElementById(
"mapping-id",
) as HTMLInputElement;
const mappingCollectionInput = document.getElementById(
"mapping-collection",
) as HTMLSelectElement;
const mappingShelfNameInput = document.getElementById(
"mapping-shelf-name",
) as HTMLInputElement;
const mappingIdInput = document.getElementById("mapping-id") as HTMLInputElement;
const mappingCollectionInput = document.getElementById("mapping-collection") as HTMLSelectElement;
const mappingShelfNameInput = document.getElementById("mapping-shelf-name") as HTMLInputElement;
const mappingSyncDirectionInput = document.getElementById(
"mapping-sync-direction",
) as HTMLSelectElement;
@@ -321,8 +266,7 @@ function editMapping(
if (mappingIdInput) mappingIdInput.value = mappingId;
if (mappingCollectionInput) mappingCollectionInput.value = collectionId;
if (mappingShelfNameInput) mappingShelfNameInput.value = shelfName;
if (mappingSyncDirectionInput)
mappingSyncDirectionInput.value = syncDirection;
if (mappingSyncDirectionInput) mappingSyncDirectionInput.value = syncDirection;
if (modal) modal.classList.remove("hidden");
}
@@ -331,18 +275,10 @@ async function handleSaveMapping(event: Event): Promise<void> {
event.preventDefault();
const token = getToken();
const mappingsDeviceIdInput = document.getElementById(
"mapping-device-id",
) as HTMLInputElement;
const mappingIdInput = document.getElementById(
"mapping-id",
) as HTMLInputElement;
const mappingCollectionInput = document.getElementById(
"mapping-collection",
) as HTMLSelectElement;
const mappingShelfNameInput = document.getElementById(
"mapping-shelf-name",
) as HTMLInputElement;
const mappingsDeviceIdInput = document.getElementById("mapping-device-id") as HTMLInputElement;
const mappingIdInput = document.getElementById("mapping-id") as HTMLInputElement;
const mappingCollectionInput = document.getElementById("mapping-collection") as HTMLSelectElement;
const mappingShelfNameInput = document.getElementById("mapping-shelf-name") as HTMLInputElement;
const mappingSyncDirectionInput = document.getElementById(
"mapping-sync-direction",
) as HTMLSelectElement;
@@ -398,21 +334,16 @@ async function deleteMapping(mappingId: string): Promise<void> {
if (!confirm("Are you sure you want to delete this mapping?")) return;
const token = getToken();
const mappingsDeviceIdInput = document.getElementById(
"mappings-device-id",
) as HTMLInputElement;
const mappingsDeviceIdInput = document.getElementById("mappings-device-id") as HTMLInputElement;
if (!token || !mappingsDeviceIdInput) return;
const deviceId = mappingsDeviceIdInput.value;
try {
const response = await fetch(
`/api/devices/${deviceId}/collections/${mappingId}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
},
);
const response = await fetch(`/api/devices/${deviceId}/collections/${mappingId}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
showToast("Mapping deleted", "success");
@@ -435,9 +366,7 @@ async function handleSaveDeviceSettings(event: Event): Promise<void> {
event.preventDefault();
const token = getToken();
const settingsDeviceIdInput = document.getElementById(
"settings-device-id",
) as HTMLInputElement;
const settingsDeviceIdInput = document.getElementById("settings-device-id") as HTMLInputElement;
const settingsDeviceTypeInput = document.getElementById(
"settings-device-type",
) as HTMLInputElement;
@@ -447,9 +376,7 @@ async function handleSaveDeviceSettings(event: Event): Promise<void> {
const settingsSyncEnabledInput = document.getElementById(
"settings-sync-enabled",
) as HTMLInputElement;
const settingsAutoSyncInput = document.getElementById(
"settings-auto-sync",
) as HTMLInputElement;
const settingsAutoSyncInput = document.getElementById("settings-auto-sync") as HTMLInputElement;
const settingsSyncFrequencyInput = document.getElementById(
"settings-sync-frequency",
) as HTMLInputElement;
@@ -463,27 +390,14 @@ async function handleSaveDeviceSettings(event: Event): Promise<void> {
device_name: settingsDeviceNameInput.value,
sync_enabled: settingsSyncEnabledInput?.checked || false,
auto_sync: settingsAutoSyncInput?.checked || false,
sync_frequency_minutes: parseInt(
settingsSyncFrequencyInput?.value || "60",
10,
),
sync_frequency_minutes: parseInt(settingsSyncFrequencyInput?.value || "60", 10),
};
const viewModeInput = document.getElementById(
"settings-view-mode",
) as HTMLSelectElement;
const sortOrderInput = document.getElementById(
"settings-sort-order",
) as HTMLSelectElement;
const itemsPerPageInput = document.getElementById(
"settings-items-per-page",
) as HTMLInputElement;
const showCoversInput = document.getElementById(
"settings-show-covers",
) as HTMLInputElement;
const showProgressInput = document.getElementById(
"settings-show-progress",
) as HTMLInputElement;
const viewModeInput = document.getElementById("settings-view-mode") as HTMLSelectElement;
const sortOrderInput = document.getElementById("settings-sort-order") as HTMLSelectElement;
const itemsPerPageInput = document.getElementById("settings-items-per-page") as HTMLInputElement;
const showCoversInput = document.getElementById("settings-show-covers") as HTMLInputElement;
const showProgressInput = document.getElementById("settings-show-progress") as HTMLInputElement;
const viewSettings = {
view_mode: viewModeInput?.value || "grid",
@@ -536,10 +450,7 @@ async function handleSaveDeviceSettings(event: Event): Promise<void> {
}
}
async function copyToClipboard(
text: string,
description: string,
): Promise<void> {
async function copyToClipboard(text: string, description: string): Promise<void> {
try {
await navigator.clipboard.writeText(text);
showToast(`${description} copied!`, "success");
@@ -551,16 +462,10 @@ async function copyToClipboard(
async function handleRevokeDevice(): Promise<void> {
const token = getToken();
const settingsDeviceIdInput = document.getElementById(
"settings-device-id",
) as HTMLInputElement;
const settingsDeviceIdInput = document.getElementById("settings-device-id") as HTMLInputElement;
if (!token || !settingsDeviceIdInput) return;
if (
!confirm(
"Are you sure you want to revoke this device? It will no longer be able to sync.",
)
)
if (!confirm("Are you sure you want to revoke this device? It will no longer be able to sync."))
return;
const deviceId = settingsDeviceIdInput.value;
+12 -7
View File
@@ -15,7 +15,7 @@ function toggleSection(el: HTMLElement): void {
const items = el.nextElementSibling as HTMLElement;
const arrow = el.querySelector(".section-arrow") as HTMLElement;
if (!items || !arrow) return;
const isCollapsed = items.getAttribute("data-collapsed") === "true";
if (isCollapsed) {
@@ -30,9 +30,7 @@ function toggleSection(el: HTMLElement): void {
}
function initializeDocsSearch(): void {
const searchInput = document.getElementById(
"docs-search",
) as HTMLInputElement;
const searchInput = document.getElementById("docs-search") as HTMLInputElement;
const searchResults = document.getElementById("docs-search-results");
if (!searchInput || !searchResults) return;
@@ -125,14 +123,15 @@ function initializeCodeCopyButtons(): void {
document.querySelectorAll("pre code").forEach((block) => {
const pre = block.parentElement;
if (!pre) return;
const wrapper = document.createElement("div");
wrapper.className = "relative group";
pre.parentNode.insertBefore(wrapper, pre);
wrapper.appendChild(pre);
const button = document.createElement("button");
button.className = "absolute top-2 right-2 bg-background-secondary text-text-secondary px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity border border-border hover:text-accent";
button.className =
"absolute top-2 right-2 bg-background-secondary text-text-secondary px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity border border-border hover:text-accent";
button.textContent = "Copy";
button.onclick = async () => {
await navigator.clipboard.writeText(block.textContent || "");
@@ -154,4 +153,10 @@ Alpine.data("docs", () => ({
initializeCodeCopyButtons,
}));
export { toggleSidebar, toggleSection, initializeDocsSearch, highlightCurrentPage, initializeCodeCopyButtons };
export {
toggleSidebar,
toggleSection,
initializeDocsSearch,
highlightCurrentPage,
initializeCodeCopyButtons,
};
+7 -29
View File
@@ -50,61 +50,39 @@ function onDelegatedKeydown(
});
}
function getDataAttribute(
element: HTMLElement,
name: string,
): string | undefined {
function getDataAttribute(element: HTMLElement, name: string): string | undefined {
return element.dataset[name];
}
function setDataAttribute(
element: HTMLElement,
name: string,
value: string,
): void {
function setDataAttribute(element: HTMLElement, name: string, value: string): void {
element.dataset[name] = value;
}
function onClick(
element: HTMLElement | null,
handler: (event: MouseEvent) => void,
): void {
function onClick(element: HTMLElement | null, handler: (event: MouseEvent) => void): void {
if (element) {
element.addEventListener("click", handler);
}
}
function onSubmit(
element: HTMLFormElement | null,
handler: (event: Event) => void,
): void {
function onSubmit(element: HTMLFormElement | null, handler: (event: Event) => void): void {
if (element) {
element.addEventListener("submit", handler);
}
}
function onChange(
element: HTMLElement | null,
handler: (event: Event) => void,
): void {
function onChange(element: HTMLElement | null, handler: (event: Event) => void): void {
if (element) {
element.addEventListener("change", handler);
}
}
function onKeydown(
element: HTMLElement | null,
handler: (event: KeyboardEvent) => void,
): void {
function onKeydown(element: HTMLElement | null, handler: (event: KeyboardEvent) => void): void {
if (element) {
element.addEventListener("keydown", handler);
}
}
function onInput(
element: HTMLElement | null,
handler: (event: Event) => void,
): void {
function onInput(element: HTMLElement | null, handler: (event: Event) => void): void {
if (element) {
element.addEventListener("input", handler);
}
+171
View File
@@ -0,0 +1,171 @@
import { describe, expect, it } from "vitest";
import {
isFlatable,
isRule,
pruneIncomplete,
ruleToFlatParam,
type FilterGroupState,
type FilterRuleState,
} from "./filter_builder";
// ruleToFlatParam/isFlatable encode the PINNED deep-link wire contract:
// these params are byte-compatible with bookmarked URLs already in the
// wild, so assertions here are contract pins, not implementation details.
const rule = (field: string, op: string, values: string[] = []): FilterRuleState => ({
field,
op,
values,
});
const group = (
mode: "all" | "any",
children: (FilterRuleState | FilterGroupState)[],
implicit = false,
): FilterGroupState => ({ mode, children, ...(implicit ? { implicit: true } : {}) });
describe("ruleToFlatParam", () => {
it("maps contains on flat fields to the pinned param", () => {
expect(ruleToFlatParam(rule("author", "contains", ["king"]))).toEqual({
author_filter: "king",
});
});
it("joins multiple values with commas", () => {
expect(ruleToFlatParam(rule("tags", "contains", ["a", "b"]))).toEqual({
tags_filter: "a,b",
});
});
it("maps is on enum fields", () => {
expect(ruleToFlatParam(rule("manga_type", "is", ["manga"]))).toEqual({
manga_type_filter: "manga",
});
});
it("maps published_year at_least/at_most to year_min/year_max", () => {
expect(ruleToFlatParam(rule("published_year", "at_least", ["2000"]))).toEqual({
year_min: "2000",
});
expect(ruleToFlatParam(rule("published_year", "at_most", ["2010"]))).toEqual({
year_max: "2010",
});
});
it("maps published_year between to both bounds", () => {
expect(ruleToFlatParam(rule("published_year", "between", ["2000", "2010"]))).toEqual({
year_min: "2000",
year_max: "2010",
});
});
it("returns null for empty values", () => {
expect(ruleToFlatParam(rule("author", "contains", []))).toBeNull();
});
it("returns null for non-flat-expressible combinations", () => {
// contains on a numeric field has no flat param.
expect(ruleToFlatParam(rule("published_year", "contains", ["2000"]))).toBeNull();
// not_contains is deliberately excluded from the flat contract.
expect(ruleToFlatParam(rule("author", "not_contains", ["king"]))).toBeNull();
});
});
describe("isFlatable", () => {
it("accepts a single ALL group of unique flat rules", () => {
const tree = group("all", [
rule("author", "contains", ["king"]),
rule("published_year", "at_least", ["2000"]),
]);
expect(isFlatable(tree)).toBe(true);
});
it("rejects ANY mode", () => {
expect(isFlatable(group("any", [rule("author", "contains", ["king"])]))).toBe(false);
});
it("rejects nested groups", () => {
expect(isFlatable(group("all", [group("all", [rule("author", "contains", ["king"])])]))).toBe(
false,
);
});
it("rejects duplicate fields", () => {
expect(
isFlatable(
group("all", [rule("author", "contains", ["a"]), rule("author", "contains", ["b"])]),
),
).toBe(false);
});
it("rejects non-flat rules", () => {
expect(isFlatable(group("all", [rule("author", "not_contains", ["king"])]))).toBe(false);
});
});
describe("pruneIncomplete", () => {
it("keeps complete rules untouched", () => {
const tree = group("all", [rule("author", "contains", ["king"])]);
expect(pruneIncomplete(tree, null)).toEqual(tree);
});
it("drops rules with empty values", () => {
const tree = group("all", [
rule("author", "contains", [""]),
rule("series", "contains", ["x"]),
]);
expect(pruneIncomplete(tree, null)).toEqual(group("all", [rule("series", "contains", ["x"])]));
});
it("requires both values for two-arity operators", () => {
const tree = group("all", [rule("published_year", "between", ["2000"])]);
expect(pruneIncomplete(tree, null)).toBeNull();
});
it("keeps none-arity rules without values", () => {
const tree = group("all", [rule("cover", "has")]);
expect(pruneIncomplete(tree, null)).toEqual(group("all", [rule("cover", "has", [])]));
});
it("drops groups left empty after pruning", () => {
const tree = group("all", [
group("any", [rule("author", "contains", [""])]),
rule("series", "contains", ["x"]),
]);
expect(pruneIncomplete(tree, null)).toEqual(group("all", [rule("series", "contains", ["x"])]));
});
it("returns null when nothing complete remains", () => {
expect(pruneIncomplete(group("all", [rule("author", "contains", [""])]), null)).toBeNull();
});
it("excludes the rule at the given path", () => {
const tree = group("all", [
rule("author", "contains", ["a"]),
rule("series", "contains", ["b"]),
]);
expect(pruneIncomplete(tree, [0])).toEqual(group("all", [rule("series", "contains", ["b"])]));
});
it("preserves the implicit marker", () => {
const tree = group("all", [rule("author", "contains", ["a"])], true);
expect(pruneIncomplete(tree, null)).toEqual(
group("all", [rule("author", "contains", ["a"])], true),
);
});
it("deep-clones: pruning does not mutate the input tree", () => {
const tree = group("all", [rule("author", "contains", ["a"])]);
const pruned = pruneIncomplete(tree, null);
expect(pruned).not.toBe(tree);
expect(pruned!.children[0]).not.toBe(tree.children[0]);
});
});
describe("isRule", () => {
it("distinguishes rules from groups", () => {
expect(isRule(rule("author", "contains", ["a"]))).toBe(true);
expect(isRule(group("all", []))).toBe(false);
});
});
+159 -59
View File
@@ -23,12 +23,12 @@ export interface OpMeta {
arity: "none" | "one" | "two" | "many";
}
const op = (
key: string,
label: string,
negated: boolean,
arity: OpMeta["arity"],
): OpMeta => ({ key, label, negated, arity });
const op = (key: string, label: string, negated: boolean, arity: OpMeta["arity"]): OpMeta => ({
key,
label,
negated,
arity,
});
// The map key DERIVES from meta.key — the wire value and the display
// label cannot drift apart again (a mis-keyed entry threw every
@@ -132,10 +132,25 @@ export const FIELD_GROUPS: { group: string; fields: FieldMeta[] }[] = [
F("isbn", "ISBN", "Book facts", "text", ["is", "is_not", "has", "missing"], "has"),
F("asin", "ASIN", "Book facts", "text", ["is", "is_not", "has", "missing"], "has"),
F("file_size", "File size", "Book facts", "number", NUM_OPS, "at_least"),
F("file_availability", "File availability", "Book facts", "fixed",
["is", "is_not"], "is", false, ["available", "missing"]),
F(
"file_availability",
"File availability",
"Book facts",
"fixed",
["is", "is_not"],
"is",
false,
["available", "missing"],
),
F("cover", "Cover", "Book facts", "none", ["has", "missing"], "has"),
F("manually_edited", "Manually edited", "Book facts", "none", ["is_true", "is_false"], "is_true"),
F(
"manually_edited",
"Manually edited",
"Book facts",
"none",
["is_true", "is_false"],
"is_true",
),
F("added_by", "Added by", "Book facts", "id", ["is", "has", "missing"], "has", true),
],
},
@@ -148,39 +163,121 @@ export const FIELD_GROUPS: { group: string; fields: FieldMeta[] }[] = [
F("volume", "Volume", "Comics & manga", "number", NUM_OPS, "at_least"),
F("format_group", "Format group", "Comics & manga", "text", ["is", "is_not"], "is", true),
F("mime_type", "File type (mime)", "Comics & manga", "text", ["is", "is_not"], "is", true),
F("is_reflowable", "Reflowable", "Comics & manga", "none", ["is_true", "is_false"], "is_true"),
F("has_fixed_layout", "Fixed layout", "Comics & manga", "none", ["is_true", "is_false"], "is_true"),
F("is_black_and_white", "Black & white", "Comics & manga", "none", ["is_true", "is_false"], "is_true"),
F("manga_type", "Manga type", "Comics & manga", "text", ["is", "is_not", "empty"], "is", true),
F("reading_direction", "Reading direction", "Comics & manga", "text", ["is", "is_not", "empty"], "is", true),
F(
"is_reflowable",
"Reflowable",
"Comics & manga",
"none",
["is_true", "is_false"],
"is_true",
),
F(
"has_fixed_layout",
"Fixed layout",
"Comics & manga",
"none",
["is_true", "is_false"],
"is_true",
),
F(
"is_black_and_white",
"Black & white",
"Comics & manga",
"none",
["is_true", "is_false"],
"is_true",
),
F(
"manga_type",
"Manga type",
"Comics & manga",
"text",
["is", "is_not", "empty"],
"is",
true,
),
F(
"reading_direction",
"Reading direction",
"Comics & manga",
"text",
["is", "is_not", "empty"],
"is",
true,
),
],
},
{
group: "My reading",
fields: [
F("status", "Reading status", "My reading", "fixed", ["is"], "is", false,
["unread", "reading", "finished"]),
F("status", "Reading status", "My reading", "fixed", ["is"], "is", false, [
"unread",
"reading",
"finished",
]),
F("progress_percent", "Progress percent", "My reading", "number", NUM_OPS, "at_least"),
F("last_read", "Last read", "My reading", "days", DATE_OPS, "within_days"),
F("my_rating", "My rating", "My reading", "fixed",
["is", "at_least", "at_most", "unrated"], "at_least", false,
["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]),
F(
"my_rating",
"My rating",
"My reading",
"fixed",
["is", "at_least", "at_most", "unrated"],
"at_least",
false,
["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
),
F("review", "Has review", "My reading", "none", ["has", "missing"], "has"),
F("notes", "Notes", "My reading", "none", ["has", "missing", "within_days"], "has"),
F("highlights", "Highlights", "My reading", "none", ["has", "missing", "within_days"], "has"),
F("note_text", "Note text", "My reading", "text", ["contains", "not_contains", "empty"], "contains", true),
F("highlight_text", "Highlight text", "My reading", "text", ["contains", "not_contains", "empty"], "contains", true),
F("time_spent", "Time spent (minutes)", "My reading", "number", ["between", "at_least", "at_most"], "at_least"),
F("pages_read", "Pages read", "My reading", "number", ["between", "at_least", "at_most"], "at_least"),
F("collection", "In collection", "My reading", "id",
["in", "not_in", "in_any", "in_none"], "in", true),
F(
"note_text",
"Note text",
"My reading",
"text",
["contains", "not_contains", "empty"],
"contains",
true,
),
F(
"highlight_text",
"Highlight text",
"My reading",
"text",
["contains", "not_contains", "empty"],
"contains",
true,
),
F(
"time_spent",
"Time spent (minutes)",
"My reading",
"number",
["between", "at_least", "at_most"],
"at_least",
),
F(
"pages_read",
"Pages read",
"My reading",
"number",
["between", "at_least", "at_most"],
"at_least",
),
F(
"collection",
"In collection",
"My reading",
"id",
["in", "not_in", "in_any", "in_none"],
"in",
true,
),
],
},
{
group: "Library",
fields: [
F("library", "Library", "Library", "id", ["is", "is_not"], "is", true),
],
fields: [F("library", "Library", "Library", "id", ["is", "is_not"], "is", true)],
},
];
@@ -194,10 +291,7 @@ for (const g of FIELD_GROUPS) {
// Grouped ops per field, precomputed in the field's op order.
const OPS_BY_FIELD = new Map<string, OpMeta[]>();
for (const [, f] of FIELD_BY_KEY) {
OPS_BY_FIELD.set(
f.key,
f.ops.map((k) => OPS[k]).filter(Boolean),
);
OPS_BY_FIELD.set(f.key, f.ops.map((k) => OPS[k]).filter(Boolean));
}
// Flat-expressible fields: key → the flat wire param a `contains`/`is`
@@ -237,8 +331,7 @@ export interface FilterGroupState {
implicit?: boolean;
}
export const isRule = (n: FilterRuleState | FilterGroupState): n is FilterRuleState =>
"field" in n;
export const isRule = (n: FilterRuleState | FilterGroupState): n is FilterRuleState => "field" in n;
// flatParamNames are the wire params the flat deep-link contract pins
// (plus the two flat extras the builder owns).
@@ -252,17 +345,19 @@ export const FLAT_WIRE_NAMES = [
];
// opIsFlatExhaustible — every op that maps 1:1 onto a flat param.
function ruleToFlatParam(rule: FilterRuleState): Record<string, string> | null {
export function ruleToFlatParam(rule: FilterRuleState): Record<string, string> | null {
const joined = rule.values.join(",");
if (!joined) return null;
const flat = FLAT_PARAMS[rule.field];
if (flat && rule.op === "contains") return { [flat]: joined };
if (flat && rule.op === "is" &&
["age_rating", "format_group", "manga_type", "reading_direction"].includes(rule.field)) {
if (
flat &&
rule.op === "is" &&
["age_rating", "format_group", "manga_type", "reading_direction"].includes(rule.field)
) {
return { [flat]: joined };
}
if (flat && rule.op === "is" &&
["story_arc", "imprint", "edition"].includes(rule.field)) {
if (flat && rule.op === "is" && ["story_arc", "imprint", "edition"].includes(rule.field)) {
return { [flat]: joined };
}
if (rule.field === "cover") {
@@ -283,7 +378,7 @@ function ruleToFlatParam(rule: FilterRuleState): Record<string, string> | null {
// isFlatable — the smart-serialization rule: a single ALL group of
// rules, one rule per field, every rule flat-expressible.
function isFlatable(tree: FilterGroupState): boolean {
export function isFlatable(tree: FilterGroupState): boolean {
if (tree.mode !== "all") return false;
const seen = new Set<string>();
for (const child of tree.children) {
@@ -300,7 +395,7 @@ function isFlatable(tree: FilterGroupState): boolean {
// groups left empty, and can exclude the rule being edited (suggestion
// symmetry). Returns null when nothing complete remains — callers then
// send NO tree at all (an empty group is a server-side 400).
function pruneIncomplete(
export function pruneIncomplete(
group: FilterGroupState,
excludePath: number[] | null,
path: number[] = [],
@@ -362,8 +457,7 @@ function projectRows(
const childPath = [...path, idx];
const key = childPath.join("-");
// The slot chip: how this child joins the row above it.
const slotChip: "AND" | "OR" =
idx === 0 || group.mode === "all" ? "AND" : "OR";
const slotChip: "AND" | "OR" = idx === 0 || group.mode === "all" ? "AND" : "OR";
if (!isRule(child)) {
const g = child as FilterGroupState;
if (g.implicit) {
@@ -412,10 +506,7 @@ function projectRows(
return;
}
const prev = group.children[idx - 1];
const clickable =
idx > 0 &&
!!prev &&
(isRule(prev) || !!(prev as FilterGroupState).implicit);
const clickable = idx > 0 && !!prev && (isRule(prev) || !!(prev as FilterGroupState).implicit);
out.push({
key,
path: childPath,
@@ -682,8 +773,7 @@ export function filterBuilderData() {
return FIELD_GROUPS.map((g) => ({
group: g.group,
fields: g.fields.filter(
(f) =>
f.label.toLowerCase().includes(q) || f.key.toLowerCase().includes(q),
(f) => f.label.toLowerCase().includes(q) || f.key.toLowerCase().includes(q),
),
})).filter((g) => g.fields.length > 0);
},
@@ -719,9 +809,8 @@ export function filterBuilderData() {
const params = new URLSearchParams();
params.set(rule.field, typed || " ");
if (others) params.set("qf", JSON.stringify(others));
const currentLibraryId = (
document.getElementById("library-select") as HTMLSelectElement
)?.value;
const currentLibraryId = (document.getElementById("library-select") as HTMLSelectElement)
?.value;
if (currentLibraryId) params.set("library_id", currentLibraryId);
// Browse (no typing) shows a slightly wider window than filter-
// as-you-type.
@@ -790,9 +879,8 @@ export function filterBuilderData() {
}
const params = new URLSearchParams();
params.set("qf", JSON.stringify(completed));
const currentLibraryId = (
document.getElementById("library-select") as HTMLSelectElement
)?.value;
const currentLibraryId = (document.getElementById("library-select") as HTMLSelectElement)
?.value;
if (currentLibraryId) params.set("library_id", currentLibraryId);
this.matchCountLoading = true;
try {
@@ -911,13 +999,25 @@ export function filterBuilderData() {
if (cover && cover.value) {
rules.push({ field: "cover", op: cover.value === "true" ? "has" : "missing", values: [] });
}
const explicitIn = form.querySelector('input[name="explicit_filter"]') as HTMLInputElement | null;
const explicitIn = form.querySelector(
'input[name="explicit_filter"]',
) as HTMLInputElement | null;
if (explicitIn && explicitIn.value) {
rules.push({ field: "explicit", op: explicitIn.value === "true" ? "is_true" : "is_false", values: [] });
rules.push({
field: "explicit",
op: explicitIn.value === "true" ? "is_true" : "is_false",
values: [],
});
}
const abridgedIn = form.querySelector('input[name="abridged_filter"]') as HTMLInputElement | null;
const abridgedIn = form.querySelector(
'input[name="abridged_filter"]',
) as HTMLInputElement | null;
if (abridgedIn && abridgedIn.value) {
rules.push({ field: "abridged", op: abridgedIn.value === "true" ? "is_true" : "is_false", values: [] });
rules.push({
field: "abridged",
op: abridgedIn.value === "true" ? "is_true" : "is_false",
values: [],
});
}
const yearMin = form.querySelector('input[name="year_min"]') as HTMLInputElement | null;
if (yearMin && yearMin.value) {
+1 -5
View File
@@ -21,11 +21,7 @@ const restoreLibrarySelection = (): void => {
const savedLibrary = getSelectedLibrary();
if (!savedLibrary) return;
const dropdownSelectors = [
"#library-select",
"#selected-library",
"#library_id",
];
const dropdownSelectors = ["#library-select", "#selected-library", "#library_id"];
for (const selector of dropdownSelectors) {
const dropdown = document.querySelector(selector) as HTMLSelectElement;
+7 -18
View File
@@ -43,11 +43,7 @@ export async function switchWithTransition(
}
try {
container.classList.add(
"opacity-0",
"transition-opacity",
"duration-150",
);
container.classList.add("opacity-0", "transition-opacity", "duration-150");
await new Promise((resolve) => setTimeout(resolve, 150));
loading.classList.remove("hidden");
@@ -68,12 +64,7 @@ export async function switchWithTransition(
container.classList.remove("transition-opacity", "duration-300");
}, 300);
} catch (error) {
container.classList.remove(
"opacity-0",
"transition-opacity",
"duration-150",
"duration-300",
);
container.classList.remove("opacity-0", "transition-opacity", "duration-150", "duration-300");
showToast("Failed to load library", "error");
console.error("Switch library error:", error);
} finally {
@@ -82,22 +73,20 @@ export async function switchWithTransition(
}
export interface LibrarySwitcherOptions {
onSwitch: (libraryId: string) => Promise<void>;
// Async switchers (SSR navigation awaits nothing, API-backed switchers
// await their fetch) — hence void | Promise<void>.
onSwitch: (libraryId: string) => void | Promise<void>;
}
export function initLibrarySwitcher(options: LibrarySwitcherOptions): void {
const librarySelect = document.getElementById(
"library-select",
) as HTMLSelectElement;
const librarySelect = document.getElementById("library-select") as HTMLSelectElement;
if (!librarySelect) return;
const stored = localStorage.getItem("selectedLibrary");
if (stored === ALL_LIBRARIES) {
librarySelect.value = "";
} else if (stored) {
const option = librarySelect.querySelector(
`option[value="${stored}"]`,
);
const option = librarySelect.querySelector(`option[value="${stored}"]`);
if (option) {
librarySelect.value = stored;
}
+2 -8
View File
@@ -73,10 +73,7 @@ function renderUnlinkedBooks(books: UnlinkedBookData[]): void {
.join("");
}
async function linkBook(
progressId: string,
mediaItemId: string,
): Promise<void> {
async function linkBook(progressId: string, mediaItemId: string): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
@@ -151,10 +148,7 @@ async function getSuggestions(progressId: string): Promise<void> {
}
}
function showSuggestionsModal(
progressId: string,
suggestions: PotentialMatchData[],
): void {
function showSuggestionsModal(progressId: string, suggestions: PotentialMatchData[]): void {
const modal = document.getElementById("match-modal");
const content = document.getElementById("match-modal-content");
+118
View File
@@ -0,0 +1,118 @@
import { describe, expect, it } from "vitest";
import { htmlToMarkdown, renderNoteHtml, stripNote } from "./markdown";
// The sanitizer pipeline is the notes feature's XSS defense: markdown-it
// with html disabled, then DOMPurify with a restrictive allow-list. These
// tests pin the security-relevant behavior — loosening any of them should
// require a deliberate decision, never an accident.
describe("renderNoteHtml", () => {
it("renders basic markdown to html", () => {
const out = renderNoteHtml("**bold** and _ital_");
expect(out).toContain("<strong>bold</strong>");
expect(out).toContain("<em>ital</em>");
});
it("returns empty string for empty source", () => {
expect(renderNoteHtml("")).toBe("");
});
it("strips raw script tags (parser-level html disabled)", () => {
const out = renderNoteHtml("<script>alert(1)</script>hello");
expect(out).not.toContain("<script");
expect(out).toContain("hello");
});
it("removes img/input/style/form tags", () => {
const out = renderNoteHtml(
'![x](y.png)\n\n<form action="/steal"></form>\n\n<style>.x{}</style>\n\n<input type="text">',
);
expect(out).not.toContain("<img");
expect(out).not.toContain("<form");
expect(out).not.toContain("<style");
expect(out).not.toContain("<input");
});
it("escapes raw html to inert literal text (html disabled at the parser)", () => {
// html:false makes markdown-it ESCAPE raw html — the output string
// contains the tags as visible text, which is inert wherever the
// compact rows render it.
const out = renderNoteHtml('<span onerror="alert(1)">text</span>');
expect(out).not.toContain("<span");
expect(out).toContain("onerror");
expect(out).toContain("text");
});
it("neutralizes javascript: links at the markdown layer (no anchor emitted)", () => {
// markdown-it's validateLink rejects the URL entirely — the raw syntax
// stays as inert literal text and no <a> element ever reaches the
// sanitizer (which would then scheme-check it a second time).
const out = renderNoteHtml("[click](javascript:alert(1))");
expect(out).not.toContain("<a");
expect(out).toContain("[click](javascript:alert(1))");
});
it("removes markdown-syntax images (FORBID_TAGS)", () => {
const out = renderNoteHtml("![x](y.png)");
expect(out).not.toContain("<img");
});
it("keeps safe https links", () => {
const out = renderNoteHtml("[site](https://example.com)");
expect(out).toContain('href="https://example.com"');
});
it("renders footnotes", () => {
const out = renderNoteHtml("note[^1]\n\n[^1]: the footnote");
expect(out).toContain("footnote-ref");
expect(out).toContain("the footnote");
});
});
describe("stripNote", () => {
it("extracts plain text from markdown", () => {
expect(stripNote("**bold** plain")).toBe("bold plain");
});
it("collapses whitespace and trims", () => {
expect(stripNote("a\n\n b ")).toBe("a b");
});
it("reduces raw html to literal text (safe: html disabled, so tags never become elements)", () => {
// markdown-it escapes the tags and DOMPurify leaves escaped entities
// alone — the result is inert visible text, not executable markup.
expect(stripNote("<script>x</script>safe")).toBe("<script>x</script>safe");
});
it("returns empty string for empty source", () => {
expect(stripNote("")).toBe("");
});
});
describe("htmlToMarkdown", () => {
it("converts emphasis", () => {
expect(htmlToMarkdown("<strong>b</strong>")).toBe("**b**");
});
it("converts atx headings", () => {
expect(htmlToMarkdown("<h2>Title</h2>")).toBe("## Title");
});
it("blanks images entirely", () => {
expect(htmlToMarkdown('<p>a<img src="x.png">b</p>')).toBe("ab");
});
it("converts tables with heading rows to pipe syntax", () => {
const md = htmlToMarkdown(
"<table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>",
);
expect(md).toContain("| A | B |");
expect(md).toContain("| 1 | 2 |");
});
it("converts headingless tables to blank-header pipes (never raw html)", () => {
const md = htmlToMarkdown("<table><tr><td>x</td><td>y</td></tr></table>");
expect(md).toContain("| x | y |");
expect(md).not.toContain("<table");
});
});
+58 -58
View File
@@ -14,35 +14,35 @@ const md = new MarkdownIt({ html: false, linkify: false });
md.use(footnote);
export function renderNoteHtml(source: string): string {
if (!source) return "";
const raw = md.render(source);
// Disallowing img/input/style/form by tag and keeping DOMPurify's default
// safe-URI scheme check (http/https/mailto + same-page anchors only)
// closes the link-injection hole, which is the one markdown pivot into
// script execution.
const clean = DOMPurify.sanitize(raw, {
ALLOWED_ATTR: ["href", "class", "id", "role"],
FORBID_TAGS: ["img", "input", "style", "form"],
});
return clean;
if (!source) return "";
const raw = md.render(source);
// Disallowing img/input/style/form by tag and keeping DOMPurify's default
// safe-URI scheme check (http/https/mailto + same-page anchors only)
// closes the link-injection hole, which is the one markdown pivot into
// script execution.
const clean = DOMPurify.sanitize(raw, {
ALLOWED_ATTR: ["href", "class", "id", "role"],
FORBID_TAGS: ["img", "input", "style", "form"],
});
return clean;
}
// Compact rows may show a plain line instead of rich markup.
export function stripNote(source: string): string {
if (!source) return "";
const holder = document.createElement("div");
holder.innerHTML = renderNoteHtml(source);
return (holder.textContent || "").replace(/\s+/g, " ").trim();
if (!source) return "";
const holder = document.createElement("div");
holder.innerHTML = renderNoteHtml(source);
return (holder.textContent || "").replace(/\s+/g, " ").trim();
}
// ----- rich paste: HTML flavor -> markdown source -----
const turndown = new TurndownService({
// Keep the converted output inside our supported syntax: headings,
// emphasis, lists, code, quotes, links, tables. Everything else degrades.
headingStyle: "atx",
codeBlockStyle: "fenced",
bulletListMarker: "-",
hr: "---",
// Keep the converted output inside our supported syntax: headings,
// emphasis, lists, code, quotes, links, tables. Everything else degrades.
headingStyle: "atx",
codeBlockStyle: "fenced",
bulletListMarker: "-",
hr: "---",
});
turndown.use(gfm);
turndown.use(tables);
@@ -53,50 +53,50 @@ turndown.addRule("noImages", { filter: "img", replacement: () => "" });
// headingless ones as raw HTML — which our no-raw-HTML parser would then
// show as literal tags. Convert those to pipe syntax with a blank header.
turndown.addRule("tablesNoHeading", {
filter: (node: Node) => {
const el = node as HTMLElement;
if (el.nodeName !== "TABLE" || !el.querySelector) return false;
return !el.querySelector("th") && el.querySelectorAll("tr").length > 0;
},
replacement: (_content: string, node: Node) => {
const table = node as HTMLTableElement;
const rows = Array.from(table.querySelectorAll("tr")).map((tr) =>
Array.from(tr.querySelectorAll("td, th")).map((cell) =>
(cell.textContent ?? "").replace(/\s+/g, " ").trim().replace(/\|/g, "\\|"),
),
);
const cols = Math.max(1, ...rows.map((r) => r.length));
const pad = (r: string[]) => {
const copy = [...r];
while (copy.length < cols) copy.push("");
return copy.map((c) => c || " ");
};
const head = pad(rows[0] ?? []);
const lines = [
"| " + head.join(" | ") + " |",
"|" + Array.from({ length: cols }, () => " --- ").join("|") + "|",
...rows.slice(1).map((r) => "| " + pad(r).join(" | ") + " |"),
];
return "\n\n" + lines.join("\n") + "\n\n";
},
filter: (node: Node) => {
const el = node as HTMLElement;
if (el.nodeName !== "TABLE" || !el.querySelector) return false;
return !el.querySelector("th") && el.querySelectorAll("tr").length > 0;
},
replacement: (_content: string, node: Node) => {
const table = node as HTMLTableElement;
const rows = Array.from(table.querySelectorAll("tr")).map((tr) =>
Array.from(tr.querySelectorAll("td, th")).map((cell) =>
(cell.textContent ?? "").replace(/\s+/g, " ").trim().replace(/\|/g, "\\|"),
),
);
const cols = Math.max(1, ...rows.map((r) => r.length));
const pad = (r: string[]) => {
const copy = [...r];
while (copy.length < cols) copy.push("");
return copy.map((c) => c || " ");
};
const head = pad(rows[0] ?? []);
const lines = [
"| " + head.join(" | ") + " |",
"|" + Array.from({ length: cols }, () => " --- ").join("|") + "|",
...rows.slice(1).map((r) => "| " + pad(r).join(" | ") + " |"),
];
return "\n\n" + lines.join("\n") + "\n\n";
},
});
export function htmlToMarkdown(html: string): string {
return turndown.turndown(html);
return turndown.turndown(html);
}
/** Reads the paste event's clipboard and returns markdown converted from the
* rich (text/html) flavor, or null when the clipboard holds no usable rich
* content (plain pastes must fall through to the browser default). */
export function richPasteToMarkdown(event: ClipboardEvent): string | null {
const data = event.clipboardData;
if (!data) return null;
const html = data.getData("text/html");
if (!html || !html.trim()) return null;
// A rich flavor that is just an HTML wrapper of plain text (some apps
// emit <p>...</p> wrappers for everything Chrome copies) still converts
// harmlessly to the same text, so no content sniffing is needed here.
const markdown = htmlToMarkdown(html);
if (!markdown || !markdown.trim()) return null;
return markdown;
const data = event.clipboardData;
if (!data) return null;
const html = data.getData("text/html");
if (!html || !html.trim()) return null;
// A rich flavor that is just an HTML wrapper of plain text (some apps
// emit <p>...</p> wrappers for everything Chrome copies) still converts
// harmlessly to the same text, so no content sniffing is needed here.
const markdown = htmlToMarkdown(html);
if (!markdown || !markdown.trim()) return null;
return markdown;
}
+159
View File
@@ -0,0 +1,159 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { initPasswordValidation, loadPasswordRules } from "./password_validation";
// The checklist is display-only (the server enforces), but it gates the
// submit button and mirrors the ACTIVE server rules — these tests pin the
// rules matrix, the fetch fallback behavior, and the submit gating.
function buildDom(): void {
document.body.innerHTML = `
<input id="password" />
<input id="confirm-password" />
<input id="username" />
<input id="email" />
<div id="password-requirements"></div>
<button id="register-btn">Register</button>
`;
}
function setPassword(value: string): void {
(document.getElementById("password") as HTMLInputElement).value = value;
(document.getElementById("password") as HTMLInputElement).dispatchEvent(new Event("input"));
}
const submitButton = (): HTMLButtonElement =>
document.getElementById("register-btn") as HTMLButtonElement;
describe("loadPasswordRules", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("adopts fetched rules", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(JSON.stringify({ min_length: 12, require_upper: false }), { status: 200 }),
),
);
const rules = await loadPasswordRules();
expect(rules).toEqual({
min_length: 12,
require_upper: false,
require_lower: true,
require_number: true,
require_special: true,
});
});
it("falls back to defaults when the endpoint fails", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("nope", { status: 500 })),
);
const rules = await loadPasswordRules();
expect(rules).toEqual({
min_length: 8,
require_upper: true,
require_lower: true,
require_number: true,
require_special: true,
});
});
it("falls back to defaults on invalid payloads", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(JSON.stringify({ min_length: -5 }), { status: 200 })),
);
const rules = await loadPasswordRules();
expect(rules.min_length).toBe(8);
});
});
describe("initPasswordValidation", () => {
beforeEach(() => {
// Deterministic rules: fetch failure lands on the historical defaults.
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("nope", { status: 500 })),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
// initPasswordValidation debounces through window.setTimeout, which under
// jsdom is a distinct timer from the faked global — so these tests wait
// out the real 100ms debounce instead of advancing fake clocks.
const settleDebounce = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 150));
it("generates one checklist row per active rule plus the match row", async () => {
buildDom();
await loadPasswordRules();
initPasswordValidation({});
const rows = document.querySelectorAll("#password-requirements li");
expect(rows.length).toBe(6); // length, upper, lower, number, special, match
});
it("starts with the submit button disabled for an empty password", async () => {
buildDom();
await loadPasswordRules();
initPasswordValidation({});
expect(submitButton().disabled).toBe(true);
});
it("enables submit when all requirements pass", async () => {
buildDom();
await loadPasswordRules();
initPasswordValidation({});
setPassword("Abcdef1!");
(document.getElementById("confirm-password") as HTMLInputElement).value = "Abcdef1!";
(document.getElementById("confirm-password") as HTMLInputElement).dispatchEvent(
new Event("input"),
);
(document.getElementById("username") as HTMLInputElement).value = "user";
(document.getElementById("username") as HTMLInputElement).dispatchEvent(new Event("input"));
(document.getElementById("email") as HTMLInputElement).value = "user@example.com";
(document.getElementById("email") as HTMLInputElement).dispatchEvent(new Event("input"));
await settleDebounce();
expect(submitButton().disabled).toBe(false);
});
it("keeps submit disabled on a confirmation mismatch", async () => {
buildDom();
await loadPasswordRules();
initPasswordValidation({});
setPassword("Abcdef1!");
(document.getElementById("confirm-password") as HTMLInputElement).value = "Different1!";
(document.getElementById("confirm-password") as HTMLInputElement).dispatchEvent(
new Event("input"),
);
await settleDebounce();
expect(submitButton().disabled).toBe(true);
});
it("does not require username/email when requireUsernameEmail is false", async () => {
buildDom();
await loadPasswordRules();
initPasswordValidation({ requireUsernameEmail: false });
setPassword("Abcdef1!");
(document.getElementById("confirm-password") as HTMLInputElement).value = "Abcdef1!";
(document.getElementById("confirm-password") as HTMLInputElement).dispatchEvent(
new Event("input"),
);
await settleDebounce();
expect(submitButton().disabled).toBe(false);
});
});
+2 -2
View File
@@ -63,7 +63,7 @@ function hasNumber(password: string): boolean {
return /[0-9]/.test(password);
}
function hasSpecialChar(password: string): boolean {
return /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password);
return /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>?]/.test(password);
}
function passwordsMatch(password: string, confirm: string): boolean {
return password.length > 0 && password === confirm;
@@ -107,7 +107,7 @@ function requirementRow(id: string, label: string): string {
function ensureChecklist(containerId: string, rules: PasswordRules, hasConfirm: boolean): void {
const container = document.getElementById(containerId);
if (!container) return;
let list = container.querySelector("ul");
let list: HTMLElement | null = container.querySelector("ul");
if (container.tagName === "UL") list = container; // container IS the list
if (!list) {
container.insertAdjacentHTML(
+3 -8
View File
@@ -60,17 +60,14 @@ export function buildPageText(
const t = item.transform ?? [1, 0, 0, 1, 0, 0];
const baseline = t[5] ?? 0;
const x = t[4] ?? 0;
const size =
Math.abs(item.height) || Math.abs(t[3]) || Math.abs(t[0]) || 10;
const size = Math.abs(item.height) || Math.abs(t[3]) || Math.abs(t[0]) || 10;
const w = Math.abs(item.width) || 0;
const h = size;
let sep = "";
if (text && !text.endsWith(" ") && prevRight != null) {
const newLine =
item.hasEOL ||
prevBaseline == null ||
Math.abs(baseline - prevBaseline) > size * 0.5;
item.hasEOL || prevBaseline == null || Math.abs(baseline - prevBaseline) > size * 0.5;
const gap = x - prevRight;
if (newLine || gap > size * 0.2) sep = " ";
}
@@ -106,9 +103,7 @@ export async function extractPdfPages(
const page = await pdf.getPage(i + 1);
const viewport = page.getViewport({ scale: 1 });
const tc = await page.getTextContent();
pages.push(
buildPageText(tc.items, viewport.width, viewport.height, i),
);
pages.push(buildPageText(tc.items, viewport.width, viewport.height, i));
onProgress?.((i + 1) / num);
}
return pages;
+416 -642
View File
File diff suppressed because it is too large. Load diff
+3 -5
View File
@@ -16,14 +16,12 @@ export async function loadSettings(): Promise<ReaderSettings> {
const settings = await response.json();
localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(settings));
return settings;
} catch (error) {
} catch {
const local = localStorage.getItem(LOCALSTORAGE_KEY);
return local ? JSON.parse(local) : getDefaultSettings();
}
}
export async function saveSettings(
settings: Partial<ReaderSettings>,
): Promise<void> {
export async function saveSettings(settings: Partial<ReaderSettings>): Promise<void> {
const token = getToken();
const current = await loadSettings();
const updated = { ...current, ...settings };
@@ -41,7 +39,7 @@ export async function saveSettings(
body: JSON.stringify(settings),
});
localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
} catch (error) {
} catch {
localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
}
}
+18 -30
View File
@@ -5,9 +5,7 @@ const SEARCH_DEBOUNCE_MS = 300;
const SEARCH_MIN_CHARS = 2;
function initializeSearch(): void {
const searchInput = document.getElementById(
"header-search",
) as HTMLInputElement | null;
const searchInput = document.getElementById("header-search") as HTMLInputElement | null;
const mobileSearchInput = document.getElementById(
"header-search-mobile",
) as HTMLInputElement | null;
@@ -107,11 +105,9 @@ function selectSearchResult(items: NodeListOf<Element>, index: number): void {
}
function getActiveSearchInput(): HTMLInputElement | null {
return (
document.activeElement?.id === "header-search-mobile"
? (document.getElementById("header-search-mobile") as HTMLInputElement)
: (document.getElementById("header-search") as HTMLInputElement)
);
return document.activeElement?.id === "header-search-mobile"
? (document.getElementById("header-search-mobile") as HTMLInputElement)
: (document.getElementById("header-search") as HTMLInputElement);
}
function performSearch(query: string): void {
@@ -138,27 +134,21 @@ function performSearch(query: string): void {
}
return response.json();
})
.then(
(
data:
| { error?: string; results?: MediaItemSummary[] }
| MediaItemSummary[],
) => {
hideSearchLoading();
.then((data: { error?: string; results?: MediaItemSummary[] } | MediaItemSummary[]) => {
hideSearchLoading();
const resultActiveId = getActiveSearchInput()?.id;
const resultActiveId = getActiveSearchInput()?.id;
if (data && "error" in data && data.error === "no results found") {
showNoResults(query, resultActiveId);
} else if (Array.isArray(data) && data.length > 0) {
showSearchResults(data, query, resultActiveId);
} else if (Array.isArray(data)) {
showNoResults(query, resultActiveId);
} else {
showNoResults(query, resultActiveId);
}
},
)
if (data && "error" in data && data.error === "no results found") {
showNoResults(query, resultActiveId);
} else if (Array.isArray(data) && data.length > 0) {
showSearchResults(data, query, resultActiveId);
} else if (Array.isArray(data)) {
showNoResults(query, resultActiveId);
} else {
showNoResults(query, resultActiveId);
}
})
.catch((error) => {
hideSearchLoading();
console.error("Search error:", error);
@@ -292,9 +282,7 @@ function createSearchResultsContainer(activeId?: string): void {
searchResults.style.cssText =
"background-color: var(--bg-secondary); border-color: var(--border)";
const searchInput = document.getElementById(
activeId || "header-search",
);
const searchInput = document.getElementById(activeId || "header-search");
if (searchInput) {
const searchContainer = searchInput.closest(".relative");
if (searchContainer) {
+12 -7
View File
@@ -36,7 +36,8 @@ function initSetup(): void {
if (savedTheme && savedTheme !== "tokyo-night") {
applyTheme(savedTheme);
}
}Alpine.data("setupWizard", () => ({
}
Alpine.data("setupWizard", () => ({
currentStep: 1 as number,
loading: false as boolean,
errorMsg: "" as string,
@@ -110,7 +111,9 @@ function initSetup(): void {
}
if (
(errMsg.includes("already exists") || errMsg.includes("email already") || errMsg.includes("username already")) &&
(errMsg.includes("already exists") ||
errMsg.includes("email already") ||
errMsg.includes("username already")) &&
getToken()
) {
this.currentStep = 2;
@@ -140,11 +143,12 @@ function initSetup(): void {
async saveBaseUrl() {
if (!this.admin.baseUrl) return;
try {
await handleVoidResponse(
await apiPut("/system/config", { base_url: this.admin.baseUrl })
);
await handleVoidResponse(await apiPut("/system/config", { base_url: this.admin.baseUrl }));
} catch {
showToast("Warning: could not save server URL. You can set it later in Admin Settings.", "warning");
showToast(
"Warning: could not save server URL. You can set it later in Admin Settings.",
"warning",
);
}
},
@@ -299,7 +303,8 @@ function initSetup(): void {
`;
if (data.directories.length === 0) {
html += '<p style="color: var(--text-secondary)" class="text-center py-4">No subdirectories</p>';
html +=
'<p style="color: var(--text-secondary)" class="text-center py-4">No subdirectories</p>';
} else {
data.directories.forEach((dir) => {
const fullPath = data.current_path === "/" ? `/${dir}` : `${data.current_path}/${dir}`;
+9 -8
View File
@@ -3,6 +3,8 @@ declare module "turndown-plugin-gfm";
// htmx is loaded as a classic script on every page and used from TS for
// partial swaps (rendering-ownership rule: templ renders, JS swaps).
// Members mirror the htmx 2.x public API that bookhoard actually calls;
// `trigger`'s element accepts a selector string, per the htmx docs.
interface HtmxAjaxOptions {
target?: string;
swap?: string;
@@ -10,13 +12,12 @@ interface HtmxAjaxOptions {
headers?: Record<string, string>;
}
interface Htmx {
ajax(method: string, url: string, options?: HtmxAjaxOptions): Promise<boolean>;
process(element: Element): void;
trigger(element: Element | string, eventName: string, detail?: Record<string, unknown>): void;
}
interface Window {
htmx: {
ajax(
method: string,
url: string,
options?: HtmxAjaxOptions,
): Promise<boolean>;
process(element: Element): void;
};
htmx: Htmx;
}
+70 -34
View File
@@ -137,9 +137,13 @@ let libraryDataLoaded = false;
// Page-scoped helpers -----------------------------------------------------
async function chartCtor(): Promise<new (el: HTMLCanvasElement, cfg: ChartConfiguration) => ChartLike> {
async function chartCtor(): Promise<
new (el: HTMLCanvasElement, cfg: ChartConfiguration) => ChartLike
> {
const mod = await import("chart.js/auto");
return (mod as unknown as { default: new (el: HTMLCanvasElement, cfg: ChartConfiguration) => ChartLike }).default;
return (
mod as unknown as { default: new (el: HTMLCanvasElement, cfg: ChartConfiguration) => ChartLike }
).default;
}
function text(id: string, value: string): void {
@@ -147,11 +151,6 @@ function text(id: string, value: string): void {
if (el) el.textContent = value;
}
function getActiveRange(): string {
const btn = document.querySelector(".stats-range-btn.btn-active") as HTMLElement | null;
return btn?.dataset.range || "7";
}
function markRangeButtons(active: string): void {
document.querySelectorAll<HTMLElement>(".stats-range-btn").forEach((btn) => {
const isActive = btn.dataset.range === active;
@@ -302,10 +301,14 @@ function renderSummary(summary: Summary): void {
const hasData = summary.total_minutes > 0 || summary.session_count > 0;
document.getElementById("stats-empty")?.classList.toggle("hidden", hasData);
const windowLabel = document.getElementById("stats-window-label");
if (windowLabel) windowLabel.textContent = `${summary.start_date} → ${summary.end_date} (${summary.days} days)`;
if (windowLabel)
windowLabel.textContent = `${summary.start_date} → ${summary.end_date} (${summary.days} days)`;
const mins = summary.total_minutes;
text("stats-total-minutes", mins >= 60 ? `${Math.floor(mins / 60)} h ${mins % 60} min` : `${mins} min`);
text(
"stats-total-minutes",
mins >= 60 ? `${Math.floor(mins / 60)} h ${mins % 60} min` : `${mins} min`,
);
renderPagesCard(summary);
text(
"stats-streaks",
@@ -315,7 +318,9 @@ function renderSummary(summary: Summary): void {
);
text(
"stats-books-finished",
summary.books_with_activity > 0 ? `${summary.books_finished} of ${summary.books_with_activity}` : "–",
summary.books_with_activity > 0
? `${summary.books_finished} of ${summary.books_with_activity}`
: "–",
);
const mostActive = document.getElementById("stats-most-active");
@@ -349,7 +354,9 @@ async function renderDailyCharts(daily: DailyEntry[]): Promise<void> {
tooltip: {
callbacks: {
label: (ctx) => {
const v = typeof ctx.parsed === "number" ? ctx.parsed : ctx.parsed.y;
// Scalar for bar charts, {x, y} points for line charts.
const parsed: number | { y: number } = ctx.parsed;
const v = typeof parsed === "number" ? parsed : parsed.y;
return `${Math.round(v * 10) / 10} ${unit}`;
},
},
@@ -357,7 +364,11 @@ async function renderDailyCharts(daily: DailyEntry[]): Promise<void> {
},
scales: {
x: { grid: { display: false }, ticks: { color: secondary, maxTicksLimit: 10 } },
y: { grid: { color: "rgba(128,128,128,.15)" }, ticks: { color: secondary }, beginAtZero: true },
y: {
grid: { color: "rgba(128,128,128,.15)" },
ticks: { color: secondary },
beginAtZero: true,
},
},
});
@@ -368,7 +379,9 @@ async function renderDailyCharts(daily: DailyEntry[]): Promise<void> {
pagesChart = null;
listeningChart = null;
const minutesCanvas = document.getElementById("stats-daily-minutes-chart") as HTMLCanvasElement | null;
const minutesCanvas = document.getElementById(
"stats-daily-minutes-chart",
) as HTMLCanvasElement | null;
if (minutesCanvas) {
minutesChart = new ChartCtor(minutesCanvas, {
type: "bar",
@@ -380,7 +393,9 @@ async function renderDailyCharts(daily: DailyEntry[]): Promise<void> {
});
}
const pagesCanvas = document.getElementById("stats-daily-pages-chart") as HTMLCanvasElement | null;
const pagesCanvas = document.getElementById(
"stats-daily-pages-chart",
) as HTMLCanvasElement | null;
if (pagesCanvas) {
pagesChart = new ChartCtor(pagesCanvas, {
type: "line",
@@ -404,7 +419,9 @@ async function renderDailyCharts(daily: DailyEntry[]): Promise<void> {
// Listening per Day (hrs) — the audio half of the side-by-side pages
// split; its panel is hidden by renderPagesCard unless the range has
// audio minutes.
const listeningCanvas = document.getElementById("stats-daily-listening-chart") as HTMLCanvasElement | null;
const listeningCanvas = document.getElementById(
"stats-daily-listening-chart",
) as HTMLCanvasElement | null;
if (listeningCanvas) {
listeningChart = new ChartCtor(listeningCanvas, {
type: "bar",
@@ -426,26 +443,32 @@ async function renderDailyCharts(daily: DailyEntry[]): Promise<void> {
function renderLibraries(rows: LibraryRow[]): void {
const container = document.getElementById("stats-library-breakdown");
if (!container) return;
renderBars(container, rows.map((r) => ({
label: r.name,
sub: `${fmtMinutes(r.minutes)} · ${r.sessions} sessions`,
value: r.minutes,
})));
renderBars(
container,
rows.map((r) => ({
label: r.name,
sub: `${fmtMinutes(r.minutes)} · ${r.sessions} sessions`,
value: r.minutes,
})),
);
}
function renderDevices(rows: DeviceRow[]): void {
const container = document.getElementById("stats-device-breakdown");
if (!container) return;
renderBars(container, rows.map((r) => ({
label: r.name || "Web",
sub: `${r.books} ${r.books === 1 ? "book" : "books"} · ${r.sessions} sessions`,
value: r.minutes,
})));
renderBars(
container,
rows.map((r) => ({
label: r.name || "Web",
sub: `${r.books} ${r.books === 1 ? "book" : "books"} · ${r.sessions} sessions`,
value: r.minutes,
})),
);
}
function fmtMinutes(minutes: number): string {
return minutes >= 60
? `${Math.floor(minutes / 60)}h${minutes % 60 ? ` ${(minutes % 60)}m` : ""}`
? `${Math.floor(minutes / 60)}h${minutes % 60 ? ` ${minutes % 60}m` : ""}`
: `${minutes} min`;
}
@@ -522,10 +545,9 @@ async function loadLibraries(): Promise<VisibleLibrary[]> {
function fillLibrarySelector(rows: VisibleLibrary[]): void {
const select = document.getElementById("stats-library-select") as HTMLSelectElement | null;
if (!select) return;
select.innerHTML = `<option value="">All libraries</option>` +
rows
.map((r) => `<option value="${escapeHtml(r.id)}">${escapeHtml(r.name)}</option>`)
.join("");
select.innerHTML =
`<option value="">All libraries</option>` +
rows.map((r) => `<option value="${escapeHtml(r.id)}">${escapeHtml(r.name)}</option>`).join("");
// Honor a URL-seeded selection initStatsPage may have set.
if (currentLibraryID) select.value = currentLibraryID;
}
@@ -545,9 +567,15 @@ async function loadLibraryUsage(): Promise<void> {
"lib-stat-narrators",
];
targets.forEach((id) => text(id, "–"));
for (const id of ["stats-lib-genres", "stats-lib-authors", "stats-lib-longest", "stats-lib-largest"]) {
for (const id of [
"stats-lib-genres",
"stats-lib-authors",
"stats-lib-longest",
"stats-lib-largest",
]) {
const el = document.getElementById(id);
if (el) el.innerHTML = `<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div>`;
if (el)
el.innerHTML = `<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div>`;
}
try {
@@ -598,7 +626,10 @@ function renderLibraryUsage(payload: LibraryUsage): void {
}
const gb = payload.total_size_bytes / 1e9;
text("lib-stat-size", gb >= 0.1 ? gb.toFixed(1) : (payload.total_size_bytes / 1e6).toFixed(1) + " MB");
text(
"lib-stat-size",
gb >= 0.1 ? gb.toFixed(1) : (payload.total_size_bytes / 1e6).toFixed(1) + " MB",
);
const itemTotal = Math.max(payload.items, 1);
renderBars(
@@ -619,7 +650,12 @@ function renderLibraryUsage(payload: LibraryUsage): void {
})),
"No authors recorded in this pool yet.",
);
renderUsageItems("stats-lib-longest", "stats-lib-longest-label", payload.longest_items, payload.hours_mode);
renderUsageItems(
"stats-lib-longest",
"stats-lib-longest-label",
payload.longest_items,
payload.hours_mode,
);
renderUsageItems("stats-lib-largest", null, payload.largest_items, payload.hours_mode, true);
}
+74
View File
@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it } from "vitest";
import {
clearAll,
getRefreshToken,
getSelectedLibrary,
getTheme,
getToken,
removeRefreshToken,
removeToken,
setRefreshToken,
setSelectedLibrary,
setTheme,
setToken,
} from "./storage";
afterEach(() => {
clearAll();
});
describe("token storage", () => {
it("round-trips and removes the access token", () => {
expect(getToken()).toBeNull();
setToken("tok-1");
expect(getToken()).toBe("tok-1");
removeToken();
expect(getToken()).toBeNull();
});
it("round-trips and removes the refresh token", () => {
setRefreshToken("r-1");
expect(getRefreshToken()).toBe("r-1");
removeRefreshToken();
expect(getRefreshToken()).toBeNull();
});
});
describe("theme storage", () => {
it("defaults to tokyo-night when unset", () => {
expect(getTheme()).toBe("tokyo-night");
});
it("round-trips a chosen theme", () => {
setTheme("dracula");
expect(getTheme()).toBe("dracula");
});
});
describe("library selection", () => {
it("round-trips a concrete library id", () => {
setSelectedLibrary("abc-123");
expect(getSelectedLibrary()).toBe("abc-123");
});
it("stores the __all__ sentinel for an empty selection and reads it back as empty", () => {
setSelectedLibrary("");
expect(getSelectedLibrary()).toBe("");
});
it("writes the selection cookie for SSR", () => {
setSelectedLibrary("abc-123");
expect(document.cookie).toContain("selectedLibrary=abc-123");
});
});
describe("clearAll", () => {
it("wipes everything", () => {
setToken("t");
setTheme("dracula");
clearAll();
expect(getToken()).toBeNull();
expect(getTheme()).toBe("tokyo-night");
});
});
+1 -1
View File
@@ -61,5 +61,5 @@ export {
setRefreshToken,
setSelectedLibrary,
setTheme,
setToken
setToken,
};
+6 -19
View File
@@ -68,9 +68,7 @@ const applyTheme = (theme: string): void => {
// Load theme from localStorage or use default
const loadTheme = (): void => {
const storedTheme = localStorage.getItem(
THEME_STORAGE_KEY,
) as ThemeType | null;
const storedTheme = localStorage.getItem(THEME_STORAGE_KEY) as ThemeType | null;
const theme = storedTheme || DEFAULT_THEME;
applyTheme(theme);
};
@@ -145,9 +143,7 @@ const updateThemeIndicators = (): void => {
const revealCurrentTheme = (mode: string): void => {
const match = document.body.className.match(/theme-([\w-]+)/);
const current = match ? match[1] : DEFAULT_THEME;
const btn = document.querySelector<HTMLElement>(
`.theme-btn[data-theme="${current}"]`,
);
const btn = document.querySelector<HTMLElement>(`.theme-btn[data-theme="${current}"]`);
if (btn && btn.dataset.mode === mode) {
btn.scrollIntoView({ block: "nearest" });
}
@@ -160,12 +156,9 @@ const initializeTheme = (): void => {
updateThemeIndicators();
// Set theme select value to current theme
const themeSelect = document.getElementById(
"theme-select",
) as HTMLSelectElement;
const themeSelect = document.getElementById("theme-select") as HTMLSelectElement;
if (themeSelect) {
const currentTheme =
localStorage.getItem(THEME_STORAGE_KEY) || DEFAULT_THEME;
const currentTheme = localStorage.getItem(THEME_STORAGE_KEY) || DEFAULT_THEME;
themeSelect.value = currentTheme;
}
@@ -205,11 +198,7 @@ const applyWoodPaneling = (paneling: WoodPanelingType): void => {
if (!container || !body) return;
// Remove all wood background classes from container
container.classList.remove(
"bg-wood-light",
"bg-wood-dark",
"bg-wood-mahogany",
);
container.classList.remove("bg-wood-light", "bg-wood-dark", "bg-wood-mahogany");
container.removeAttribute("data-wood");
// Remove from body
@@ -231,9 +220,7 @@ const applyWoodPaneling = (paneling: WoodPanelingType): void => {
// Load wood paneling from localStorage on page load
const loadWoodPaneling = (): void => {
const stored = localStorage.getItem(
WOOD_STORAGE_KEY,
) as WoodPanelingType | null;
const stored = localStorage.getItem(WOOD_STORAGE_KEY) as WoodPanelingType | null;
if (stored) {
applyWoodPaneling(stored);
} else {
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { getToastConfig, showToast, type ToastType } from "./toast";
// getToastConfig drives every toast's styling; a missing entry for a
// ToastType member crashes showToast at runtime (that bug shipped once —
// the 'warning' variant — which is why this suite exists).
const ALL_TYPES: ToastType[] = ["error", "success", "info", "warning"];
describe("getToastConfig", () => {
it("has a complete config for every ToastType", () => {
for (const type of ALL_TYPES) {
const config = getToastConfig(type);
expect(config, `missing config for "${type}"`).toBeDefined();
expect(config.bgClass, `missing bgClass for "${type}"`).toBeTruthy();
expect(config.icon, `missing icon for "${type}"`).toBeTruthy();
}
});
it("styles the warning variant with the /90 opacity used by siblings", () => {
expect(getToastConfig("warning").bgClass).toBe("bg-yellow-500/90");
});
it("keeps distinct visuals per type", () => {
const classes = ALL_TYPES.map((type) => getToastConfig(type).bgClass);
expect(new Set(classes).size).toBe(ALL_TYPES.length);
});
});
describe("showToast", () => {
it("creates the container and a styled toast", () => {
showToast("Saved", "success");
const container = document.getElementById("toast-container");
expect(container).not.toBeNull();
expect(container?.innerHTML).toContain("Saved");
expect(container?.innerHTML).toContain("bg-green-500/90");
});
it("renders the warning variant without throwing", () => {
// Regression: 'warning' had no config entry and crashed on render.
expect(() => showToast("Careful", "warning")).not.toThrow();
expect(document.getElementById("toast-container")?.innerHTML).toContain("bg-yellow-500/90");
});
});
+16 -18
View File
@@ -1,7 +1,7 @@
// Toast notification system for backend errors
// Displays toast notifications at the top of the page
type ToastType = "error" | "success" | "info";
type ToastType = "error" | "success" | "info" | "warning";
const TOAST_DEFAULT_DURATION = 5000;
@@ -11,8 +11,7 @@ const createToastContainer = (): HTMLElement => {
if (!container) {
container = document.createElement("div");
container.id = "toast-container";
container.className =
"fixed top-5 right-5 z-[9999] flex flex-col gap-2.5 pointer-events-none";
container.className = "fixed top-5 right-5 z-[9999] flex flex-col gap-2.5 pointer-events-none";
document.body.appendChild(container);
}
return container;
@@ -40,6 +39,10 @@ const getToastConfig = (type: ToastType) => {
bgClass: "bg-blue-500/90",
icon: "ℹ️",
},
warning: {
bgClass: "bg-yellow-500/90",
icon: "⚠️",
},
};
return configs[type];
};
@@ -110,7 +113,7 @@ const parseXHRError = (xhr: XMLHttpRequest): string => {
try {
const response = JSON.parse(xhr.responseText);
errorMessage = response.error || response.message || errorMessage;
} catch (e) {
} catch {
errorMessage = xhr.responseText || errorMessage;
}
return errorMessage;
@@ -141,11 +144,7 @@ const setupHTMXListeners = (): void => {
if (customEvent.detail.xhr) {
const xhr = customEvent.detail.xhr;
if (
customEvent.detail.succeeded === false &&
xhr.status >= 400 &&
xhr.status < 600
) {
if (customEvent.detail.succeeded === false && xhr.status >= 400 && xhr.status < 600) {
const errorMessage = parseXHRError(xhr);
showToast(errorMessage, "error");
} else if (xhr.status >= 200 && xhr.status < 300) {
@@ -159,7 +158,7 @@ const setupHTMXListeners = (): void => {
customEvent.detail.target.innerHTML = "";
}
}
} catch (e) {
} catch {
// Not valid JSON — ignore
}
}
@@ -188,7 +187,10 @@ function isSuggestionFetch(input: Parameters<typeof fetch>[0]): boolean {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
// The builder's live-count endpoint is a background hint too — a
// failed count must never interrupt with a toast.
if (!url.includes("/api/media-items/search?") && !url.includes("/api/media-items/filter-count?")) {
if (
!url.includes("/api/media-items/search?") &&
!url.includes("/api/media-items/filter-count?")
) {
return false;
}
if (url.includes("/api/media-items/filter-count?")) return true;
@@ -198,9 +200,7 @@ function isSuggestionFetch(input: Parameters<typeof fetch>[0]): boolean {
// Setup fetch interceptor
const setupFetchInterceptor = (): void => {
const originalFetch = window.fetch;
window.fetch = async (
...args: Parameters<typeof fetch>
): Promise<Response> => {
window.fetch = async (...args: Parameters<typeof fetch>): Promise<Response> => {
try {
const response = await originalFetch(...args);
@@ -233,9 +233,7 @@ const setupFetchInterceptor = (): void => {
if (!response.ok) {
const errorMessage = await parseFetchError(response);
if (isSuggestionFetch(args[0])) {
console.warn(
`Suggestion fetch failed (${response.status}): ${errorMessage}`,
);
console.warn(`Suggestion fetch failed (${response.status}): ${errorMessage}`);
} else {
showToast(errorMessage, "error");
}
@@ -264,5 +262,5 @@ const initializeToastSystem = (): void => {
initializeToastSystem();
export { showToast };
export { showToast, getToastConfig };
export type { ToastType };
+8 -11
View File
@@ -59,8 +59,8 @@ interface MediaItemSummary {
library_type_name: string;
// NEW: Reading direction fields for manga/comics
manga_type?: 'unknown' | 'no' | 'yes' | 'yes_and_right_to_left';
reading_direction?: 'auto' | 'ltr' | 'rtl' | 'vertical';
manga_type?: "unknown" | "no" | "yes" | "yes_and_right_to_left";
reading_direction?: "auto" | "ltr" | "rtl" | "vertical";
// NEW: Universal metadata fields (apply to ebooks, audiobooks, comics)
series_count?: number;
@@ -74,7 +74,11 @@ interface MediaItemSummary {
// NEW: Comic-specific fields
story_arc?: string;
is_black_and_white?: boolean;
alternate_info?: { alternate_series?: string; alternate_number?: number; alternate_count?: number };
alternate_info?: {
alternate_series?: string;
alternate_number?: number;
alternate_count?: number;
};
scan_information?: string;
summary?: string;
}
@@ -162,14 +166,7 @@ interface PotentialMatchData {
// Used in: collection_rules.ts
interface CollectionRule {
id: string;
field:
| "genre"
| "series"
| "author"
| "language"
| "publisher"
| "copyright_year"
| "tags";
field: "genre" | "series" | "author" | "language" | "publisher" | "copyright_year" | "tags";
operator:
| "equals"
| "not_equals"
+1 -9
View File
@@ -51,15 +51,7 @@ interface TOCNode {
// Parser Types (Procedural, not OOP)
// ============================================================
type ParserFormat =
| "epub"
| "fb2"
| "txt"
| "html"
| "mobi"
| "azw3"
| "docx"
| "rtf";
type ParserFormat = "epub" | "fb2" | "txt" | "html" | "mobi" | "azw3" | "docx" | "rtf";
interface ParserCapabilities {
canParse(mimeType: string, extension: string): boolean;
+24 -78
View File
@@ -4,11 +4,7 @@ import { getToken } from "./storage";
let selectedMediaItem: string | null = null;
function searchMatches(
progressId: string,
sha256: string,
title: string,
): void {
function searchMatches(progressId: string, sha256: string, title: string): void {
const container = document.getElementById(`matches-${progressId}`);
const matchesList = document.getElementById(`matches-list-${progressId}`);
@@ -38,18 +34,8 @@ function searchMatches(
});
}
function autoLinkBook(
progressId: string,
mediaItemId: string,
confidence: number,
): void {
if (
!confirm(
"Link this book? The confidence score is " +
Math.round(confidence * 100) +
"%",
)
) {
function autoLinkBook(progressId: string, mediaItemId: string, confidence: number): void {
if (!confirm("Link this book? The confidence score is " + Math.round(confidence * 100) + "%")) {
return;
}
@@ -88,12 +74,8 @@ function autoLinkBook(
function showManualLinkModal(progressId: string, bookTitle: string): void {
const modal = document.getElementById("manual-link-modal");
const progressIdInput = document.getElementById(
"link-progress-id",
) as HTMLInputElement;
const bookTitleInput = document.getElementById(
"link-book-title",
) as HTMLInputElement;
const progressIdInput = document.getElementById("link-progress-id") as HTMLInputElement;
const bookTitleInput = document.getElementById("link-book-title") as HTMLInputElement;
const searchResults = document.getElementById("link-search-results");
if (modal) modal.classList.remove("hidden");
@@ -107,9 +89,7 @@ function showManualLinkModal(progressId: string, bookTitle: string): void {
function hideManualLinkModal(): void {
const modal = document.getElementById("manual-link-modal");
const searchInput = document.getElementById(
"link-search-input",
) as HTMLInputElement;
const searchInput = document.getElementById("link-search-input") as HTMLInputElement;
if (modal) modal.classList.add("hidden");
if (searchInput) searchInput.value = "";
@@ -117,9 +97,7 @@ function hideManualLinkModal(): void {
}
function searchBooksForLink(): void {
const searchInput = document.getElementById(
"link-search-input",
) as HTMLInputElement;
const searchInput = document.getElementById("link-search-input") as HTMLInputElement;
const resultsContainer = document.getElementById("link-search-results");
if (!searchInput || !resultsContainer) return;
@@ -195,18 +173,10 @@ function confirmManualLink(): void {
return;
}
const progressIdInput = document.getElementById(
"link-progress-id",
) as HTMLInputElement;
const confidenceInput = document.getElementById(
"link-confidence",
) as HTMLInputElement;
const bookTitleInput = document.getElementById(
"link-book-title",
) as HTMLInputElement;
const sha256Input = document.getElementById(
"link-book-sha256",
) as HTMLInputElement;
const progressIdInput = document.getElementById("link-progress-id") as HTMLInputElement;
const confidenceInput = document.getElementById("link-confidence") as HTMLInputElement;
const bookTitleInput = document.getElementById("link-book-title") as HTMLInputElement;
const sha256Input = document.getElementById("link-book-sha256") as HTMLInputElement;
if (!progressIdInput) return;
@@ -245,9 +215,7 @@ function confirmManualLink(): void {
}
function toggleAllUnlinked(): void {
const selectAll = document.getElementById(
"select-all-unlinked",
) as HTMLInputElement;
const selectAll = document.getElementById("select-all-unlinked") as HTMLInputElement;
if (!selectAll) return;
document.querySelectorAll(".unlinked-checkbox").forEach((cb) => {
@@ -257,9 +225,7 @@ function toggleAllUnlinked(): void {
}
function getSelectedUnlinked(): { progressId: string; title: string }[] {
return Array.from(
document.querySelectorAll(".unlinked-checkbox:checked"),
).map((cb) => ({
return Array.from(document.querySelectorAll(".unlinked-checkbox:checked")).map((cb) => ({
progressId: cb.getAttribute("data-progress-id") || "",
title: cb.getAttribute("data-title") || "",
}));
@@ -280,11 +246,7 @@ async function bulkAutoLink(): Promise<void> {
return;
}
if (
!confirm(
`Auto-link ${selected.length} books with high confidence matches (≥80%)?`,
)
) {
if (!confirm(`Auto-link ${selected.length} books with high confidence matches (≥80%)?`)) {
return;
}
@@ -303,10 +265,7 @@ async function bulkAutoLink(): Promise<void> {
});
const result = await response.json();
showToast(
`Auto-linked ${result.auto_linked} books successfully`,
"success",
);
showToast(`Auto-linked ${result.auto_linked} books successfully`, "success");
setTimeout(() => window.location.reload(), 1500);
} catch (error) {
console.error("Auto-link failed", error);
@@ -324,14 +283,11 @@ async function bulkGetSuggestions(): Promise<void> {
const token = getToken();
for (const book of selected) {
try {
const response = await fetch(
`/sync/unlinked-books/${book.progressId}/suggestions`,
{
headers: {
Authorization: `Bearer ${token}`,
},
const response = await fetch(`/sync/unlinked-books/${book.progressId}/suggestions`, {
headers: {
Authorization: `Bearer ${token}`,
},
);
});
const result = await response.json();
displaySuggestions(book.progressId, result.suggestions, result.action);
@@ -341,11 +297,7 @@ async function bulkGetSuggestions(): Promise<void> {
}
}
function displaySuggestions(
progressId: string,
suggestions: any[],
_action: string,
): void {
function displaySuggestions(progressId: string, suggestions: any[], _action: string): void {
const container = document.getElementById(`matches-${progressId}`);
if (!container) return;
@@ -356,15 +308,13 @@ function displaySuggestions(
listContainer.innerHTML = "";
if (suggestions.length === 0) {
listContainer.innerHTML =
'<p style="color: var(--text-secondary)">No matches found</p>';
listContainer.innerHTML = '<p style="color: var(--text-secondary)">No matches found</p>';
return;
}
suggestions.forEach((match) => {
const div = document.createElement("div");
div.className =
"p-3 border rounded cursor-pointer hover:bg-opacity-80 transition-colors";
div.className = "p-3 border rounded cursor-pointer hover:bg-opacity-80 transition-colors";
div.style.cssText = `background-color: var(--bg-primary); border-color: var(--border);`;
div.innerHTML = `
<div class="flex justify-between items-center">
@@ -400,8 +350,7 @@ function showBulkManualLink(): void {
"info",
);
window.location.href =
"/library?mode=link&unlinked=" +
selected.map((s) => s.progressId).join(",");
"/library?mode=link&unlinked=" + selected.map((s) => s.progressId).join(",");
}
function setupEventDelegation(): void {
@@ -420,10 +369,7 @@ function setupEventDelegation(): void {
parseFloat(card.dataset.confidence || "0"),
);
} else if (action === "select-book") {
selectBookForLink(
card.dataset.mediaItemId || "",
card.dataset.cover || "",
);
selectBookForLink(card.dataset.mediaItemId || "", card.dataset.cover || "");
} else if (action === "select-match") {
autoLinkBook(
card.dataset.progressId || "",
+1 -6
View File
@@ -120,9 +120,4 @@ function createWebSocket(config: {
return ws;
}
export {
addListener,
createWebSocket,
disconnectWebSocket,
removeListener,
};
export { addListener, createWebSocket, disconnectWebSocket, removeListener };
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

+1 -1
View File
@@ -1,2 +1,2 @@
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated