Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03c3e151dc | ||
|
|
3576a4415a | ||
|
|
3325b64463 | ||
|
|
a9430f3c06 | ||
|
|
96fd2aad42 | ||
|
|
1f30602c23 | ||
|
|
18bed79d3a | ||
|
|
fe7eab91e8 | ||
|
|
295c4b0664 | ||
|
|
db96ea91cd | ||
|
|
408a972665 | ||
|
|
6508abc00f | ||
|
|
3ed852515b | ||
|
|
f62b46fb15 | ||
|
|
35d8ba32e5 | ||
|
|
80bfc1a77b | ||
|
|
970beee4b8 | ||
|
|
fa5b952c54 | ||
|
|
ff17d14694 | ||
|
|
807899b137 | ||
|
|
6791d611f0 | ||
|
|
e5315f1b94 | ||
|
|
86c4940426 | ||
|
|
d7d1495753 | ||
|
|
77cd268f4e | ||
|
|
8aeecaa0ef |
No files matched your search
@@ -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
|
||||
`'` 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.
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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 & interactions"
|
||||
title="Shortcuts & 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 & 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>
|
||||
|
||||
@@ -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 & interactions\" title=\"Shortcuts & 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
|
||||
}
|
||||
|
||||
@@ -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 ' 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);">
|
||||
|
||||
@@ -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 ' 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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/** The export item text — identical in single and multi modes. */
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
];
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
|
||||
@@ -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(
|
||||
'\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("");
|
||||
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
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
@@ -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}`;
|
||||
|
||||
Vendored
+9
-8
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -61,5 +61,5 @@ export {
|
||||
setRefreshToken,
|
||||
setSelectedLibrary,
|
||||
setTheme,
|
||||
setToken
|
||||
setToken,
|
||||
};
|
||||
+6
-19
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
Vendored
+8
-11
@@ -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"
|
||||
|
||||
Vendored
+1
-9
@@ -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
@@ -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 || "",
|
||||
|
||||
@@ -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,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
|
||||
Reference in new issue
Block a user