Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d0ae2bcc4 | ||
|
|
f8de17723a | ||
|
|
4769e832c4 | ||
|
|
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
|
## 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`
|
- Use `setupTestServer()` from `cmd/server/tests/test_helpers_test.go`
|
||||||
(see the Conventions section below for the pool/cleanup rules).
|
(see the Conventions section below for the pool/cleanup rules).
|
||||||
- Integration tests run against the SHARED dev database: never write
|
- 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:
|
tested with synthetic ffprobeReport values. Recipes:
|
||||||
docs/contributing/development.md → Media fixtures.
|
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
|
## Timezones
|
||||||
|
|
||||||
- The server owns display conversion: clients send instants (RFC3339),
|
- 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
|
test pools use `max_conns=1`; never create a `pgxpool` per subtest
|
||||||
(connection exhaustion); use `t.Cleanup()`-based helpers; table-driven
|
(connection exhaustion); use `t.Cleanup()`-based helpers; table-driven
|
||||||
subtests over duplicate functions.
|
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))
|
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
|
// audioTestLibrary builds an audiobooks-type library with a fresh temp
|
||||||
// folder, returning its UUID and the folder path.
|
// folder, returning its UUID and the folder path.
|
||||||
func audioTestLibrary(t *testing.T, setup *TestServerSetup, name string) (pgtype.UUID, string) {
|
func audioTestLibrary(t *testing.T, setup *TestServerSetup, name string) (pgtype.UUID, string) {
|
||||||
|
|||||||
@@ -17,11 +17,9 @@ package main
|
|||||||
// equal → stable (second corruption persists)
|
// equal → stable (second corruption persists)
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -58,14 +56,11 @@ func corruptItemColumn(t *testing.T, setup *TestServerSetup, itemID pgtype.UUID,
|
|||||||
|
|
||||||
func setExtractorVersion(t *testing.T, setup *TestServerSetup, v int) {
|
func setExtractorVersion(t *testing.T, setup *TestServerSetup, v int) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
body, _ := json.Marshal(map[string]string{"key": "scan_extractor_version", "value": strconv.Itoa(v)})
|
// Restore-to-prior (not a hardcoded value): the shared dev DB's
|
||||||
req, _ := http.NewRequest("PUT", setup.Server.URL+"/api/system/settings", bytes.NewBuffer(body))
|
// setting must survive the suite exactly as the test found it — if a
|
||||||
req.Header.Set("Content-Type", "application/json")
|
// parser-fix bump was in flight, a hardcoded 1 would silently cancel
|
||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
// it and the live server would stop re-extracting.
|
||||||
resp, err := http.DefaultClient.Do(req)
|
setSystemSettingRestored(t, setup, "scan_extractor_version", strconv.Itoa(v))
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode, "settings API is the only sanctioned write path")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractorVersionReextract(t *testing.T) {
|
func TestExtractorVersionReextract(t *testing.T) {
|
||||||
@@ -123,7 +118,8 @@ func TestExtractorVersionReextract(t *testing.T) {
|
|||||||
// re-extract once: the corruption heals and the fresh manifest
|
// re-extract once: the corruption heals and the fresh manifest
|
||||||
// carries ev=2.
|
// carries ev=2.
|
||||||
setExtractorVersion(t, setup, 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)
|
scanTestFolder(t, setup, admin, folder)
|
||||||
item, found = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Probe Author/EV Probe Book/EV Probe Book.epub"))
|
item, found = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Probe Author/EV Probe Book/EV Probe Book.epub"))
|
||||||
require.True(t, found)
|
require.True(t, found)
|
||||||
@@ -168,7 +164,8 @@ func TestExtractorVersionMultifile(t *testing.T) {
|
|||||||
// Bump → the ev gate fires through multifileSidecarChanged → the
|
// Bump → the ev gate fires through multifileSidecarChanged → the
|
||||||
// editorial apply re-derives the title from the folder basename.
|
// editorial apply re-derives the title from the folder basename.
|
||||||
setExtractorVersion(t, setup, 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)
|
scanTestFolder(t, setup, admin, folder)
|
||||||
book, found = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Audio Author/EV Audio Title"))
|
book, found = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Audio Author/EV Audio Title"))
|
||||||
require.True(t, found)
|
require.True(t, found)
|
||||||
|
|||||||
@@ -32,11 +32,11 @@ import (
|
|||||||
// the default 1 for the test's duration. Prior interrupted runs can
|
// the default 1 for the test's duration. Prior interrupted runs can
|
||||||
// leave it bumped (an early test failure aborts before its restore
|
// leave it bumped (an early test failure aborts before its restore
|
||||||
// cleanup registers), and every stamp assertion in these tests assumes
|
// 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) {
|
func pinExtractorVersionOne(t *testing.T, setup *TestServerSetup) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
setExtractorVersion(t, setup, 1)
|
setExtractorVersion(t, setup, 1)
|
||||||
t.Cleanup(func() { setExtractorVersion(t, setup, 1) })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// forceScanTestFolder runs a FORCE scan over one folder.
|
// 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/go-playground/validator/v10"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
@@ -181,6 +182,54 @@ func getUploadPath() string {
|
|||||||
return "./uploads" // Host path (left side of volume mount)
|
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
|
// getCachePath returns the appropriate cache path based on runtime environment
|
||||||
func getCachePath() string {
|
func getCachePath() string {
|
||||||
// Check for explicit override first
|
// Check for explicit override first
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
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 system_settings table
|
||||||
CREATE TABLE IF NOT EXISTS system_settings (
|
CREATE TABLE IF NOT EXISTS system_settings (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
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_user ON reading_speed(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_reading_speed_item ON reading_speed(media_item_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)
|
-- Dictionary cache (for offline use)
|
||||||
CREATE TABLE IF NOT EXISTS dictionary_cache (
|
CREATE TABLE IF NOT EXISTS dictionary_cache (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"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 {
|
type CollectionItems struct {
|
||||||
ID pgtype.UUID `db:"id" json:"id"`
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
|
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
|
||||||
@@ -587,4 +596,5 @@ type Users struct {
|
|||||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||||
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
||||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
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)
|
GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error)
|
||||||
GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error)
|
GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error)
|
||||||
GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, 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).
|
// Lifetime session rows for ONE book (the book-detail stats card).
|
||||||
GetBookReadingSessions(ctx context.Context, arg GetBookReadingSessionsParams) ([]GetBookReadingSessionsRow, error)
|
GetBookReadingSessions(ctx context.Context, arg GetBookReadingSessionsParams) ([]GetBookReadingSessionsRow, error)
|
||||||
GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error)
|
GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error)
|
||||||
@@ -696,12 +698,14 @@ type Querier interface {
|
|||||||
UpdateSystemSetting(ctx context.Context, arg UpdateSystemSettingParams) error
|
UpdateSystemSetting(ctx context.Context, arg UpdateSystemSettingParams) error
|
||||||
// Update universal progress
|
// Update universal progress
|
||||||
UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error)
|
UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error)
|
||||||
|
UpdateUserDefaultAudioSpeed(ctx context.Context, arg UpdateUserDefaultAudioSpeedParams) error
|
||||||
UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDevicesParams) (Users, error)
|
UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDevicesParams) (Users, error)
|
||||||
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
|
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
|
||||||
UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UpdateUserRoleRow, error)
|
UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UpdateUserRoleRow, error)
|
||||||
UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error
|
UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error
|
||||||
UpdateUserTimezone(ctx context.Context, arg UpdateUserTimezoneParams) error
|
UpdateUserTimezone(ctx context.Context, arg UpdateUserTimezoneParams) error
|
||||||
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
|
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
|
||||||
|
UpsertAudioPlaybackSpeed(ctx context.Context, arg UpsertAudioPlaybackSpeedParams) (float32, error)
|
||||||
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
|
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
|
||||||
UpsertGlobalDashboardPreferences(ctx context.Context, arg UpsertGlobalDashboardPreferencesParams) (UserDashboardPreferencesGlobal, error)
|
UpsertGlobalDashboardPreferences(ctx context.Context, arg UpsertGlobalDashboardPreferencesParams) (UserDashboardPreferencesGlobal, error)
|
||||||
UpsertMediaReview(ctx context.Context, arg UpsertMediaReviewParams) (MediaReviews, 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
|
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
|
const GetBookReadingSessions = `-- name: GetBookReadingSessions :many
|
||||||
SELECT
|
SELECT
|
||||||
rh.id, rh.device_id, rh.progress_percentage,
|
rh.id, rh.device_id, rh.progress_percentage,
|
||||||
@@ -9922,6 +9941,7 @@ SELECT
|
|||||||
u.created_at,
|
u.created_at,
|
||||||
u.updated_at,
|
u.updated_at,
|
||||||
u.timezone,
|
u.timezone,
|
||||||
|
u.default_audio_speed,
|
||||||
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
|
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
|
||||||
FROM users u
|
FROM users u
|
||||||
WHERE u.id = $1
|
WHERE u.id = $1
|
||||||
@@ -9939,6 +9959,7 @@ type GetUserRow struct {
|
|||||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||||
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
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"`
|
DeviceCount int64 `db:"device_count" json:"device_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9957,6 +9978,7 @@ func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, erro
|
|||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
|
&i.DefaultAudioSpeed,
|
||||||
&i.DeviceCount,
|
&i.DeviceCount,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
@@ -17200,9 +17222,23 @@ func (q *Queries) UpdateUniversalProgress(ctx context.Context, arg UpdateUnivers
|
|||||||
return i, err
|
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
|
const UpdateUserMaxDevices = `-- name: UpdateUserMaxDevices :one
|
||||||
UPDATE users SET max_devices = $2, updated_at = NOW() WHERE id = $1
|
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 {
|
type UpdateUserMaxDevicesParams struct {
|
||||||
@@ -17226,6 +17262,7 @@ func (q *Queries) UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDev
|
|||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.DefaultAudioSpeed,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -17316,6 +17353,27 @@ func (q *Queries) UpdateUsername(ctx context.Context, arg UpdateUsernameParams)
|
|||||||
return err
|
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
|
const UpsertDashboardPreferences = `-- name: UpsertDashboardPreferences :one
|
||||||
INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_collections, collection_order, items_per_section)
|
INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_collections, collection_order, items_per_section)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ SELECT
|
|||||||
u.created_at,
|
u.created_at,
|
||||||
u.updated_at,
|
u.updated_at,
|
||||||
u.timezone,
|
u.timezone,
|
||||||
|
u.default_audio_speed,
|
||||||
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
|
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
|
||||||
FROM users u
|
FROM users u
|
||||||
WHERE u.id = $1;
|
WHERE u.id = $1;
|
||||||
@@ -511,6 +512,22 @@ RETURNING id, email, username, role;
|
|||||||
-- name: UpdateUserTimezone :exec
|
-- name: UpdateUserTimezone :exec
|
||||||
UPDATE users SET timezone = $2, updated_at = NOW() WHERE id = $1;
|
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
|
-- name: GetSystemTimezone :one
|
||||||
SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone';
|
SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone';
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -120,6 +121,8 @@ type UserProfile struct {
|
|||||||
LastName string `json:"last_name"`
|
LastName string `json:"last_name"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Theme string `json:"theme,omitempty"`
|
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 {
|
type UpdateProfileRequest struct {
|
||||||
@@ -129,6 +132,9 @@ type UpdateProfileRequest struct {
|
|||||||
LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
|
LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
|
||||||
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
|
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
|
||||||
Timezone string `json:"timezone,omitempty" form:"timezone" 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 {
|
type AdminUpdateUserRequest struct {
|
||||||
@@ -139,6 +145,8 @@ type AdminUpdateUserRequest struct {
|
|||||||
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
|
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
|
||||||
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
|
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
|
||||||
Role string `json:"role,omitempty" form:"role" validate:"omitempty,oneof=user admin"`
|
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
|
// Register handles POST /api/auth/register
|
||||||
@@ -545,6 +553,7 @@ func (h *AuthHandler) GetProfile(c *echo.Context) error {
|
|||||||
LastName: lastName,
|
LastName: lastName,
|
||||||
Role: user.Role,
|
Role: user.Role,
|
||||||
Theme: user.Theme.String,
|
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
|
// Browser path (htmx modal + profile page): refresh so every rendered
|
||||||
// value (timezone select, table rows, header) reflects the save and the
|
// value (timezone select, table rows, header) reflects the save and the
|
||||||
// modal closes. A bare JSON body swapped into #profile-result read as
|
// modal closes. A bare JSON body swapped into #profile-result read as
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"math"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -1610,6 +1611,14 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
|||||||
Summary: pgtype.Text{String: req.Summary, Valid: req.Summary != ""},
|
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
|
// Fields the user actually changed become overrides so future scans keep
|
||||||
// the custom values. An explicit cover upload/removal always overrides.
|
// the custom values. An explicit cover upload/removal always overrides.
|
||||||
overrides := utils.DetectMetadataOverrides(params, existing)
|
overrides := utils.DetectMetadataOverrides(params, existing)
|
||||||
@@ -2982,6 +2991,30 @@ 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{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"media_item_id": item.ID,
|
"media_item_id": item.ID,
|
||||||
"chapters": payload.Chapters,
|
"chapters": payload.Chapters,
|
||||||
@@ -2989,9 +3022,65 @@ func (mh *MediaHandler) GetAudioChapters(c *echo.Context) error {
|
|||||||
"codec_profile": payload.CodecProfile,
|
"codec_profile": payload.CodecProfile,
|
||||||
"layout": payload.Layout,
|
"layout": payload.Layout,
|
||||||
"tracks": tracks,
|
"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
|
// GetAudioTrack handles GET /api/media-items/:id/audio/:trackIndex — the
|
||||||
// per-track byte stream for multi-file audiobooks (folder books serve 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
|
// real file; zip books extract the entry in place). Library visibility is
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
|
|||||||
FirstName: utils.PgTextString(userDB.FirstName),
|
FirstName: utils.PgTextString(userDB.FirstName),
|
||||||
LastName: utils.PgTextString(userDB.LastName),
|
LastName: utils.PgTextString(userDB.LastName),
|
||||||
CreatedAt: userDB.CreatedAt.Time,
|
CreatedAt: userDB.CreatedAt.Time,
|
||||||
|
DefaultAudioSpeed: float64(userDB.DefaultAudioSpeed),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ func registerMediaRoutes(cfg *Config) {
|
|||||||
// books stream through the existing /uploads Range-serving path;
|
// books stream through the existing /uploads Range-serving path;
|
||||||
// multi-file books additionally expose per-track streaming.
|
// multi-file books additionally expose per-track streaming.
|
||||||
protected.GET("/media-items/:id/chapters", cfg.MediaHandler.GetAudioChapters)
|
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)
|
protected.GET("/media-items/:id/audio/:trackIndex", cfg.MediaHandler.GetAudioTrack)
|
||||||
|
|
||||||
// Media rating routes (all authenticated users)
|
// Media rating routes (all authenticated users)
|
||||||
|
|||||||
@@ -4049,6 +4049,18 @@ func (s *MediaScanner) updateMediaItemWithMetadata(ctx context.Context, existing
|
|||||||
if metadata.AlternateInfo != "" {
|
if metadata.AlternateInfo != "" {
|
||||||
alternateInfoBytes = []byte(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{
|
params := database.UpdateMediaItemParams{
|
||||||
ID: existing.ID,
|
ID: existing.ID,
|
||||||
Title: metadata.Title,
|
Title: metadata.Title,
|
||||||
@@ -4089,7 +4101,7 @@ func (s *MediaScanner) updateMediaItemWithMetadata(ctx context.Context, existing
|
|||||||
AlternateInfo: alternateInfoBytes,
|
AlternateInfo: alternateInfoBytes,
|
||||||
ScanInformation: pgtype.Text{String: metadata.ScanInformation, Valid: metadata.ScanInformation != ""},
|
ScanInformation: pgtype.Text{String: metadata.ScanInformation, Valid: metadata.ScanInformation != ""},
|
||||||
Summary: pgtype.Text{String: metadata.Summary, Valid: metadata.Summary != ""},
|
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.
|
// 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
|
// rescan saw the track set change (or the admin forced a rescan). Only
|
||||||
// scan-derived fields move — editorial columns keep admin edits.
|
// 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 {
|
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{
|
if _, err := s.db.UpdateMediaItemAudioBookPayload(ctx, database.UpdateMediaItemAudioBookPayloadParams{
|
||||||
ID: existing.ID,
|
ID: existing.ID,
|
||||||
FileSize: pgtype.Int8{Int64: totalSize, Valid: true},
|
FileSize: pgtype.Int8{Int64: totalSize, Valid: true},
|
||||||
@@ -1028,6 +1038,14 @@ func (s *MediaScanner) updateAudioBookItem(ctx context.Context, existing databas
|
|||||||
return nil
|
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
|
// multifileSidecarChanged runs the manifest ladder for a folder/zip book
|
||||||
// (faceted plan Phase 6): a size-stable multi-file book is changed by
|
// (faceted plan Phase 6): a size-stable multi-file book is changed by
|
||||||
// EDITING its sidecars — the track-count/size fingerprint never saw it,
|
// EDITING its sidecars — the track-count/size fingerprint never saw it,
|
||||||
|
|||||||
+11
-5
@@ -5,11 +5,12 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build:css": "tailwindcss -i ./web/static/input.css -o ./web/static/style.css --watch",
|
"build:css": "tailwindcss -i ./web/static/input.css -o ./web/static/style.css --watch",
|
||||||
"build:css:prod": "tailwindcss -i ./web/static/input.css -o ./web/static/style.css --minify",
|
"build:css:prod": "tailwindcss -i ./web/static/input.css -o ./web/static/style.css --minify",
|
||||||
"build:ts": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vite build",
|
"build:ts": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vp build",
|
||||||
"build:ts:dev": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vite build --mode development",
|
"build:ts:dev": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vp build --mode development",
|
||||||
"build:ts:watch": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vite build --watch",
|
"build:ts:watch": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vp build --watch",
|
||||||
"build": "npm run build:ts && npm run build:css:prod",
|
"build": "npm run build:ts && npm run build:css:prod",
|
||||||
"dev": "npm run build:ts:dev && npm run build:css"
|
"dev": "npm run build:ts:dev && npm run build:css",
|
||||||
|
"test": "vp test web/src"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bookhoard/foliate-js": "git+https://git.linuxhg.com/Bookhoard/foliate-js.git#f622841",
|
"@bookhoard/foliate-js": "git+https://git.linuxhg.com/Bookhoard/foliate-js.git#f622841",
|
||||||
@@ -31,9 +32,14 @@
|
|||||||
"@types/markdown-it": "^14.2.0",
|
"@types/markdown-it": "^14.2.0",
|
||||||
"@types/turndown": "^5.0.6",
|
"@types/turndown": "^5.0.6",
|
||||||
"autoprefixer": "^10.4.27",
|
"autoprefixer": "^10.4.27",
|
||||||
|
"jsdom": "^30.1.3",
|
||||||
"playwright": "^1.64.1",
|
"playwright": "^1.64.1",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "^5.9.3",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,14 +10,26 @@ package templates
|
|||||||
import "fmt"
|
import "fmt"
|
||||||
|
|
||||||
templ AudioReader(user User, metadata ReaderMetadata) {
|
templ AudioReader(user User, metadata ReaderMetadata) {
|
||||||
<!DOCTYPE html/>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>{ "Listening · " + metadata.Title } · Bookhoard</title>
|
<title>{ "Listening · " + metadata.Title } · Bookhoard</title>
|
||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
<script type="module" src="/static/main.js"></script>
|
<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>
|
</head>
|
||||||
<body
|
<body
|
||||||
x-data="audioReader"
|
x-data="audioReader"
|
||||||
@@ -32,6 +44,12 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
|||||||
"/api/media-items/"+metadata.MediaItemID+"/chapters",
|
"/api/media-items/"+metadata.MediaItemID+"/chapters",
|
||||||
"/api/media-items/"+metadata.MediaItemID+"/progress") }
|
"/api/media-items/"+metadata.MediaItemID+"/progress") }
|
||||||
>
|
>
|
||||||
|
<!-- §17 wind-down dim: the sleep ticker eases this toward 35%
|
||||||
|
black across a countdown's final 30s; pointer-events-none
|
||||||
|
keeps the page usable, stop/finish resets it. -->
|
||||||
|
<div class="fixed inset-0 pointer-events-none z-40"
|
||||||
|
:style="'opacity:' + sleepDim + '; background: #000; transition: opacity 1s linear;'"
|
||||||
|
></div>
|
||||||
<div class="h-full flex flex-col items-center justify-center px-4 gap-6 select-none">
|
<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
|
<!-- Header: back to the detail page (reader-style exit) and the
|
||||||
chapter launcher. Floating drawer, never in-flow (A2b). -->
|
chapter launcher. Floating drawer, never in-flow (A2b). -->
|
||||||
@@ -75,6 +93,86 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
|||||||
<path d="m6 9 6 6 6-6"></path>
|
<path d="m6 9 6 6 6-6"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -101,7 +199,44 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
|||||||
<!-- Scrubber + transports -->
|
<!-- Scrubber + transports -->
|
||||||
<div class="w-full max-w-xl flex flex-col gap-2">
|
<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);">
|
<div class="flex items-center gap-2 text-xs tabular-nums" style="color: var(--text-secondary);">
|
||||||
<span x-text="fmtTime(currentTime)">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
|
<input
|
||||||
id="audio-scrub"
|
id="audio-scrub"
|
||||||
type="range"
|
type="range"
|
||||||
@@ -109,13 +244,51 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
|||||||
step="0.1"
|
step="0.1"
|
||||||
:max="chaptersDuration() || 1"
|
:max="chaptersDuration() || 1"
|
||||||
:value="scrubbing ? scrubValue : currentTime"
|
:value="scrubbing ? scrubValue : currentTime"
|
||||||
class="flex-1 accent-current"
|
class="relative w-full"
|
||||||
@input="scrubbing = true; scrubValue = Number($event.target.value)"
|
@input="scrubbing = true; scrubValue = Number($event.target.value)"
|
||||||
@pointerup="seekTo(scrubValue); scrubbing = false"
|
|
||||||
@change="seekTo(scrubValue); scrubbing = false"
|
@change="seekTo(scrubValue); scrubbing = false"
|
||||||
style="z-index: 20;"
|
aria-label="Seek position"
|
||||||
/>
|
/>
|
||||||
<span x-text="fmtTime(chaptersDuration() || 1)">0:00</span>
|
<!-- 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>
|
||||||
<div class="flex items-center justify-center gap-3 md:gap-5">
|
<div class="flex items-center justify-center gap-3 md:gap-5">
|
||||||
<button
|
<button
|
||||||
@@ -219,7 +392,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]"
|
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;"
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="setSkip(secs); skipOpen = false"
|
@click="setSkip(secs); skipOpen = false"
|
||||||
@@ -249,16 +422,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]"
|
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;"
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="setSpeed(rate); speedOpen = false"
|
@click="setSpeed(rate); speedOpen = false"
|
||||||
class="w-full text-left px-3 py-1.5 rounded-md text-sm"
|
class="w-full text-left px-3 py-1.5 rounded-md text-sm"
|
||||||
:class="Number(speed) === rate ? 'font-semibold' : ''"
|
:class="Number(speed) === rate ? 'font-semibold' : ''"
|
||||||
:style="Number(speed) === rate ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'"
|
: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>
|
></button>
|
||||||
</template>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -275,6 +458,55 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Bookmark</span>
|
<span>Bookmark</span>
|
||||||
</button>
|
</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>
|
||||||
|
<!-- §17 clock mode: stop at a wall-clock time (its next
|
||||||
|
occurrence). The input drives chooseSleepClock(). -->
|
||||||
|
<div class="flex items-center gap-1.5 px-2 pt-1.5 mt-1 border-t" style="border-color: var(--border);">
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
x-model="sleepClockTime"
|
||||||
|
class="text-xs px-1.5 py-1 rounded-md flex-1 min-w-0"
|
||||||
|
style="background-color: var(--surface-hover); color: var(--text-primary); border: 1px solid var(--border);"
|
||||||
|
aria-label="Stop at time"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="chooseSleepClock(); sleepOpen = false"
|
||||||
|
class="text-xs px-2.5 py-1 rounded-md font-semibold"
|
||||||
|
:style="sleepArmedChoiceKey === 'clock' ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'background-color: var(--surface-hover); color: var(--text-secondary);'"
|
||||||
|
>Arm</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="relative" @click.outside="volumeOpen = false">
|
<div class="relative" @click.outside="volumeOpen = false">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -453,10 +685,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>
|
<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
|
<textarea
|
||||||
|
id="bookmark-note"
|
||||||
x-model="bookmarkNote"
|
x-model="bookmarkNote"
|
||||||
rows="3"
|
rows="3"
|
||||||
placeholder="Optional note — markdown welcome (e.g. **great quote**, list, [link](https://…))"
|
placeholder="Optional note — markdown welcome (e.g. **great quote**, list, [link](https://…))"
|
||||||
class="input mb-2 resize-y"
|
class="input mb-2 resize-y"
|
||||||
|
@keydown.enter="if (!$event.shiftKey) { $event.preventDefault(); saveBookmark() }"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="flex justify-end gap-2">
|
<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>
|
<button type="button" @click="bookmarkCaptureOpen = false" class="btn btn-ghost text-sm px-3 py-1.5">Cancel</button>
|
||||||
@@ -464,6 +698,59 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<audio id="audio-element" preload="auto" class="hidden"></audio>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " · 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\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"><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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.MediaItemID)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.MediaItemID)
|
||||||
if templ_7745c5c3_Err != nil {
|
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: 39, Col: 44}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -93,7 +93,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
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: 40, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -106,7 +106,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
|
||||||
if templ_7745c5c3_Err != nil {
|
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: 41, Col: 39}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -119,7 +119,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.FileURL)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.FileURL)
|
||||||
if templ_7745c5c3_Err != nil {
|
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: 42, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||||
if templ_7745c5c3_Err != nil {
|
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+"/chapters",
|
||||||
"/api/media-items/"+metadata.MediaItemID+"/progress"))
|
"/api/media-items/"+metadata.MediaItemID+"/progress"))
|
||||||
if templ_7745c5c3_Err != nil {
|
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: 45, Col: 57}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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, "\"><!-- §17 wind-down dim: the sleep ticker eases this toward 35%\n\t\t\t black across a countdown's final 30s; pointer-events-none\n\t\t\t keeps the page usable, stop/finish resets it. --><div class=\"fixed inset-0 pointer-events-none z-40\" :style=\"'opacity:' + sleepDim + '; background: #000; transition: opacity 1s linear;'\"></div><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-semLine truncated
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if metadata.CoverImagePath != "" {
|
for _, rate := range SpeedOptions {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<img src=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<option value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%g", rate))
|
||||||
if templ_7745c5c3_Err != nil {
|
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: 168, Col: 41}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" alt=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" :selected=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("Number(defaultSpeed) === %g", rate))
|
||||||
if templ_7745c5c3_Err != nil {
|
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: 169, Col: 70}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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
|
|
||||||
}
|
|
||||||
} 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>")
|
|
||||||
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);\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%g×", rate))
|
||||||
if templ_7745c5c3_Err != nil {
|
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: 170, Col: 37}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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, 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: 184, 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: 185, 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, 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, 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_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: 194, Col: 117}
|
||||||
|
}
|
||||||
|
_, 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, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,32 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"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"
|
"github.com/microcosm-cc/bluemonday"
|
||||||
"strings"
|
"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) {
|
templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombstoneDays int) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -30,10 +52,27 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
|||||||
data-conflict-winner={ conflictWinnerSource(book.ActiveConflict) }
|
data-conflict-winner={ conflictWinnerSource(book.ActiveConflict) }
|
||||||
>
|
>
|
||||||
@Header(user, "/media/{ uuidToString(book.ID) }")
|
@Header(user, "/media/{ uuidToString(book.ID) }")
|
||||||
|
if book.ActiveConflict != nil {
|
||||||
|
<!-- Sync-conflict banner (user request): the progress
|
||||||
|
card's warning sits far down a long page — this
|
||||||
|
strip is unmissable at the very top, and clicking
|
||||||
|
it opens the same resolution modal. -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showProgressSyncModal()"
|
||||||
|
class="w-full flex items-center gap-3 px-4 sm:px-6 lg:px-8 py-3 text-left"
|
||||||
|
style="background-color: color-mix(in srgb, var(--status-warning) 14%, transparent); border-bottom: 1px solid var(--status-warning);"
|
||||||
|
>
|
||||||
|
@Icon("alert", "h-5 w-5 shrink-0")
|
||||||
|
<span class="text-sm" style="color: var(--text-primary);">
|
||||||
|
Progress sync conflict detected on this item — click to review and resolve.
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
<!-- Hero with blurred cover backdrop -->
|
<!-- Hero with blurred cover backdrop -->
|
||||||
<div class="relative overflow-hidden">
|
<div class="relative overflow-hidden">
|
||||||
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
|
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">
|
<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);">
|
<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);">
|
||||||
|
|||||||
+499
-463
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,15 @@
|
|||||||
package templates
|
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) {
|
templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, showRoleField bool, showCancelButton bool) {
|
||||||
<form
|
<form
|
||||||
hx-put={ actionURL }
|
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>
|
<option value="material-dark" selected?={ user.Theme == "material-dark" }>Material Dark</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Timezone</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Timezone</label>
|
||||||
<select
|
<select
|
||||||
@@ -125,7 +146,6 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
|
|||||||
type="password"
|
type="password"
|
||||||
name="current_password"
|
name="current_password"
|
||||||
class="input"
|
class="input"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
+132
-76
@@ -8,6 +8,16 @@ package templates
|
|||||||
import "github.com/a-h/templ"
|
import "github.com/a-h/templ"
|
||||||
import templruntime "github.com/a-h/templ/runtime"
|
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 {
|
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) {
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
@@ -36,7 +46,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(actionURL)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(actionURL)
|
||||||
if templ_7745c5c3_Err != nil {
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -57,7 +67,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Username)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Username)
|
||||||
if templ_7745c5c3_Err != nil {
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -70,7 +80,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Email)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Email)
|
||||||
if templ_7745c5c3_Err != nil {
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -83,7 +93,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.FirstName)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.FirstName)
|
||||||
if templ_7745c5c3_Err != nil {
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -96,7 +106,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.LastName)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.LastName)
|
||||||
if templ_7745c5c3_Err != nil {
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -172,277 +182,323 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
|||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "UTC" {
|
for _, rate := range SpeedOptions {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<option value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 = templruntime.WriteString(templ_7745c5c3_Buffer, 23, ">UTC (UTC+0)</option> <option value=\"Pacific/Honolulu\"")
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Pacific/Honolulu" {
|
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")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, ">Hawaii (UTC-10)</option> <option value=\"America/Anchorage\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, ">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "America/Anchorage" {
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " selected")
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"")
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "America/Los_Angeles" {
|
if user.Timezone == "UTC" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "America/Denver" {
|
if user.Timezone == "Pacific/Honolulu" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "America/Phoenix" {
|
if user.Timezone == "America/Anchorage" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Europe/London" {
|
if user.Timezone == "America/Chicago" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Europe/Moscow" {
|
if user.Timezone == "Europe/London" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Asia/Tehran" {
|
if user.Timezone == "Europe/Paris" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Asia/Dubai" {
|
if user.Timezone == "Europe/Helsinki" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Asia/Karachi" {
|
if user.Timezone == "Europe/Moscow" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Asia/Kolkata" {
|
if user.Timezone == "Asia/Tehran" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Asia/Dhaka" {
|
if user.Timezone == "Asia/Dubai" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Asia/Bangkok" {
|
if user.Timezone == "Asia/Karachi" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Asia/Shanghai" {
|
if user.Timezone == "Asia/Kolkata" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Asia/Tokyo" {
|
if user.Timezone == "Asia/Dhaka" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Australia/Darwin" {
|
if user.Timezone == "Asia/Bangkok" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Australia/Sydney" {
|
if user.Timezone == "Asia/Shanghai" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Timezone == "Pacific/Auckland" {
|
if user.Timezone == "Asia/Tokyo" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " selected")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " selected")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if showRoleField {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Role == "user" {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if user.Role == "admin" {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -450,12 +506,12 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if requireCurrentPassword {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -463,25 +519,25 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/password/" + user.ID)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/password/" + user.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -489,22 +545,22 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if showCancelButton {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -512,7 +568,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -350,6 +350,7 @@ templ ReaderChrome(metadata ReaderMetadata) {
|
|||||||
step="any"
|
step="any"
|
||||||
list="tick-marks"
|
list="tick-marks"
|
||||||
@input="goToFraction($event.target.value)"
|
@input="goToFraction($event.target.value)"
|
||||||
|
@pointerup="$el.blur()"
|
||||||
class="grow"
|
class="grow"
|
||||||
/>
|
/>
|
||||||
<datalist id="tick-marks"></datalist>
|
<datalist id="tick-marks"></datalist>
|
||||||
@@ -392,6 +393,7 @@ templ ReaderChrome(metadata ReaderMetadata) {
|
|||||||
max="1"
|
max="1"
|
||||||
step="any"
|
step="any"
|
||||||
@input="goToFraction($event.target.value)"
|
@input="goToFraction($event.target.value)"
|
||||||
|
@pointerup="$el.blur()"
|
||||||
class="grow min-w-[3rem]"
|
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">
|
<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"
|
max="1"
|
||||||
step="any"
|
step="any"
|
||||||
@input="goToFraction($event.target.value)"
|
@input="goToFraction($event.target.value)"
|
||||||
|
@pointerup="$el.blur()"
|
||||||
class="grow min-w-[3rem]"
|
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">
|
<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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -230,7 +230,7 @@ func drawerHeader(title string) templ.Component {
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -17,8 +17,15 @@ type User struct {
|
|||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
Token string
|
Token string
|
||||||
Timezone 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 {
|
type PageData struct {
|
||||||
User User
|
User User
|
||||||
CurrentPage string
|
CurrentPage string
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"module": "ES2020",
|
"module": "ES2020",
|
||||||
"lib": ["ES2020", "DOM"],
|
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||||
"outDir": "./web/static",
|
"outDir": "./web/static",
|
||||||
"rootDir": "./web/src",
|
"rootDir": "./web/src",
|
||||||
"moduleResolution": "bundler",
|
"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 { cpSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
@@ -6,8 +6,7 @@ const pdfjsAssets = () => ({
|
|||||||
name: "pdfjs-assets",
|
name: "pdfjs-assets",
|
||||||
closeBundle() {
|
closeBundle() {
|
||||||
const outDir = "web/static/vendor/pdfjs";
|
const outDir = "web/static/vendor/pdfjs";
|
||||||
const srcDir =
|
const srcDir = "node_modules/@bookhoard/foliate-js/vendor/pdfjs";
|
||||||
"node_modules/@bookhoard/foliate-js/vendor/pdfjs";
|
|
||||||
mkdirSync(outDir, { recursive: true });
|
mkdirSync(outDir, { recursive: true });
|
||||||
cpSync(join(srcDir, "standard_fonts"), join(outDir, "standard_fonts"), {
|
cpSync(join(srcDir, "standard_fonts"), join(outDir, "standard_fonts"), {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
@@ -19,7 +18,14 @@ const pdfjsAssets = () => ({
|
|||||||
// emptyOutDir must stay false (web/static also holds tracked assets),
|
// emptyOutDir must stay false (web/static also holds tracked assets),
|
||||||
// so hashed chunks from previous builds would otherwise accumulate
|
// so hashed chunks from previous builds would otherwise accumulate
|
||||||
// forever and leak into Docker images via the build context. Remove
|
// 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 cleanStaleChunks = () => {
|
||||||
const produced = new Set<string>();
|
const produced = new Set<string>();
|
||||||
return {
|
return {
|
||||||
@@ -28,8 +34,9 @@ const cleanStaleChunks = () => {
|
|||||||
for (const fileName of Object.keys(bundle)) produced.add(fileName);
|
for (const fileName of Object.keys(bundle)) produced.add(fileName);
|
||||||
},
|
},
|
||||||
closeBundle() {
|
closeBundle() {
|
||||||
|
if (produced.size === 0) return;
|
||||||
const outDir = "web/static";
|
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)) {
|
for (const f of readdirSync(outDir)) {
|
||||||
if (chunkRe.test(f) && !produced.has(f)) {
|
if (chunkRe.test(f) && !produced.has(f)) {
|
||||||
rmSync(join(outDir, f));
|
rmSync(join(outDir, f));
|
||||||
@@ -40,6 +47,15 @@ const cleanStaleChunks = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default defineConfig({
|
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: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": "/web/src",
|
"@": "/web/src",
|
||||||
@@ -63,6 +79,6 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
target: "esnext",
|
target: "esnext",
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
minify: "esbuild",
|
minify: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
+16
-53
@@ -57,13 +57,8 @@ async function scanAllLibraries(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showScanProgress(
|
function showScanProgress(jobIds: string[], libraryNames: Record<string, string>): void {
|
||||||
jobIds: string[],
|
const container = document.getElementById("scan-progress-container") as HTMLElement;
|
||||||
libraryNames: Record<string, string>,
|
|
||||||
): void {
|
|
||||||
const container = document.getElementById(
|
|
||||||
"scan-progress-container",
|
|
||||||
) as HTMLElement;
|
|
||||||
const list = document.getElementById("library-progress-list") as HTMLElement;
|
const list = document.getElementById("library-progress-list") as HTMLElement;
|
||||||
|
|
||||||
if (!container || !list) return;
|
if (!container || !list) return;
|
||||||
@@ -113,10 +108,7 @@ function showScanProgress(
|
|||||||
|
|
||||||
let scanPollInterval: ReturnType<typeof setInterval> | undefined = undefined;
|
let scanPollInterval: ReturnType<typeof setInterval> | undefined = undefined;
|
||||||
|
|
||||||
function pollScanProgress(
|
function pollScanProgress(jobIds: string[], _libraryNames: Record<string, string>): void {
|
||||||
jobIds: string[],
|
|
||||||
_libraryNames: Record<string, string>,
|
|
||||||
): void {
|
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const cancelledJobs = new Set<string>();
|
const cancelledJobs = new Set<string>();
|
||||||
@@ -168,14 +160,9 @@ function pollScanProgress(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// status.progress arrives 0-1 from the server; render as percent.
|
// status.progress arrives 0-1 from the server; render as percent.
|
||||||
const overallProgress =
|
const overallProgress = progressJobs > 0 ? Math.round((totalProgress / progressJobs) * 100) : 0;
|
||||||
progressJobs > 0 ? Math.round((totalProgress / progressJobs) * 100) : 0;
|
const progressBar = document.getElementById("scan-progress-bar") as HTMLElement;
|
||||||
const progressBar = document.getElementById(
|
const progressText = document.getElementById("scan-progress-text") as HTMLElement;
|
||||||
"scan-progress-bar",
|
|
||||||
) as HTMLElement;
|
|
||||||
const progressText = document.getElementById(
|
|
||||||
"scan-progress-text",
|
|
||||||
) as HTMLElement;
|
|
||||||
const statusText = document.getElementById("scan-status") as HTMLElement;
|
const statusText = document.getElementById("scan-status") as HTMLElement;
|
||||||
|
|
||||||
if (progressBar) progressBar.style.width = overallProgress + "%";
|
if (progressBar) progressBar.style.width = overallProgress + "%";
|
||||||
@@ -241,9 +228,7 @@ async function cancelScan(jobId: string): Promise<void> {
|
|||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
const btn = document.querySelector<HTMLButtonElement>(
|
const btn = document.querySelector<HTMLButtonElement>(`[data-cancel-job="${jobId}"]`);
|
||||||
`[data-cancel-job="${jobId}"]`,
|
|
||||||
);
|
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = "Cancelling...";
|
btn.textContent = "Cancelling...";
|
||||||
@@ -260,10 +245,7 @@ async function cancelScan(jobId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
showToast("Scan cancellation requested", "success");
|
showToast("Scan cancellation requested", "success");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(
|
showToast(e instanceof Error ? e.message : "Failed to cancel scan", "error");
|
||||||
e instanceof Error ? e.message : "Failed to cancel scan",
|
|
||||||
"error",
|
|
||||||
);
|
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = "Cancel";
|
btn.textContent = "Cancel";
|
||||||
@@ -291,10 +273,7 @@ async function cancelAllScans(): Promise<void> {
|
|||||||
|
|
||||||
await Promise.allSettled(jobs.map((job) => cancelScan(job.job_id)));
|
await Promise.allSettled(jobs.map((job) => cancelScan(job.job_id)));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(
|
showToast(e instanceof Error ? e.message : "Failed to cancel scans", "error");
|
||||||
e instanceof Error ? e.message : "Failed to cancel scans",
|
|
||||||
"error",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,9 +340,7 @@ function showScanResults(
|
|||||||
cancelled: number,
|
cancelled: number,
|
||||||
): void {
|
): void {
|
||||||
const resultsDiv = document.getElementById("scan-results") as HTMLElement;
|
const resultsDiv = document.getElementById("scan-results") as HTMLElement;
|
||||||
const contentDiv = document.getElementById(
|
const contentDiv = document.getElementById("scan-results-content") as HTMLElement;
|
||||||
"scan-results-content",
|
|
||||||
) as HTMLElement;
|
|
||||||
|
|
||||||
if (!resultsDiv || !contentDiv) return;
|
if (!resultsDiv || !contentDiv) return;
|
||||||
|
|
||||||
@@ -381,16 +358,12 @@ function showScanResults(
|
|||||||
const statusText = document.getElementById("scan-status") as HTMLElement;
|
const statusText = document.getElementById("scan-status") as HTMLElement;
|
||||||
if (statusText) {
|
if (statusText) {
|
||||||
statusText.textContent =
|
statusText.textContent =
|
||||||
cancelled > 0
|
cancelled > 0 ? "Scan finished (some scans were cancelled)" : "Scan complete!";
|
||||||
? "Scan finished (some scans were cancelled)"
|
|
||||||
: "Scan complete!";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideScanProgress(): void {
|
function hideScanProgress(): void {
|
||||||
const container = document.getElementById(
|
const container = document.getElementById("scan-progress-container") as HTMLElement;
|
||||||
"scan-progress-container",
|
|
||||||
) as HTMLElement;
|
|
||||||
if (container) container.classList.add("hidden");
|
if (container) container.classList.add("hidden");
|
||||||
setCancelAllVisible(false);
|
setCancelAllVisible(false);
|
||||||
}
|
}
|
||||||
@@ -447,16 +420,10 @@ async function purgeArchivedItems(): Promise<void> {
|
|||||||
throw new Error(err.error || "Failed to purge archived items");
|
throw new Error(err.error || "Failed to purge archived items");
|
||||||
}
|
}
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
showToast(
|
showToast(`Purged ${data.purged} archived item${data.purged === 1 ? "" : "s"}`, "success");
|
||||||
`Purged ${data.purged} archived item${data.purged === 1 ? "" : "s"}`,
|
|
||||||
"success",
|
|
||||||
);
|
|
||||||
setTimeout(() => window.location.reload(), 700);
|
setTimeout(() => window.location.reload(), 700);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(
|
showToast(e instanceof Error ? e.message : "Failed to purge archived items", "error");
|
||||||
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 || ""));
|
el.addEventListener("click", () => unarchiveItem(el.dataset.unarchive || ""));
|
||||||
});
|
});
|
||||||
document.querySelectorAll<HTMLElement>("[data-delete-archived]").forEach((el) => {
|
document.querySelectorAll<HTMLElement>("[data-delete-archived]").forEach((el) => {
|
||||||
el.addEventListener("click", () =>
|
el.addEventListener("click", () => deleteArchivedItem(el.dataset.deleteArchived || ""));
|
||||||
deleteArchivedItem(el.dataset.deleteArchived || ""),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
document.querySelectorAll<HTMLElement>("[data-merge-source]").forEach((el) => {
|
document.querySelectorAll<HTMLElement>("[data-merge-source]").forEach((el) => {
|
||||||
el.addEventListener("click", () =>
|
el.addEventListener("click", () =>
|
||||||
@@ -631,9 +596,7 @@ function bindDeleteLibraryModalClose(): void {
|
|||||||
?.addEventListener("htmx:afterRequest", function (evt) {
|
?.addEventListener("htmx:afterRequest", function (evt) {
|
||||||
const detail = (evt as CustomEvent).detail as { successful?: boolean };
|
const detail = (evt as CustomEvent).detail as { successful?: boolean };
|
||||||
if (detail.successful) {
|
if (detail.successful) {
|
||||||
document
|
document.getElementById("delete-library-modal")?.classList.add("hidden");
|
||||||
.getElementById("delete-library-modal")
|
|
||||||
?.classList.add("hidden");
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-4
@@ -1,12 +1,11 @@
|
|||||||
import Alpine from "alpinejs";
|
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 {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
Alpine: typeof Alpine;
|
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(). */
|
/** 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();
|
const raw = (position ?? "").trim();
|
||||||
if (raw) {
|
if (raw) {
|
||||||
// PDF/comic anchors: "page:N"
|
// 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).
|
// 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) {
|
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 cut;
|
||||||
}
|
}
|
||||||
return "Location";
|
return "Location";
|
||||||
@@ -214,10 +220,7 @@ function buildAnnotationRows(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtmlForRich(text: string): string {
|
function escapeHtmlForRich(text: string): string {
|
||||||
return text
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The export item text — identical in single and multi modes. */
|
/** 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 responseStatus = document.getElementById("response-status");
|
||||||
const responseTime = document.getElementById("response-time");
|
const responseTime = document.getElementById("response-time");
|
||||||
const apiResponse = document.querySelector(".api-response");
|
const apiResponse = document.querySelector(".api-response");
|
||||||
const requestBody = document.getElementById(
|
const requestBody = document.getElementById("request-body") as HTMLTextAreaElement;
|
||||||
"request-body",
|
|
||||||
) as HTMLTextAreaElement;
|
|
||||||
const tryItOut = document.getElementById("try-it-out");
|
const tryItOut = document.getElementById("try-it-out");
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -58,12 +56,8 @@ function showDocMode(mode: "mock" | "real"): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function tryDocEndpoint(): Promise<void> {
|
async function tryDocEndpoint(): Promise<void> {
|
||||||
const methodSelect = document.getElementById(
|
const methodSelect = document.getElementById("http-method") as HTMLSelectElement;
|
||||||
"http-method",
|
const requestBody = document.getElementById("request-body") as HTMLTextAreaElement;
|
||||||
) as HTMLSelectElement;
|
|
||||||
const requestBody = document.getElementById(
|
|
||||||
"request-body",
|
|
||||||
) as HTMLTextAreaElement;
|
|
||||||
const responseBody = document.getElementById("response-body");
|
const responseBody = document.getElementById("response-body");
|
||||||
const responseStatus = document.getElementById("response-status");
|
const responseStatus = document.getElementById("response-status");
|
||||||
const responseTime = document.getElementById("response-time");
|
const responseTime = document.getElementById("response-time");
|
||||||
@@ -113,9 +107,7 @@ async function tryDocEndpoint(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function copyDocRequest(): void {
|
function copyDocRequest(): void {
|
||||||
const requestBody = document.getElementById(
|
const requestBody = document.getElementById("request-body") as HTMLTextAreaElement;
|
||||||
"request-body",
|
|
||||||
) as HTMLTextAreaElement;
|
|
||||||
if (requestBody) {
|
if (requestBody) {
|
||||||
navigator.clipboard.writeText(requestBody.value);
|
navigator.clipboard.writeText(requestBody.value);
|
||||||
}
|
}
|
||||||
@@ -129,12 +121,8 @@ function copyDocResponse(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function generateDocCURL(): void {
|
function generateDocCURL(): void {
|
||||||
const methodSelect = document.getElementById(
|
const methodSelect = document.getElementById("http-method") as HTMLSelectElement;
|
||||||
"http-method",
|
const requestBody = document.getElementById("request-body") as HTMLTextAreaElement;
|
||||||
) as HTMLSelectElement;
|
|
||||||
const requestBody = document.getElementById(
|
|
||||||
"request-body",
|
|
||||||
) as HTMLTextAreaElement;
|
|
||||||
|
|
||||||
if (!methodSelect || !requestBody) return;
|
if (!methodSelect || !requestBody) return;
|
||||||
|
|
||||||
|
|||||||
+6
-18
@@ -10,13 +10,9 @@ interface ApiExplorerRequest {
|
|||||||
const requestHistory: ApiExplorerRequest[] = [];
|
const requestHistory: ApiExplorerRequest[] = [];
|
||||||
|
|
||||||
function sendApiRequest(): void {
|
function sendApiRequest(): void {
|
||||||
const method =
|
const method = (document.getElementById("api-method") as HTMLSelectElement)?.value || "GET";
|
||||||
(document.getElementById("api-method") as HTMLSelectElement)?.value ||
|
const endpoint = (document.getElementById("api-endpoint") as HTMLInputElement)?.value || "";
|
||||||
"GET";
|
const bodyText = (document.getElementById("api-body") as HTMLTextAreaElement)?.value || "";
|
||||||
const endpoint =
|
|
||||||
(document.getElementById("api-endpoint") as HTMLInputElement)?.value || "";
|
|
||||||
const bodyText =
|
|
||||||
(document.getElementById("api-body") as HTMLTextAreaElement)?.value || "";
|
|
||||||
|
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
|
|
||||||
@@ -64,11 +60,7 @@ function sendApiRequest(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayResponse(
|
function displayResponse(response: Response, data: unknown, duration: number): void {
|
||||||
response: Response,
|
|
||||||
data: unknown,
|
|
||||||
duration: number,
|
|
||||||
): void {
|
|
||||||
const container = document.getElementById("api-response");
|
const container = document.getElementById("api-response");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
@@ -150,12 +142,8 @@ function loadFromHistory(index: number): void {
|
|||||||
const request = requestHistory[index];
|
const request = requestHistory[index];
|
||||||
if (!request) return;
|
if (!request) return;
|
||||||
|
|
||||||
const methodSelect = document.getElementById(
|
const methodSelect = document.getElementById("api-method") as HTMLSelectElement;
|
||||||
"api-method",
|
const endpointInput = document.getElementById("api-endpoint") as HTMLInputElement;
|
||||||
) as HTMLSelectElement;
|
|
||||||
const endpointInput = document.getElementById(
|
|
||||||
"api-endpoint",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const bodyInput = document.getElementById("api-body") as HTMLTextAreaElement;
|
const bodyInput = document.getElementById("api-body") as HTMLTextAreaElement;
|
||||||
|
|
||||||
if (methodSelect) methodSelect.value = request.method;
|
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>(
|
async function apiDelete<T extends object>(url: string, data?: T): Promise<Response> {
|
||||||
url: string,
|
|
||||||
data?: T,
|
|
||||||
): Promise<Response> {
|
|
||||||
return fetch(`/api${url}`, {
|
return fetch(`/api${url}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -64,9 +61,7 @@ async function apiPatch(url: string, data?: unknown): Promise<Response> {
|
|||||||
|
|
||||||
async function handleResponse<T>(response: Response): Promise<T> {
|
async function handleResponse<T>(response: Response): Promise<T> {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response
|
const errorData = await response.json().catch(() => ({ error: "Unknown error" }));
|
||||||
.json()
|
|
||||||
.catch(() => ({ error: "Unknown error" }));
|
|
||||||
throw new Error(errorData.error || `HTTP ${response.status}`);
|
throw new Error(errorData.error || `HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
return response.json();
|
return response.json();
|
||||||
@@ -74,17 +69,14 @@ async function handleResponse<T>(response: Response): Promise<T> {
|
|||||||
|
|
||||||
async function handleVoidResponse(response: Response): Promise<void> {
|
async function handleVoidResponse(response: Response): Promise<void> {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response
|
const errorData = await response.json().catch(() => ({ error: "Unknown error" }));
|
||||||
.json()
|
|
||||||
.catch(() => ({ error: "Unknown error" }));
|
|
||||||
throw new Error(errorData.error || `HTTP ${response.status}`);
|
throw new Error(errorData.error || `HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleError(error: unknown, context: string): void {
|
function handleError(error: unknown, context: string): void {
|
||||||
console.error(`${context}:`, error);
|
console.error(`${context}:`, error);
|
||||||
const message =
|
const message = error instanceof Error ? error.message : "An unexpected error occurred";
|
||||||
error instanceof Error ? error.message : "An unexpected error occurred";
|
|
||||||
showToast(message, "error");
|
showToast(message, "error");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,16 +98,11 @@ async function updateReadingProgress(
|
|||||||
mediaItemId: string,
|
mediaItemId: string,
|
||||||
progress: ReadingProgress,
|
progress: ReadingProgress,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const response = await apiPut(
|
const response = await apiPut(`/media-items/${mediaItemId}/progress`, progress);
|
||||||
`/media-items/${mediaItemId}/progress`,
|
|
||||||
progress,
|
|
||||||
);
|
|
||||||
await handleVoidResponse(response);
|
await handleVoidResponse(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getReadingProgress(
|
async function getReadingProgress(mediaItemId: string): Promise<ReadingProgress | null> {
|
||||||
mediaItemId: string,
|
|
||||||
): Promise<ReadingProgress | null> {
|
|
||||||
const response = await apiGet(`/media-items/${mediaItemId}/progress`);
|
const response = await apiGet(`/media-items/${mediaItemId}/progress`);
|
||||||
|
|
||||||
if (response.status === 404) {
|
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,262 @@
|
|||||||
|
// 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)" },
|
||||||
|
{
|
||||||
|
label: "End time",
|
||||||
|
description: "Click to cycle the −label: total → book left → chapter left",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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,164 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SLEEP_CHOICES,
|
||||||
|
SLEEP_OFF,
|
||||||
|
armClockTime,
|
||||||
|
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, followUpMinutes: null }, 0),
|
||||||
|
).toBe("Fading");
|
||||||
|
expect(sleepLabel(armMinutes(1000, 5), 1000 + 65_000)).toBe("3:55");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("armClockTime (§17 stop-at-clock)", () => {
|
||||||
|
it("arms a minutes-state deadline at the next occurrence of HH:MM", () => {
|
||||||
|
// 10:00 local → 23:00 same day.
|
||||||
|
const tenAm = new Date();
|
||||||
|
tenAm.setHours(10, 0, 0, 0);
|
||||||
|
const state = armClockTime(tenAm.getTime(), "23:00");
|
||||||
|
expect(state?.mode).toBe("minutes");
|
||||||
|
const expected = new Date(tenAm);
|
||||||
|
expected.setHours(23, 0, 0, 0);
|
||||||
|
expect(state?.endsAtMs).toBe(expected.getTime());
|
||||||
|
// Label is the ordinary countdown machinery (12h59m → 779:00).
|
||||||
|
expect(sleepLabel(state!, tenAm.getTime() + 60_000)).toBe("779:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps to tomorrow when the time already passed", () => {
|
||||||
|
const noon = new Date();
|
||||||
|
noon.setHours(12, 0, 0, 0);
|
||||||
|
const state = armClockTime(noon.getTime(), "09:15");
|
||||||
|
const expected = new Date(noon);
|
||||||
|
expected.setDate(expected.getDate() + 1);
|
||||||
|
expected.setHours(9, 15, 0, 0);
|
||||||
|
expect(state?.endsAtMs).toBe(expected.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unparseable and out-of-range input", () => {
|
||||||
|
const now = Date.now();
|
||||||
|
expect(armClockTime(now, "nope")).toBeNull();
|
||||||
|
expect(armClockTime(now, "24:00")).toBeNull();
|
||||||
|
expect(armClockTime(now, "10:61")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("§17 chain: end-of-chapter with a follow-up countdown", () => {
|
||||||
|
it("arms the follow-up on the state; the chapter fire contract is unchanged", () => {
|
||||||
|
const state = armEndOfChapter(2, 15);
|
||||||
|
expect(state.followUpMinutes).toBe(15);
|
||||||
|
// The sync contract (any chapter change fires) is the same — the
|
||||||
|
// component converts to armMinutes instead of fading.
|
||||||
|
expect(sleepChapterFired(state, 3)).toBe(true);
|
||||||
|
expect(sleepChapterFired(state, 1)).toBe(true);
|
||||||
|
expect(sleepChapterFired(state, 2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels the chapter phase with the follow-up length", () => {
|
||||||
|
expect(sleepLabel(armEndOfChapter(2, 15), 0)).toBe("Ch + 15m");
|
||||||
|
expect(sleepLabel(armEndOfChapter(2), 0)).toBe("End of chapter");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers the chain row in the dropdown vocabulary", () => {
|
||||||
|
const chain = SLEEP_CHOICES.find((c) => c.key === "eoch15");
|
||||||
|
expect(chain).toMatchObject({ mode: "end-of-chapter", minutes: 15, chain: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SLEEP_CHOICES", () => {
|
||||||
|
it("is the agreed option list: off, 5/10/15/30/45/60, end-of-chapter, chain", () => {
|
||||||
|
expect(SLEEP_CHOICES.map((c) => c.key)).toEqual([
|
||||||
|
"off",
|
||||||
|
"5",
|
||||||
|
"10",
|
||||||
|
"15",
|
||||||
|
"30",
|
||||||
|
"45",
|
||||||
|
"60",
|
||||||
|
"eoch",
|
||||||
|
"eoch15",
|
||||||
|
]);
|
||||||
|
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,141 @@
|
|||||||
|
// 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;
|
||||||
|
// §17 chain: when set on an end-of-chapter arm, leaving the chapter
|
||||||
|
// does NOT fade — it starts a minutes countdown of this length
|
||||||
|
// ("end of chapter, then 15 min").
|
||||||
|
followUpMinutes: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SLEEP_OFF: SleepState = {
|
||||||
|
mode: "off",
|
||||||
|
endsAtMs: null,
|
||||||
|
chapterIndex: null,
|
||||||
|
followUpMinutes: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function armMinutes(nowMs: number, minutes: number): SleepState {
|
||||||
|
return {
|
||||||
|
mode: "minutes",
|
||||||
|
endsAtMs: nowMs + minutes * 60_000,
|
||||||
|
chapterIndex: null,
|
||||||
|
followUpMinutes: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function armEndOfChapter(
|
||||||
|
chapterIndex: number,
|
||||||
|
followUpMinutes: number | null = null,
|
||||||
|
): SleepState {
|
||||||
|
return { mode: "end-of-chapter", endsAtMs: null, chapterIndex, followUpMinutes };
|
||||||
|
}
|
||||||
|
|
||||||
|
// armClockTime (§17): "stop at 23:00" — resolves HH:MM to its NEXT
|
||||||
|
// occurrence (today if still ahead, otherwise tomorrow) and arms the
|
||||||
|
// ordinary minutes machinery against that epoch deadline. Returns null
|
||||||
|
// for unparseable strings; the caller keeps the old state then.
|
||||||
|
export function armClockTime(nowMs: number, hhmm: string): SleepState | null {
|
||||||
|
const m = /^(\d{1,2}):(\d{2})$/.exec(hhmm.trim());
|
||||||
|
if (!m) return null;
|
||||||
|
const h = Number(m[1]);
|
||||||
|
const min = Number(m[2]);
|
||||||
|
if (h > 23 || min > 59) return null;
|
||||||
|
const target = new Date(nowMs);
|
||||||
|
target.setHours(h, min, 0, 0);
|
||||||
|
if (target.getTime() <= nowMs) target.setDate(target.getDate() + 1);
|
||||||
|
return { mode: "minutes", endsAtMs: target.getTime(), chapterIndex: null, followUpMinutes: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 state.followUpMinutes !== null ? `Ch + ${state.followUpMinutes}m` : "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;
|
||||||
|
// chain: this end-of-chapter row arms a follow-up minutes countdown
|
||||||
|
// ("end of chapter, then N min") instead of fading straight away.
|
||||||
|
chain?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
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" },
|
||||||
|
{
|
||||||
|
key: "eoch15",
|
||||||
|
label: "End of chapter, then 15 min",
|
||||||
|
mode: "end-of-chapter",
|
||||||
|
minutes: 15,
|
||||||
|
chain: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
+809
-38
File diff suppressed because it is too large.
Load diff
+45
-89
@@ -9,7 +9,6 @@ import {
|
|||||||
triggerDownload,
|
triggerDownload,
|
||||||
bannerBookTitle,
|
bannerBookTitle,
|
||||||
type AnnotationRow,
|
type AnnotationRow,
|
||||||
type AnnotationSource,
|
|
||||||
} from "./annotations-banner";
|
} from "./annotations-banner";
|
||||||
|
|
||||||
function getMediaId(): string {
|
function getMediaId(): string {
|
||||||
@@ -69,8 +68,8 @@ function collectFormData(editorTags: string[]): Record<string, unknown> {
|
|||||||
if (el.type === "number") {
|
if (el.type === "number") {
|
||||||
const val = parseFloat(el.value);
|
const val = parseFloat(el.value);
|
||||||
data[el.name] = isNaN(val) ? 0 : val;
|
data[el.name] = isNaN(val) ? 0 : val;
|
||||||
} else if (el.type === "checkbox") {
|
} else if (el instanceof HTMLInputElement && el.type === "checkbox") {
|
||||||
data[el.name] = (el as HTMLInputElement).checked;
|
data[el.name] = el.checked;
|
||||||
} else {
|
} else {
|
||||||
data[el.name] = el.value;
|
data[el.name] = el.value;
|
||||||
}
|
}
|
||||||
@@ -169,9 +168,7 @@ Alpine.data("bookDetail", () => {
|
|||||||
const coverImg = document.querySelector(
|
const coverImg = document.querySelector(
|
||||||
"#metadata-editor-modal img#metadata-cover-preview",
|
"#metadata-editor-modal img#metadata-cover-preview",
|
||||||
) as HTMLImageElement | null;
|
) as HTMLImageElement | null;
|
||||||
const hasCover =
|
const hasCover = coverImg?.src && !coverImg.src.includes("placeholder-book.svg");
|
||||||
coverImg?.src &&
|
|
||||||
!coverImg.src.includes("placeholder-book.svg");
|
|
||||||
|
|
||||||
const initialTags: string[] = [];
|
const initialTags: string[] = [];
|
||||||
const tagBadges = document.querySelectorAll("#metadata-editor-modal [data-editor-tag]");
|
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.
|
// HTML — no \n\n separators in it) and came up empty every time.
|
||||||
this.bannerEditRow = row;
|
this.bannerEditRow = row;
|
||||||
this.bannerEditText = String(
|
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;
|
this.bannerEditOpen = true;
|
||||||
},
|
},
|
||||||
@@ -308,7 +305,7 @@ Alpine.data("bookDetail", () => {
|
|||||||
// Full-replace PUT: title + position ride along from the row's
|
// Full-replace PUT: title + position ride along from the row's
|
||||||
// stored values; the note is the editable part.
|
// stored values; the note is the editable part.
|
||||||
const bmId = row.key.slice("bookmark-".length);
|
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}`, {
|
const resp = await fetch(`/api/media-items/${mediaId}/bookmarks/${bmId}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { Authorization: getAuthHeader(), "Content-Type": "application/json" },
|
headers: { Authorization: getAuthHeader(), "Content-Type": "application/json" },
|
||||||
@@ -323,8 +320,10 @@ Alpine.data("bookDetail", () => {
|
|||||||
const noteId = row.key.slice("note-".length);
|
const noteId = row.key.slice("note-".length);
|
||||||
// The stored position rides along: the server's PUT hard-sets the
|
// The stored position rides along: the server's PUT hard-sets the
|
||||||
// position column.
|
// position column.
|
||||||
const position = (row.loc || "").startsWith("cfi:") || (row.loc || "").startsWith("page:")
|
const position =
|
||||||
? row.loc : undefined;
|
(row.loc || "").startsWith("cfi:") || (row.loc || "").startsWith("page:")
|
||||||
|
? row.loc
|
||||||
|
: undefined;
|
||||||
const resp = await fetch(`/api/media-items/${mediaId}/notes/${noteId}`, {
|
const resp = await fetch(`/api/media-items/${mediaId}/notes/${noteId}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { Authorization: getAuthHeader(), "Content-Type": "application/json" },
|
headers: { Authorization: getAuthHeader(), "Content-Type": "application/json" },
|
||||||
@@ -335,7 +334,7 @@ Alpine.data("bookDetail", () => {
|
|||||||
// Highlight-attached note: the full-replace highlight PUT; empty
|
// Highlight-attached note: the full-replace highlight PUT; empty
|
||||||
// content clears the note (allowed by the validator).
|
// content clears the note (allowed by the validator).
|
||||||
const hlId = row.key.slice("hl-".length);
|
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;
|
if (content === String(src?.source ?? "")) return;
|
||||||
// The banner only ever changes note_text: read the highlight list,
|
// The banner only ever changes note_text: read the highlight list,
|
||||||
// then full-replace the row with the new note text.
|
// then full-replace the row with the new note text.
|
||||||
@@ -352,7 +351,8 @@ Alpine.data("bookDetail", () => {
|
|||||||
headers: { Authorization: getAuthHeader(), "Content-Type": "application/json" },
|
headers: { Authorization: getAuthHeader(), "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
selection_text: typeof hl.selection_text === "string" ? hl.selection_text : "",
|
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 : "",
|
end_position: typeof (hl.end_position ?? "") === "string" ? hl.end_position : "",
|
||||||
epubcfi_start: hl.epubcfi_start ?? "",
|
epubcfi_start: hl.epubcfi_start ?? "",
|
||||||
epubcfi_end: hl.epubcfi_end ?? "",
|
epubcfi_end: hl.epubcfi_end ?? "",
|
||||||
@@ -368,7 +368,7 @@ Alpine.data("bookDetail", () => {
|
|||||||
}
|
}
|
||||||
await this.fetchBannerAnnotations();
|
await this.fetchBannerAnnotations();
|
||||||
showToast("Note saved", "success");
|
showToast("Note saved", "success");
|
||||||
} catch (e) {
|
} catch {
|
||||||
showToast("Could not save the note", "error");
|
showToast("Could not save the note", "error");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -385,7 +385,7 @@ Alpine.data("bookDetail", () => {
|
|||||||
// Tombstoned server-side: restorable from the Deleted modal.
|
// Tombstoned server-side: restorable from the Deleted modal.
|
||||||
showToast("Note deleted — restorable from Deleted", "success");
|
showToast("Note deleted — restorable from Deleted", "success");
|
||||||
await this.fetchBannerAnnotations();
|
await this.fetchBannerAnnotations();
|
||||||
} catch (_e) {
|
} catch {
|
||||||
showToast("Could not delete the note", "error");
|
showToast("Could not delete the note", "error");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -402,7 +402,7 @@ Alpine.data("bookDetail", () => {
|
|||||||
// Tombstoned server-side: restorable from the Deleted modal.
|
// Tombstoned server-side: restorable from the Deleted modal.
|
||||||
showToast("Bookmark deleted — restorable from Deleted", "success");
|
showToast("Bookmark deleted — restorable from Deleted", "success");
|
||||||
await this.fetchBannerAnnotations();
|
await this.fetchBannerAnnotations();
|
||||||
} catch (_e) {
|
} catch {
|
||||||
showToast("Could not delete the bookmark", "error");
|
showToast("Could not delete the bookmark", "error");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -419,7 +419,7 @@ Alpine.data("bookDetail", () => {
|
|||||||
// Tombstoned server-side: restorable from the Deleted modal.
|
// Tombstoned server-side: restorable from the Deleted modal.
|
||||||
showToast("Highlight deleted — restorable from Deleted", "success");
|
showToast("Highlight deleted — restorable from Deleted", "success");
|
||||||
await this.fetchBannerAnnotations();
|
await this.fetchBannerAnnotations();
|
||||||
} catch (_e) {
|
} catch {
|
||||||
showToast("Could not delete the highlight", "error");
|
showToast("Could not delete the highlight", "error");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -454,7 +454,7 @@ Alpine.data("bookDetail", () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
showToast("Annotations exported", "success");
|
showToast("Annotations exported", "success");
|
||||||
} catch (_e) {
|
} catch {
|
||||||
showToast("Export failed", "error");
|
showToast("Export failed", "error");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -479,10 +479,7 @@ Alpine.data("bookDetail", () => {
|
|||||||
showToast("Conflict resolved successfully", "success");
|
showToast("Conflict resolved successfully", "success");
|
||||||
setTimeout(() => window.location.reload(), 500);
|
setTimeout(() => window.location.reload(), 500);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(
|
showToast(e instanceof Error ? e.message : "Failed to resolve conflict", "error");
|
||||||
e instanceof Error ? e.message : "Failed to resolve conflict",
|
|
||||||
"error",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -507,10 +504,7 @@ Alpine.data("bookDetail", () => {
|
|||||||
showToast("Annotation restored", "success");
|
showToast("Annotation restored", "success");
|
||||||
setTimeout(() => window.location.reload(), 500);
|
setTimeout(() => window.location.reload(), 500);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(
|
showToast(e instanceof Error ? e.message : "Failed to restore annotation", "error");
|
||||||
e instanceof Error ? e.message : "Failed to restore annotation",
|
|
||||||
"error",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -538,10 +532,7 @@ Alpine.data("bookDetail", () => {
|
|||||||
showToast("Annotation permanently deleted", "success");
|
showToast("Annotation permanently deleted", "success");
|
||||||
setTimeout(() => window.location.reload(), 500);
|
setTimeout(() => window.location.reload(), 500);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(
|
showToast(e instanceof Error ? e.message : "Failed to delete annotation", "error");
|
||||||
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 conflictId = document.body.getAttribute("data-conflict-id");
|
||||||
const conflictWinner = document.body.getAttribute("data-conflict-winner");
|
const conflictWinner = document.body.getAttribute("data-conflict-winner");
|
||||||
this.conflictId = conflictId && conflictId !== "null" ? conflictId : "";
|
this.conflictId = conflictId && conflictId !== "null" ? conflictId : "";
|
||||||
this.conflictWinner =
|
this.conflictWinner = conflictWinner && conflictWinner !== "null" ? conflictWinner : "";
|
||||||
conflictWinner && conflictWinner !== "null" ? conflictWinner : "";
|
|
||||||
|
|
||||||
const link = document.getElementById("back-link");
|
const link = document.getElementById("back-link");
|
||||||
if (!link) return;
|
if (!link) return;
|
||||||
@@ -628,10 +618,7 @@ Alpine.data("bookDetail", () => {
|
|||||||
this.ratingHover = 0;
|
this.ratingHover = 0;
|
||||||
showToast("Rating saved", "success");
|
showToast("Rating saved", "success");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(
|
showToast(e instanceof Error ? e.message : "Failed to save rating", "error");
|
||||||
e instanceof Error ? e.message : "Failed to save rating",
|
|
||||||
"error",
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
this.ratingSaving = false;
|
this.ratingSaving = false;
|
||||||
}
|
}
|
||||||
@@ -654,10 +641,7 @@ Alpine.data("bookDetail", () => {
|
|||||||
this.ratingHover = 0;
|
this.ratingHover = 0;
|
||||||
showToast("Rating cleared", "success");
|
showToast("Rating cleared", "success");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(
|
showToast(e instanceof Error ? e.message : "Failed to clear rating", "error");
|
||||||
e instanceof Error ? e.message : "Failed to clear rating",
|
|
||||||
"error",
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
this.ratingSaving = false;
|
this.ratingSaving = false;
|
||||||
}
|
}
|
||||||
@@ -691,10 +675,7 @@ Alpine.data("bookDetail", () => {
|
|||||||
// Reload so the server-rendered card (or its absence) matches.
|
// Reload so the server-rendered card (or its absence) matches.
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(
|
showToast(e instanceof Error ? e.message : "Failed to save review", "error");
|
||||||
e instanceof Error ? e.message : "Failed to save review",
|
|
||||||
"error",
|
|
||||||
);
|
|
||||||
this.reviewSaving = false;
|
this.reviewSaving = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -714,10 +695,7 @@ Alpine.data("bookDetail", () => {
|
|||||||
}
|
}
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(
|
showToast(e instanceof Error ? e.message : "Failed to delete review", "error");
|
||||||
e instanceof Error ? e.message : "Failed to delete review",
|
|
||||||
"error",
|
|
||||||
);
|
|
||||||
this.reviewSaving = false;
|
this.reviewSaving = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -732,23 +710,18 @@ Alpine.data("bookDetail", () => {
|
|||||||
// conflict. The winner only needs to be a valid source key; the final
|
// conflict. The winner only needs to be a valid source key; the final
|
||||||
// read/unread state is set by the progress write that follows.
|
// read/unread state is set by the progress write that follows.
|
||||||
if (this.conflictId && this.conflictWinner) {
|
if (this.conflictId && this.conflictWinner) {
|
||||||
const cr = await fetch(
|
const cr = await fetch(`/api/conflicts/${this.conflictId}/resolve`, {
|
||||||
`/api/conflicts/${this.conflictId}/resolve`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: getAuthHeader(),
|
Authorization: getAuthHeader(),
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ winner: this.conflictWinner }),
|
body: JSON.stringify({ winner: this.conflictWinner }),
|
||||||
},
|
});
|
||||||
);
|
|
||||||
// 400 means it was already resolved - treat as no conflict.
|
// 400 means it was already resolved - treat as no conflict.
|
||||||
if (!cr.ok && cr.status !== 400) {
|
if (!cr.ok && cr.status !== 400) {
|
||||||
const err = await cr.json().catch(() => ({}));
|
const err = await cr.json().catch(() => ({}));
|
||||||
throw new Error(
|
throw new Error(err.error || err.message || "Failed to clear sync conflict");
|
||||||
err.error || err.message || "Failed to clear sync conflict",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -780,16 +753,10 @@ Alpine.data("bookDetail", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast(
|
showToast(read ? "Marked as read" : "Marked as unread", "success");
|
||||||
read ? "Marked as read" : "Marked as unread",
|
|
||||||
"success",
|
|
||||||
);
|
|
||||||
setTimeout(() => window.location.reload(), 500);
|
setTimeout(() => window.location.reload(), 500);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(
|
showToast(e instanceof Error ? e.message : "Failed to update read state", "error");
|
||||||
e instanceof Error ? e.message : "Failed to update read state",
|
|
||||||
"error",
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
this.readSaving = false;
|
this.readSaving = false;
|
||||||
}
|
}
|
||||||
@@ -805,9 +772,7 @@ Alpine.data("bookDetail", () => {
|
|||||||
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
const preview = document.getElementById(
|
const preview = document.getElementById("metadata-cover-preview") as HTMLImageElement;
|
||||||
"metadata-cover-preview",
|
|
||||||
) as HTMLImageElement;
|
|
||||||
if (preview && e.target?.result) {
|
if (preview && e.target?.result) {
|
||||||
preview.src = e.target.result as string;
|
preview.src = e.target.result as string;
|
||||||
}
|
}
|
||||||
@@ -820,9 +785,7 @@ Alpine.data("bookDetail", () => {
|
|||||||
this.coverAction = "remove";
|
this.coverAction = "remove";
|
||||||
this.coverFile = null;
|
this.coverFile = null;
|
||||||
this.newCoverPreview = "";
|
this.newCoverPreview = "";
|
||||||
const preview = document.getElementById(
|
const preview = document.getElementById("metadata-cover-preview") as HTMLImageElement;
|
||||||
"metadata-cover-preview",
|
|
||||||
) as HTMLImageElement;
|
|
||||||
if (preview) {
|
if (preview) {
|
||||||
preview.src = "/static/placeholder-book.svg";
|
preview.src = "/static/placeholder-book.svg";
|
||||||
}
|
}
|
||||||
@@ -880,10 +843,7 @@ Alpine.data("bookDetail", () => {
|
|||||||
showToast("Metadata saved successfully", "success");
|
showToast("Metadata saved successfully", "success");
|
||||||
setTimeout(() => window.location.reload(), 500);
|
setTimeout(() => window.location.reload(), 500);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(
|
showToast(e instanceof Error ? e.message : "Failed to save metadata", "error");
|
||||||
e instanceof Error ? e.message : "Failed to save metadata",
|
|
||||||
"error",
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
this.saving = false;
|
this.saving = false;
|
||||||
}
|
}
|
||||||
@@ -905,10 +865,7 @@ Alpine.data("bookDetail", () => {
|
|||||||
showToast("Book rescanned successfully", "success");
|
showToast("Book rescanned successfully", "success");
|
||||||
setTimeout(() => window.location.reload(), 500);
|
setTimeout(() => window.location.reload(), 500);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(
|
showToast(e instanceof Error ? e.message : "Failed to rescan book", "error");
|
||||||
e instanceof Error ? e.message : "Failed to rescan book",
|
|
||||||
"error",
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
this.rescanning = false;
|
this.rescanning = false;
|
||||||
}
|
}
|
||||||
@@ -926,13 +883,10 @@ Alpine.data("bookDetail", () => {
|
|||||||
this.resetting = true;
|
this.resetting = true;
|
||||||
const mediaId = getMediaId();
|
const mediaId = getMediaId();
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(
|
const resp = await fetch(`/api/media-items/${mediaId}/rescan?reset_overrides=true`, {
|
||||||
`/api/media-items/${mediaId}/rescan?reset_overrides=true`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: getAuthHeader() },
|
headers: { Authorization: getAuthHeader() },
|
||||||
},
|
});
|
||||||
);
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const err = await resp.json().catch(() => ({}));
|
const err = await resp.json().catch(() => ({}));
|
||||||
throw new Error(err.error || "Failed to reset metadata");
|
throw new Error(err.error || "Failed to reset metadata");
|
||||||
@@ -940,10 +894,7 @@ Alpine.data("bookDetail", () => {
|
|||||||
showToast("Metadata reset to scanned defaults", "success");
|
showToast("Metadata reset to scanned defaults", "success");
|
||||||
setTimeout(() => window.location.reload(), 500);
|
setTimeout(() => window.location.reload(), 500);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(
|
showToast(e instanceof Error ? e.message : "Failed to reset metadata", "error");
|
||||||
e instanceof Error ? e.message : "Failed to reset metadata",
|
|
||||||
"error",
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
this.resetting = false;
|
this.resetting = false;
|
||||||
}
|
}
|
||||||
@@ -958,7 +909,9 @@ Alpine.data("bookDetail", () => {
|
|||||||
}
|
}
|
||||||
const results = await searchTagSuggestions(this.tagSearch, libraryId);
|
const results = await searchTagSuggestions(this.tagSearch, libraryId);
|
||||||
const current = this.editorTags.map((t: string) => t.toLowerCase());
|
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.showTagDropdown = this.tagSuggestions.length > 0;
|
||||||
this.highlightedTagIndex = -1;
|
this.highlightedTagIndex = -1;
|
||||||
},
|
},
|
||||||
@@ -984,7 +937,9 @@ Alpine.data("bookDetail", () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
hideEditorTagDropdown() {
|
hideEditorTagDropdown() {
|
||||||
setTimeout(() => { this.showTagDropdown = false; }, 200);
|
setTimeout(() => {
|
||||||
|
this.showTagDropdown = false;
|
||||||
|
}, 200);
|
||||||
},
|
},
|
||||||
|
|
||||||
onTagKeydown(event: KeyboardEvent) {
|
onTagKeydown(event: KeyboardEvent) {
|
||||||
@@ -996,7 +951,8 @@ Alpine.data("bookDetail", () => {
|
|||||||
} else if (event.key === "ArrowUp") {
|
} else if (event.key === "ArrowUp") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (this.showTagDropdown && this.tagSuggestions.length > 0) {
|
if (this.showTagDropdown && this.tagSuggestions.length > 0) {
|
||||||
this.highlightedTagIndex = this.highlightedTagIndex <= 0
|
this.highlightedTagIndex =
|
||||||
|
this.highlightedTagIndex <= 0
|
||||||
? this.tagSuggestions.length - 1
|
? this.tagSuggestions.length - 1
|
||||||
: this.highlightedTagIndex - 1;
|
: this.highlightedTagIndex - 1;
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-16
@@ -51,9 +51,7 @@ Alpine.store("bookPicker", {
|
|||||||
const filterDiv = document.getElementById("book-picker-filters");
|
const filterDiv = document.getElementById("book-picker-filters");
|
||||||
if (!filterDiv) return;
|
if (!filterDiv) return;
|
||||||
|
|
||||||
const inputs = filterDiv.querySelectorAll(
|
const inputs = filterDiv.querySelectorAll('input[type="text"]') as NodeListOf<HTMLInputElement>;
|
||||||
'input[type="text"]',
|
|
||||||
) as NodeListOf<HTMLInputElement>;
|
|
||||||
inputs.forEach((input) => {
|
inputs.forEach((input) => {
|
||||||
input.value = "";
|
input.value = "";
|
||||||
});
|
});
|
||||||
@@ -90,10 +88,7 @@ Alpine.store("bookPicker", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
showToast(
|
showToast(`Added ${this.selectedBooks.size} books to collection`, "success");
|
||||||
`Added ${this.selectedBooks.size} books to collection`,
|
|
||||||
"success",
|
|
||||||
);
|
|
||||||
this.close();
|
this.close();
|
||||||
location.reload();
|
location.reload();
|
||||||
} else {
|
} else {
|
||||||
@@ -110,32 +105,30 @@ Alpine.store("bookPicker", {
|
|||||||
// Provides methods for template to access store
|
// Provides methods for template to access store
|
||||||
Alpine.data("bookPicker", () => ({
|
Alpine.data("bookPicker", () => ({
|
||||||
get isOpen() {
|
get isOpen() {
|
||||||
return (window as any).Alpine?.$store.bookPicker.isOpen;
|
return window.Alpine?.$store.bookPicker.isOpen;
|
||||||
},
|
},
|
||||||
|
|
||||||
get selectedCount() {
|
get selectedCount() {
|
||||||
return (window as any).Alpine?.$store.bookPicker.selectedCount;
|
return window.Alpine?.$store.bookPicker.selectedCount;
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleBook(bookId: string) {
|
toggleBook(bookId: string) {
|
||||||
(window as any).Alpine?.$store.bookPicker.toggleBook(bookId);
|
window.Alpine?.$store.bookPicker.toggleBook(bookId);
|
||||||
},
|
},
|
||||||
|
|
||||||
isSelected(bookId: string): boolean {
|
isSelected(bookId: string): boolean {
|
||||||
return (window as any).Alpine?.$store.bookPicker.isSelected(bookId);
|
return window.Alpine?.$store.bookPicker.isSelected(bookId);
|
||||||
},
|
},
|
||||||
|
|
||||||
clearFilters() {
|
clearFilters() {
|
||||||
(window as any).Alpine?.$store.bookPicker.clearFilters();
|
window.Alpine?.$store.bookPicker.clearFilters();
|
||||||
},
|
},
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
(window as any).Alpine?.$store.bookPicker.close();
|
window.Alpine?.$store.bookPicker.close();
|
||||||
},
|
},
|
||||||
|
|
||||||
submit() {
|
submit() {
|
||||||
(window as any).Alpine?.$store.bookPicker.submit();
|
window.Alpine?.$store.bookPicker.submit();
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export {};
|
|
||||||
+47
-70
@@ -2,10 +2,7 @@
|
|||||||
|
|
||||||
import { Alpine } from "./alpine";
|
import { Alpine } from "./alpine";
|
||||||
import { showToast } from "./toast";
|
import { showToast } from "./toast";
|
||||||
import type {
|
import type { FilterGroupState, FilterRuleState } from "./filter_builder";
|
||||||
FilterGroupState,
|
|
||||||
FilterRuleState,
|
|
||||||
} from "./filter_builder";
|
|
||||||
|
|
||||||
function clearFilters(): void {
|
function clearFilters(): void {
|
||||||
// Clear all form state (reuses helper)
|
// Clear all form state (reuses helper)
|
||||||
@@ -65,12 +62,8 @@ function clearFormWithoutSubmit(): void {
|
|||||||
cleanupDynamicHiddenInputs();
|
cleanupDynamicHiddenInputs();
|
||||||
|
|
||||||
// Reset pagination to defaults
|
// Reset pagination to defaults
|
||||||
const limitInput = filterForm.querySelector(
|
const limitInput = filterForm.querySelector('input[name="limit"]') as HTMLInputElement;
|
||||||
'input[name="limit"]',
|
const offsetInput = filterForm.querySelector('input[name="offset"]') as HTMLInputElement;
|
||||||
) as HTMLInputElement;
|
|
||||||
const offsetInput = filterForm.querySelector(
|
|
||||||
'input[name="offset"]',
|
|
||||||
) as HTMLInputElement;
|
|
||||||
if (limitInput) limitInput.value = "50";
|
if (limitInput) limitInput.value = "50";
|
||||||
if (offsetInput) offsetInput.value = "0";
|
if (offsetInput) offsetInput.value = "0";
|
||||||
|
|
||||||
@@ -100,22 +93,21 @@ Alpine.data("bookshelf", () => ({
|
|||||||
// inputs are dropped outright) and re-apply the form. Chips refresh
|
// inputs are dropped outright) and re-apply the form. Chips refresh
|
||||||
// via the shared htmx:afterRequest hook.
|
// via the shared htmx:afterRequest hook.
|
||||||
removeFilterChip(param: string): void {
|
removeFilterChip(param: string): void {
|
||||||
const filterForm = document.getElementById(
|
const filterForm = document.getElementById("filter-form") as HTMLFormElement;
|
||||||
"filter-form",
|
|
||||||
) as HTMLFormElement;
|
|
||||||
if (!filterForm) return;
|
if (!filterForm) return;
|
||||||
// Tree chips (qf mode): "rule:<path>" — remove that rule from the
|
// Tree chips (qf mode): "rule:<path>" — remove that rule from the
|
||||||
// builder tree, resync the wire inputs, re-apply.
|
// builder tree, resync the wire inputs, re-apply.
|
||||||
if (param.startsWith("rule:")) {
|
if (param.startsWith("rule:")) {
|
||||||
const builder = builderData();
|
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);
|
builder?.removeNode(path);
|
||||||
window.htmx.trigger(filterForm, "submit");
|
window.htmx.trigger(filterForm, "submit");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const input = filterForm.querySelector(
|
const input = filterForm.querySelector(`[name="${param}"]`) as HTMLInputElement | null;
|
||||||
`[name="${param}"]`,
|
|
||||||
) as HTMLInputElement | null;
|
|
||||||
if (input) {
|
if (input) {
|
||||||
if (input.type === "hidden") {
|
if (input.type === "hidden") {
|
||||||
input.remove();
|
input.remove();
|
||||||
@@ -225,14 +217,10 @@ Alpine.data("bookshelf", () => ({
|
|||||||
|
|
||||||
// Parse filters JSON (string → object)
|
// Parse filters JSON (string → object)
|
||||||
const filterData: Record<string, string> =
|
const filterData: Record<string, string> =
|
||||||
typeof filter.filters === "string"
|
typeof filter.filters === "string" ? JSON.parse(filter.filters) : filter.filters;
|
||||||
? JSON.parse(filter.filters)
|
|
||||||
: filter.filters;
|
|
||||||
|
|
||||||
// Get the hidden filter form
|
// Get the hidden filter form
|
||||||
const filterForm = document.getElementById(
|
const filterForm = document.getElementById("filter-form") as HTMLFormElement;
|
||||||
"filter-form",
|
|
||||||
) as HTMLFormElement;
|
|
||||||
if (!filterForm) {
|
if (!filterForm) {
|
||||||
showToast("Filter form not found", "error");
|
showToast("Filter form not found", "error");
|
||||||
return;
|
return;
|
||||||
@@ -250,9 +238,7 @@ Alpine.data("bookshelf", () => ({
|
|||||||
// (qf wins over flat, the pinned precedence).
|
// (qf wins over flat, the pinned precedence).
|
||||||
Object.entries(filterData).forEach(([key, value]) => {
|
Object.entries(filterData).forEach(([key, value]) => {
|
||||||
if (!value) return; // Skip empty values
|
if (!value) return; // Skip empty values
|
||||||
const visibleField = filterForm.querySelector(
|
const visibleField = filterForm.querySelector(`[name="${key}"]`) as HTMLInputElement;
|
||||||
`[name="${key}"]`,
|
|
||||||
) as HTMLInputElement;
|
|
||||||
if (visibleField) {
|
if (visibleField) {
|
||||||
if (visibleField.type === "checkbox") {
|
if (visibleField.type === "checkbox") {
|
||||||
visibleField.checked = value === "true";
|
visibleField.checked = value === "true";
|
||||||
@@ -336,9 +322,7 @@ function applyURLFilters(filterForm: HTMLFormElement | null): boolean {
|
|||||||
// ignored — the tree IS the query.
|
// ignored — the tree IS the query.
|
||||||
const qf = params.get("qf");
|
const qf = params.get("qf");
|
||||||
if (qf) {
|
if (qf) {
|
||||||
const input = filterForm.querySelector(
|
const input = filterForm.querySelector('[name="qf"]') as HTMLInputElement | null;
|
||||||
'[name="qf"]',
|
|
||||||
) as HTMLInputElement | null;
|
|
||||||
if (input) {
|
if (input) {
|
||||||
input.value = qf;
|
input.value = qf;
|
||||||
} else {
|
} else {
|
||||||
@@ -376,9 +360,7 @@ function applyURLFilters(filterForm: HTMLFormElement | null): boolean {
|
|||||||
for (const name of filterNames) {
|
for (const name of filterNames) {
|
||||||
const value = params.get(name);
|
const value = params.get(name);
|
||||||
if (!value) continue;
|
if (!value) continue;
|
||||||
const input = filterForm.querySelector(
|
const input = filterForm.querySelector(`[name="${name}"]`) as HTMLInputElement | null;
|
||||||
`[name="${name}"]`,
|
|
||||||
) as HTMLInputElement | null;
|
|
||||||
if (input) {
|
if (input) {
|
||||||
input.value = value;
|
input.value = value;
|
||||||
applied = true;
|
applied = true;
|
||||||
@@ -413,14 +395,10 @@ interface FilterChip {
|
|||||||
function prettifyGenreLabel(value: string): string {
|
function prettifyGenreLabel(value: string): string {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (!trimmed || trimmed !== trimmed.toLowerCase()) return trimmed;
|
if (!trimmed || trimmed !== trimmed.toLowerCase()) return trimmed;
|
||||||
const small = new Set([
|
const small = new Set(["a", "an", "and", "of", "the", "in", "on", "for", "to", "or"]);
|
||||||
"a", "an", "and", "of", "the", "in", "on", "for", "to", "or",
|
|
||||||
]);
|
|
||||||
return trimmed
|
return trimmed
|
||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
.map((w, i) =>
|
.map((w, i) => (i > 0 && small.has(w) ? w : w.charAt(0).toUpperCase() + w.slice(1)))
|
||||||
i > 0 && small.has(w) ? w : w.charAt(0).toUpperCase() + w.slice(1),
|
|
||||||
)
|
|
||||||
.join(" ");
|
.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,8 +428,7 @@ function facetChipLabel(name: string, value: string): string {
|
|||||||
abridged_filter: "Abridged",
|
abridged_filter: "Abridged",
|
||||||
};
|
};
|
||||||
const label = names[name] ?? name;
|
const label = names[name] ?? name;
|
||||||
const shown =
|
const shown = name === "genre_filter" ? prettifyGenreLabel(value) : value;
|
||||||
name === "genre_filter" ? prettifyGenreLabel(value) : value;
|
|
||||||
return `${label}: ${shown}`;
|
return `${label}: ${shown}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,9 +436,7 @@ function facetChipLabel(name: string, value: string): string {
|
|||||||
// the shareable URL (history.replaceState) — the single sync point for
|
// the shareable URL (history.replaceState) — the single sync point for
|
||||||
// form applies, URL deep-links, sort changes, and clears.
|
// form applies, URL deep-links, sort changes, and clears.
|
||||||
function refreshFilterChips(): void {
|
function refreshFilterChips(): void {
|
||||||
const filterForm = document.getElementById(
|
const filterForm = document.getElementById("filter-form") as HTMLFormElement;
|
||||||
"filter-form",
|
|
||||||
) as HTMLFormElement;
|
|
||||||
const bookshelfElem = document.querySelector('[x-data*="bookshelf"]');
|
const bookshelfElem = document.querySelector('[x-data*="bookshelf"]');
|
||||||
if (!filterForm || !bookshelfElem || !(bookshelfElem as any)._x_dataStack) {
|
if (!filterForm || !bookshelfElem || !(bookshelfElem as any)._x_dataStack) {
|
||||||
return;
|
return;
|
||||||
@@ -470,9 +445,7 @@ function refreshFilterChips(): void {
|
|||||||
if (typeof data.activeFilters === "undefined") return;
|
if (typeof data.activeFilters === "undefined") return;
|
||||||
|
|
||||||
const chips: FilterChip[] = [];
|
const chips: FilterChip[] = [];
|
||||||
const qfInput = filterForm.querySelector(
|
const qfInput = filterForm.querySelector('[name="qf"]') as HTMLInputElement | null;
|
||||||
'[name="qf"]',
|
|
||||||
) as HTMLInputElement | null;
|
|
||||||
|
|
||||||
if (qfInput && qfInput.value) {
|
if (qfInput && qfInput.value) {
|
||||||
// Tree mode: one chip per rule (red for negations — the chips
|
// Tree mode: one chip per rule (red for negations — the chips
|
||||||
@@ -482,24 +455,20 @@ function refreshFilterChips(): void {
|
|||||||
try {
|
try {
|
||||||
const tree = JSON.parse(qfInput.value) as FilterGroupState;
|
const tree = JSON.parse(qfInput.value) as FilterGroupState;
|
||||||
const walk = (group: FilterGroupState, path: number[]) => {
|
const walk = (group: FilterGroupState, path: number[]) => {
|
||||||
(group.children as (FilterRuleState | FilterGroupState)[]).forEach(
|
(group.children as (FilterRuleState | FilterGroupState)[]).forEach((child, idx) => {
|
||||||
(child, idx) => {
|
|
||||||
const childPath = [...path, idx];
|
const childPath = [...path, idx];
|
||||||
if ("children" in (child as FilterGroupState)) {
|
if ("children" in (child as FilterGroupState)) {
|
||||||
walk(child as FilterGroupState, childPath);
|
walk(child as FilterGroupState, childPath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rule = child as FilterRuleState;
|
const rule = child as FilterRuleState;
|
||||||
const label = builder
|
const label = builder ? builder.chipLabelFor(rule) : `${rule.field} ${rule.op}`;
|
||||||
? builder.chipLabelFor(rule)
|
|
||||||
: `${rule.field} ${rule.op}`;
|
|
||||||
chips.push({
|
chips.push({
|
||||||
param: "rule:" + childPath.join("-"),
|
param: "rule:" + childPath.join("-"),
|
||||||
label,
|
label,
|
||||||
negated: builder?.isNegated(rule) ?? false,
|
negated: builder?.isNegated(rule) ?? false,
|
||||||
});
|
});
|
||||||
},
|
});
|
||||||
);
|
|
||||||
};
|
};
|
||||||
walk(tree, []);
|
walk(tree, []);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -527,12 +496,27 @@ function refreshFilterChips(): void {
|
|||||||
// shareable (plan: chips bar is URL-synced).
|
// shareable (plan: chips bar is URL-synced).
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
const keep = new Set([
|
const keep = new Set([
|
||||||
"author_filter", "series_filter", "genre_filter", "publisher_filter",
|
"author_filter",
|
||||||
"tags_filter", "narrator_filter", "language_filter", "story_arc_filter",
|
"series_filter",
|
||||||
"imprint_filter", "edition_filter", "age_rating_filter",
|
"genre_filter",
|
||||||
"format_group_filter", "manga_type_filter", "reading_direction_filter",
|
"publisher_filter",
|
||||||
"explicit_filter", "abridged_filter", "year_min", "year_max",
|
"tags_filter",
|
||||||
"has_cover", "library_id", "qf",
|
"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();
|
const next = new URLSearchParams();
|
||||||
url.searchParams.forEach((value, key) => {
|
url.searchParams.forEach((value, key) => {
|
||||||
@@ -551,9 +535,7 @@ function refreshFilterChips(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initBookshelf(): void {
|
function initBookshelf(): void {
|
||||||
const filterForm = document.getElementById(
|
const filterForm = document.getElementById("filter-form") as HTMLFormElement;
|
||||||
"filter-form",
|
|
||||||
) as HTMLFormElement;
|
|
||||||
|
|
||||||
// Active-filter chips refresh after every grid refetch — drawer
|
// Active-filter chips refresh after every grid refetch — drawer
|
||||||
// applies, sort changes, and pagination all include the form's
|
// 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
|
// The live match count is scope-aware: switching the top library
|
||||||
// picker re-runs it (the count fetch reads the select at fetch time —
|
// picker re-runs it (the count fetch reads the select at fetch time —
|
||||||
// this is what makes it fire).
|
// this is what makes it fire).
|
||||||
const librarySelect = document.getElementById(
|
const librarySelect = document.getElementById("library-select") as HTMLSelectElement | null;
|
||||||
"library-select",
|
|
||||||
) as HTMLSelectElement | null;
|
|
||||||
librarySelect?.addEventListener("change", () => {
|
librarySelect?.addEventListener("change", () => {
|
||||||
builderData()?.scheduleMatchCount();
|
builderData()?.scheduleMatchCount();
|
||||||
});
|
});
|
||||||
@@ -587,13 +567,10 @@ function initBookshelf(): void {
|
|||||||
|
|
||||||
// Check if books were already rendered server-side
|
// Check if books were already rendered server-side
|
||||||
const booksGrid = document.getElementById("books-grid");
|
const booksGrid = document.getElementById("books-grid");
|
||||||
const hasServerBooks =
|
const hasServerBooks = booksGrid && booksGrid.querySelector("[data-book-id]") !== null;
|
||||||
booksGrid && booksGrid.querySelector("[data-book-id]") !== null;
|
|
||||||
|
|
||||||
if (!hasServerBooks || urlApplied) {
|
if (!hasServerBooks || urlApplied) {
|
||||||
const librarySelect = document.getElementById(
|
const librarySelect = document.getElementById("library-select") as HTMLSelectElement;
|
||||||
"library-select",
|
|
||||||
) as HTMLSelectElement;
|
|
||||||
librarySelect?.addEventListener("change", () => {
|
librarySelect?.addEventListener("change", () => {
|
||||||
localStorage.setItem("selectedLibrary", librarySelect.value);
|
localStorage.setItem("selectedLibrary", librarySelect.value);
|
||||||
});
|
});
|
||||||
|
|||||||
+11
-34
@@ -146,24 +146,14 @@ async function handleCreateRule(event: Event): Promise<void> {
|
|||||||
if (!token || !collectionId) return;
|
if (!token || !collectionId) return;
|
||||||
|
|
||||||
const fieldInput = document.getElementById("rule-field") as HTMLSelectElement;
|
const fieldInput = document.getElementById("rule-field") as HTMLSelectElement;
|
||||||
const operatorInput = document.getElementById(
|
const operatorInput = document.getElementById("rule-operator") as HTMLSelectElement;
|
||||||
"rule-operator",
|
|
||||||
) as HTMLSelectElement;
|
|
||||||
const valueInput = document.getElementById("rule-value") as HTMLInputElement;
|
const valueInput = document.getElementById("rule-value") as HTMLInputElement;
|
||||||
const enabledInput = document.getElementById(
|
const enabledInput = document.getElementById("rule-enabled") as HTMLInputElement;
|
||||||
"rule-enabled",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const priorityInput = document.querySelector(
|
const priorityInput = document.querySelector(
|
||||||
'input[name="priority"]:checked',
|
'input[name="priority"]:checked',
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
|
|
||||||
if (
|
if (!fieldInput || !operatorInput || !valueInput || !enabledInput || !priorityInput) {
|
||||||
!fieldInput ||
|
|
||||||
!operatorInput ||
|
|
||||||
!valueInput ||
|
|
||||||
!enabledInput ||
|
|
||||||
!priorityInput
|
|
||||||
) {
|
|
||||||
showToast("Missing form fields", "error");
|
showToast("Missing form fields", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -204,9 +194,7 @@ async function testRule(): Promise<void> {
|
|||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
const fieldInput = document.getElementById("rule-field") as HTMLSelectElement;
|
const fieldInput = document.getElementById("rule-field") as HTMLSelectElement;
|
||||||
const operatorInput = document.getElementById(
|
const operatorInput = document.getElementById("rule-operator") as HTMLSelectElement;
|
||||||
"rule-operator",
|
|
||||||
) as HTMLSelectElement;
|
|
||||||
const valueInput = document.getElementById("rule-value") as HTMLInputElement;
|
const valueInput = document.getElementById("rule-value") as HTMLInputElement;
|
||||||
|
|
||||||
if (!fieldInput || !operatorInput || !valueInput) {
|
if (!fieldInput || !operatorInput || !valueInput) {
|
||||||
@@ -256,8 +244,7 @@ async function testRule(): Promise<void> {
|
|||||||
'<p style="color: var(--text-secondary); font-size: 0.875rem;">Found ' +
|
'<p style="color: var(--text-secondary); font-size: 0.875rem;">Found ' +
|
||||||
result.matches.length +
|
result.matches.length +
|
||||||
" matching books:</p>";
|
" matching books:</p>";
|
||||||
html +=
|
html += '<div style="display: flex; flex-direction: column; gap: 0.5rem;">';
|
||||||
'<div style="display: flex; flex-direction: column; gap: 0.5rem;">';
|
|
||||||
|
|
||||||
result.matches.slice(0, 20).forEach((book: TestRuleMatch) => {
|
result.matches.slice(0, 20).forEach((book: TestRuleMatch) => {
|
||||||
html +=
|
html +=
|
||||||
@@ -316,17 +303,14 @@ async function toggleRule(ruleId: string, enabled: boolean): Promise<void> {
|
|||||||
if (!token || !collectionId) return;
|
if (!token || !collectionId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(`/api/collections/${collectionId}/rules/${ruleId}`, {
|
||||||
`/api/collections/${collectionId}/rules/${ruleId}`,
|
|
||||||
{
|
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ enabled }),
|
body: JSON.stringify({ enabled }),
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
showToast("Rule updated", "success");
|
showToast("Rule updated", "success");
|
||||||
@@ -347,13 +331,10 @@ async function deleteRule(ruleId: string): Promise<void> {
|
|||||||
if (!token || !collectionId) return;
|
if (!token || !collectionId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(`/api/collections/${collectionId}/rules/${ruleId}`, {
|
||||||
`/api/collections/${collectionId}/rules/${ruleId}`,
|
|
||||||
{
|
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
showToast("Rule deleted", "success");
|
showToast("Rule deleted", "success");
|
||||||
@@ -369,13 +350,9 @@ async function deleteRule(ruleId: string): Promise<void> {
|
|||||||
|
|
||||||
function clearForm(): void {
|
function clearForm(): void {
|
||||||
const fieldInput = document.getElementById("rule-field") as HTMLSelectElement;
|
const fieldInput = document.getElementById("rule-field") as HTMLSelectElement;
|
||||||
const operatorInput = document.getElementById(
|
const operatorInput = document.getElementById("rule-operator") as HTMLSelectElement;
|
||||||
"rule-operator",
|
|
||||||
) as HTMLSelectElement;
|
|
||||||
const valueInput = document.getElementById("rule-value") as HTMLInputElement;
|
const valueInput = document.getElementById("rule-value") as HTMLInputElement;
|
||||||
const enabledInput = document.getElementById(
|
const enabledInput = document.getElementById("rule-enabled") as HTMLInputElement;
|
||||||
"rule-enabled",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const priorityInput = document.querySelector(
|
const priorityInput = document.querySelector(
|
||||||
'input[name="priority"][value="2"]',
|
'input[name="priority"][value="2"]',
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
|
|||||||
+36
-97
@@ -1,10 +1,7 @@
|
|||||||
import { Alpine } from "./alpine";
|
import { Alpine } from "./alpine";
|
||||||
import { showToast } from "./toast";
|
import { showToast } from "./toast";
|
||||||
import { createWebSocket } from "./websocket";
|
import { createWebSocket } from "./websocket";
|
||||||
import {
|
import { initLibrarySwitcher, switchWithTransition } from "./library-switcher";
|
||||||
initLibrarySwitcher,
|
|
||||||
switchWithTransition,
|
|
||||||
} from "./library-switcher";
|
|
||||||
|
|
||||||
let collectionId: string | null = null;
|
let collectionId: string | null = null;
|
||||||
|
|
||||||
@@ -18,10 +15,7 @@ function initializeCollectionWebSocket(): void {
|
|||||||
|
|
||||||
createWebSocket({
|
createWebSocket({
|
||||||
onMessage: (message) => {
|
onMessage: (message) => {
|
||||||
if (
|
if (message.type === "collection_updated" && message.data.collection_id === collectionId) {
|
||||||
message.type === "collection_updated" &&
|
|
||||||
message.data.collection_id === collectionId
|
|
||||||
) {
|
|
||||||
const actionText =
|
const actionText =
|
||||||
message.data.action === "books_added"
|
message.data.action === "books_added"
|
||||||
? `Added ${message.data.count || 0} book(s)`
|
? `Added ${message.data.count || 0} book(s)`
|
||||||
@@ -100,10 +94,7 @@ function renderRules(rules: CollectionRule[]): void {
|
|||||||
.join("");
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createRule(
|
async function createRule(collectionId: string, rule: Partial<CollectionRule>): Promise<void> {
|
||||||
collectionId: string,
|
|
||||||
rule: Partial<CollectionRule>,
|
|
||||||
): Promise<void> {
|
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
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;
|
if (!confirm("Are you sure you want to delete this rule?")) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(`/api/collections/${collectionId}/rules/${ruleId}`, {
|
||||||
`/api/collections/${collectionId}/rules/${ruleId}`,
|
|
||||||
{
|
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
showToast("Rule deleted", "success");
|
showToast("Rule deleted", "success");
|
||||||
@@ -155,25 +143,19 @@ async function deleteRule(collectionId: string, ruleId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testRule(
|
async function testRule(collectionId: string, rule: Partial<CollectionRule>): Promise<void> {
|
||||||
collectionId: string,
|
|
||||||
rule: Partial<CollectionRule>,
|
|
||||||
): Promise<void> {
|
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(`/api/collections/${collectionId}/rules/test`, {
|
||||||
`/api/collections/${collectionId}/rules/test`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify(rule),
|
body: JSON.stringify(rule),
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const results = await response.json();
|
const results = await response.json();
|
||||||
@@ -229,9 +211,7 @@ const borderClasses: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function selectColor(color: string): void {
|
function selectColor(color: string): void {
|
||||||
const colorInput = document.getElementById(
|
const colorInput = document.getElementById("collection-color") as HTMLInputElement;
|
||||||
"collection-color",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
if (colorInput) {
|
if (colorInput) {
|
||||||
colorInput.value = color;
|
colorInput.value = color;
|
||||||
}
|
}
|
||||||
@@ -239,9 +219,7 @@ function selectColor(color: string): void {
|
|||||||
(btn as HTMLElement).style.outline = "none";
|
(btn as HTMLElement).style.outline = "none";
|
||||||
(btn as HTMLElement).style.outlineOffset = "0";
|
(btn as HTMLElement).style.outlineOffset = "0";
|
||||||
});
|
});
|
||||||
const selectedBtn = document.querySelector(
|
const selectedBtn = document.querySelector(`.color-option[data-color="${color}"]`) as HTMLElement;
|
||||||
`.color-option[data-color="${color}"]`,
|
|
||||||
) as HTMLElement;
|
|
||||||
if (selectedBtn) {
|
if (selectedBtn) {
|
||||||
selectedBtn.style.outline = "3px solid var(--text-primary)";
|
selectedBtn.style.outline = "3px solid var(--text-primary)";
|
||||||
selectedBtn.style.outlineOffset = "3px";
|
selectedBtn.style.outlineOffset = "3px";
|
||||||
@@ -256,9 +234,7 @@ function closeCollectionModal(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initColorSelection(): void {
|
function initColorSelection(): void {
|
||||||
const colorInput = document.getElementById(
|
const colorInput = document.getElementById("collection-color") as HTMLInputElement;
|
||||||
"collection-color",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
if (colorInput && colorInput.value) {
|
if (colorInput && colorInput.value) {
|
||||||
selectColor(colorInput.value);
|
selectColor(colorInput.value);
|
||||||
}
|
}
|
||||||
@@ -291,9 +267,7 @@ function setupHTMXModalInit(): void {
|
|||||||
function showAllIcons(): void {
|
function showAllIcons(): void {
|
||||||
populateIconGrid();
|
populateIconGrid();
|
||||||
|
|
||||||
const searchInput = document.getElementById(
|
const searchInput = document.getElementById("icon-search") as HTMLInputElement;
|
||||||
"icon-search",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
if (searchInput) {
|
if (searchInput) {
|
||||||
searchInput.value = "";
|
searchInput.value = "";
|
||||||
}
|
}
|
||||||
@@ -359,12 +333,8 @@ function populateIconGrid(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectIcon(icon: string): void {
|
function selectIcon(icon: string): void {
|
||||||
const iconInput = document.getElementById(
|
const iconInput = document.getElementById("collection-icon") as HTMLInputElement;
|
||||||
"collection-icon",
|
const searchInput = document.getElementById("icon-search") as HTMLInputElement;
|
||||||
) as HTMLInputElement;
|
|
||||||
const searchInput = document.getElementById(
|
|
||||||
"icon-search",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
|
|
||||||
if (iconInput) iconInput.value = icon;
|
if (iconInput) iconInput.value = icon;
|
||||||
if (searchInput) searchInput.value = icon;
|
if (searchInput) searchInput.value = icon;
|
||||||
@@ -374,9 +344,7 @@ function selectIcon(icon: string): void {
|
|||||||
(btn as HTMLElement).style.backgroundColor = "";
|
(btn as HTMLElement).style.backgroundColor = "";
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedBtn = document.querySelector(
|
const selectedBtn = document.querySelector(`.icon-btn[data-emoji="${icon}"]`) as HTMLElement;
|
||||||
`.icon-btn[data-emoji="${icon}"]`,
|
|
||||||
) as HTMLElement;
|
|
||||||
if (selectedBtn) {
|
if (selectedBtn) {
|
||||||
selectedBtn.style.outline = "2px solid var(--text-primary)";
|
selectedBtn.style.outline = "2px solid var(--text-primary)";
|
||||||
selectedBtn.style.backgroundColor = "var(--bg-secondary)";
|
selectedBtn.style.backgroundColor = "var(--bg-secondary)";
|
||||||
@@ -385,9 +353,7 @@ function selectIcon(icon: string): void {
|
|||||||
|
|
||||||
function filterIcons(searchTerm: string): void {
|
function filterIcons(searchTerm: string): void {
|
||||||
const iconGrid = document.getElementById("icon-grid");
|
const iconGrid = document.getElementById("icon-grid");
|
||||||
const iconInput = document.getElementById(
|
const iconInput = document.getElementById("collection-icon") as HTMLInputElement;
|
||||||
"collection-icon",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
if (iconInput) iconInput.value = searchTerm;
|
if (iconInput) iconInput.value = searchTerm;
|
||||||
if (!iconGrid) return;
|
if (!iconGrid) return;
|
||||||
const buttons = iconGrid.querySelectorAll(".icon-btn");
|
const buttons = iconGrid.querySelectorAll(".icon-btn");
|
||||||
@@ -400,9 +366,7 @@ function filterIcons(searchTerm: string): void {
|
|||||||
const matches =
|
const matches =
|
||||||
searchTerm === "" ||
|
searchTerm === "" ||
|
||||||
emoji.includes(searchTerm) ||
|
emoji.includes(searchTerm) ||
|
||||||
keywords.some((keyword) =>
|
keywords.some((keyword) => keyword.toLowerCase().includes(lowerSearchTerm));
|
||||||
keyword.toLowerCase().includes(lowerSearchTerm),
|
|
||||||
);
|
|
||||||
|
|
||||||
(btn as HTMLElement).style.display = matches ? "" : "none";
|
(btn as HTMLElement).style.display = matches ? "" : "none";
|
||||||
});
|
});
|
||||||
@@ -427,11 +391,10 @@ function initCollectionsPage(): void {
|
|||||||
// Rendering ownership: the grid is server-rendered markup. Swap
|
// Rendering ownership: the grid is server-rendered markup. Swap
|
||||||
// in the templ partial instead of re-rendering from JSON.
|
// in the templ partial instead of re-rendering from JSON.
|
||||||
const param = libraryId ? `?library_id=${encodeURIComponent(libraryId)}` : "";
|
const param = libraryId ? `?library_id=${encodeURIComponent(libraryId)}` : "";
|
||||||
await window.htmx.ajax(
|
await window.htmx.ajax("GET", `/collections/${collId}/books-grid${param}`, {
|
||||||
"GET",
|
target: "#books-container",
|
||||||
`/collections/${collId}/books-grid${param}`,
|
swap: "innerHTML",
|
||||||
{ target: "#books-container", swap: "innerHTML" },
|
});
|
||||||
);
|
|
||||||
const container = document.getElementById("books-container");
|
const container = document.getElementById("books-container");
|
||||||
if (container) Alpine.initTree(container);
|
if (container) Alpine.initTree(container);
|
||||||
});
|
});
|
||||||
@@ -461,13 +424,10 @@ async function doRemoveBook(id: string): Promise<void> {
|
|||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(`/api/collections/${getCollectionId()}/books/${id}`, {
|
||||||
`/api/collections/${getCollectionId()}/books/${id}`,
|
|
||||||
{
|
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
},
|
});
|
||||||
);
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
showToast("Removed from collection", "success");
|
showToast("Removed from collection", "success");
|
||||||
setTimeout(() => location.reload(), 500);
|
setTimeout(() => location.reload(), 500);
|
||||||
@@ -483,17 +443,14 @@ async function doBulkRemove(ids: string[]): Promise<void> {
|
|||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(`/api/collections/${getCollectionId()}/books/bulk-remove`, {
|
||||||
`/api/collections/${getCollectionId()}/books/bulk-remove`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ book_ids: ids }),
|
body: JSON.stringify({ book_ids: ids }),
|
||||||
},
|
});
|
||||||
);
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
showToast(`Removed ${ids.length} book(s)`, "success");
|
showToast(`Removed ${ids.length} book(s)`, "success");
|
||||||
setTimeout(() => location.reload(), 500);
|
setTimeout(() => location.reload(), 500);
|
||||||
@@ -506,19 +463,14 @@ async function doBulkRemove(ids: string[]): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function filterCollectionBooks(): void {
|
function filterCollectionBooks(): void {
|
||||||
const input = document.getElementById(
|
const input = document.getElementById("collection-search") as HTMLInputElement;
|
||||||
"collection-search",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
if (!input) return;
|
if (!input) return;
|
||||||
const query = input.value.toLowerCase();
|
const query = input.value.toLowerCase();
|
||||||
const cards = document.querySelectorAll<HTMLElement>(
|
const cards = document.querySelectorAll<HTMLElement>(".collection-book-card");
|
||||||
".collection-book-card",
|
|
||||||
);
|
|
||||||
cards.forEach((card) => {
|
cards.forEach((card) => {
|
||||||
const title = (card.dataset.title || "").toLowerCase();
|
const title = (card.dataset.title || "").toLowerCase();
|
||||||
const author = (card.dataset.author || "").toLowerCase();
|
const author = (card.dataset.author || "").toLowerCase();
|
||||||
card.style.display =
|
card.style.display = title.includes(query) || author.includes(query) ? "" : "none";
|
||||||
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
|
// 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
|
// order; the API is the truth for SAVED order. On failure the grid refresh
|
||||||
// from the templ partial so the UI never lies.
|
// from the templ partial so the UI never lies.
|
||||||
async function moveCollectionBook(
|
async function moveCollectionBook(this: any, mediaId: string, direction: number): Promise<void> {
|
||||||
this: any,
|
|
||||||
mediaId: string,
|
|
||||||
direction: number,
|
|
||||||
): Promise<void> {
|
|
||||||
const container = document.getElementById("books-container");
|
const container = document.getElementById("books-container");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
const cards = Array.from(
|
const cards = Array.from(container.querySelectorAll<HTMLElement>(".collection-book-card"));
|
||||||
container.querySelectorAll<HTMLElement>(".collection-book-card"),
|
const index = cards.findIndex((c) => c.dataset.mediaId === mediaId);
|
||||||
);
|
|
||||||
const index = cards.findIndex(
|
|
||||||
(c) => c.dataset.mediaId === mediaId,
|
|
||||||
);
|
|
||||||
const target = index + direction;
|
const target = index + direction;
|
||||||
if (index < 0 || target < 0 || target >= cards.length) return;
|
if (index < 0 || target < 0 || target >= cards.length) return;
|
||||||
|
|
||||||
@@ -624,32 +568,27 @@ async function moveCollectionBook(
|
|||||||
container.insertBefore(cards[target], cards[index]);
|
container.insertBefore(cards[target], cards[index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderedIds = Array.from(
|
const orderedIds = Array.from(container.querySelectorAll<HTMLElement>(".collection-book-card"))
|
||||||
container.querySelectorAll<HTMLElement>(".collection-book-card"),
|
|
||||||
)
|
|
||||||
.map((c) => c.dataset.mediaId)
|
.map((c) => c.dataset.mediaId)
|
||||||
.filter((id): id is string => Boolean(id));
|
.filter((id): id is string => Boolean(id));
|
||||||
|
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(`/api/collections/${getCollectionId()}/items/order`, {
|
||||||
`/api/collections/${getCollectionId()}/items/order`,
|
|
||||||
{
|
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ item_ids: orderedIds }),
|
body: JSON.stringify({ item_ids: orderedIds }),
|
||||||
},
|
});
|
||||||
);
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
showToast("Failed to save the new order", "error");
|
showToast("Failed to save the new order", "error");
|
||||||
// Rendering ownership: the templ partial re-renders the true order.
|
// Rendering ownership: the templ partial re-renders the true order.
|
||||||
const gridTarget = document.getElementById("books-container");
|
const gridTarget = document.getElementById("books-container");
|
||||||
if (gridTarget && (window as any).htmx) {
|
if (gridTarget && window.htmx) {
|
||||||
(window as any).htmx.ajax(
|
window.htmx.ajax(
|
||||||
"GET",
|
"GET",
|
||||||
`/collections/${getCollectionId()}/books-grid${window.location.search.startsWith("?") ? window.location.search : ""}`,
|
`/collections/${getCollectionId()}/books-grid${window.location.search.startsWith("?") ? window.location.search : ""}`,
|
||||||
{ target: "#books-container", swap: "innerHTML" },
|
{ 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).
|
// ownership: JS decides what shows; templ rendered what they look like).
|
||||||
|
|
||||||
function filterConflicts(): void {
|
function filterConflicts(): void {
|
||||||
const select = document.getElementById(
|
const select = document.getElementById("status-filter") as HTMLSelectElement | null;
|
||||||
"status-filter",
|
|
||||||
) as HTMLSelectElement | null;
|
|
||||||
const filter = select?.value ?? "unresolved";
|
const filter = select?.value ?? "unresolved";
|
||||||
let total = 0;
|
let total = 0;
|
||||||
let visible = 0;
|
let visible = 0;
|
||||||
@@ -44,9 +42,7 @@ function filterConflicts(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleAllConflicts(): void {
|
function toggleAllConflicts(): void {
|
||||||
const selectAll = document.getElementById(
|
const selectAll = document.getElementById("select-all-conflicts") as HTMLInputElement | null;
|
||||||
"select-all-conflicts",
|
|
||||||
) as HTMLInputElement | null;
|
|
||||||
if (!selectAll) return;
|
if (!selectAll) return;
|
||||||
document.querySelectorAll<HTMLElement>(".conflict-item").forEach((item) => {
|
document.querySelectorAll<HTMLElement>(".conflict-item").forEach((item) => {
|
||||||
if (item.style.display === "none") return;
|
if (item.style.display === "none") return;
|
||||||
@@ -57,9 +53,7 @@ function toggleAllConflicts(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getSelectedConflictIds(): string[] {
|
function getSelectedConflictIds(): string[] {
|
||||||
return Array.from(
|
return Array.from(document.querySelectorAll<HTMLInputElement>(".conflict-checkbox:checked"))
|
||||||
document.querySelectorAll<HTMLInputElement>(".conflict-checkbox:checked"),
|
|
||||||
)
|
|
||||||
.map((box) => box.dataset.conflictId || "")
|
.map((box) => box.dataset.conflictId || "")
|
||||||
.filter((id) => id !== "");
|
.filter((id) => id !== "");
|
||||||
}
|
}
|
||||||
@@ -75,12 +69,8 @@ function updateBulkActions(): void {
|
|||||||
function resolveSelected(): void {
|
function resolveSelected(): void {
|
||||||
const ids = getSelectedConflictIds();
|
const ids = getSelectedConflictIds();
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
const strategyEl = document.getElementById(
|
const strategyEl = document.getElementById("bulk-strategy") as HTMLSelectElement | null;
|
||||||
"bulk-strategy",
|
const strategy = (strategyEl?.value ?? "most_recent") as "most_recent" | "highest_progress";
|
||||||
) as HTMLSelectElement | null;
|
|
||||||
const strategy = (strategyEl?.value ?? "most_recent") as
|
|
||||||
| "most_recent"
|
|
||||||
| "highest_progress";
|
|
||||||
void bulkResolve(strategy, ids);
|
void bulkResolve(strategy, ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,9 +185,7 @@ async function dismissAllResolved(): Promise<void> {
|
|||||||
|
|
||||||
function showResolveModal(conflictId: string): void {
|
function showResolveModal(conflictId: string): void {
|
||||||
const modal = document.getElementById("resolve-modal");
|
const modal = document.getElementById("resolve-modal");
|
||||||
const conflictIdInput = document.getElementById(
|
const conflictIdInput = document.getElementById("resolve-conflict-id") as HTMLInputElement;
|
||||||
"resolve-conflict-id",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
|
|
||||||
if (modal && conflictIdInput) {
|
if (modal && conflictIdInput) {
|
||||||
conflictIdInput.value = conflictId;
|
conflictIdInput.value = conflictId;
|
||||||
@@ -216,12 +204,8 @@ function handleResolveSubmit(event: Event): void {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const form = event.target as HTMLFormElement;
|
const form = event.target as HTMLFormElement;
|
||||||
const conflictId = (
|
const conflictId = (form.querySelector("#resolve-conflict-id") as HTMLInputElement)?.value;
|
||||||
form.querySelector("#resolve-conflict-id") as HTMLInputElement
|
const winner = (form.querySelector('input[name="winner"]:checked') as HTMLInputElement)?.value;
|
||||||
)?.value;
|
|
||||||
const winner = (
|
|
||||||
form.querySelector('input[name="winner"]:checked') as HTMLInputElement
|
|
||||||
)?.value;
|
|
||||||
|
|
||||||
if (!conflictId || !winner) {
|
if (!conflictId || !winner) {
|
||||||
showToast("Please select a winner", "error");
|
showToast("Please select a winner", "error");
|
||||||
|
|||||||
@@ -174,16 +174,7 @@ const FILTER_FIELDS: FilterField[] = [
|
|||||||
{ id: "in", label: "In", requiresValue: true },
|
{ id: "in", label: "In", requiresValue: true },
|
||||||
],
|
],
|
||||||
valueType: "select",
|
valueType: "select",
|
||||||
options: [
|
options: ["English", "Spanish", "French", "German", "Japanese", "Chinese", "Russian", "Other"],
|
||||||
"English",
|
|
||||||
"Spanish",
|
|
||||||
"French",
|
|
||||||
"German",
|
|
||||||
"Japanese",
|
|
||||||
"Chinese",
|
|
||||||
"Russian",
|
|
||||||
"Other",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "format",
|
id: "format",
|
||||||
@@ -219,7 +210,11 @@ const FILTER_FIELDS: FilterField[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
let ruleCounter = 0;
|
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;
|
let customSectionTimeout: number | null = null;
|
||||||
|
|
||||||
function initCustomSectionBuilder(): void {
|
function initCustomSectionBuilder(): void {
|
||||||
@@ -300,45 +295,28 @@ function addFilterRule(): void {
|
|||||||
|
|
||||||
container.appendChild(ruleElement);
|
container.appendChild(ruleElement);
|
||||||
|
|
||||||
const fieldSelect = ruleElement.querySelector(
|
const fieldSelect = ruleElement.querySelector(".field-select") as HTMLSelectElement;
|
||||||
".field-select",
|
const removeBtn = ruleElement.querySelector(".remove-rule-btn") as HTMLButtonElement;
|
||||||
) as HTMLSelectElement;
|
|
||||||
const removeBtn = ruleElement.querySelector(
|
|
||||||
".remove-rule-btn",
|
|
||||||
) as HTMLButtonElement;
|
|
||||||
|
|
||||||
fieldSelect.addEventListener("change", () => onFieldChange(ruleElement));
|
fieldSelect.addEventListener("change", () => onFieldChange(ruleElement));
|
||||||
removeBtn.addEventListener("click", () => removeFilterRule(ruleId));
|
removeBtn.addEventListener("click", () => removeFilterRule(ruleId));
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFieldChange(ruleElement: HTMLElement): void {
|
function onFieldChange(ruleElement: HTMLElement): void {
|
||||||
const fieldSelect = ruleElement.querySelector(
|
const fieldSelect = ruleElement.querySelector(".field-select") as HTMLSelectElement;
|
||||||
".field-select",
|
const operatorSelect = ruleElement.querySelector(".operator-select") as HTMLSelectElement;
|
||||||
) as HTMLSelectElement;
|
const valueInput = ruleElement.querySelector(".value-input") as HTMLInputElement;
|
||||||
const operatorSelect = ruleElement.querySelector(
|
|
||||||
".operator-select",
|
|
||||||
) as HTMLSelectElement;
|
|
||||||
const valueInput = ruleElement.querySelector(
|
|
||||||
".value-input",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
|
|
||||||
const fieldId = fieldSelect.value;
|
const fieldId = fieldSelect.value;
|
||||||
const field = FILTER_FIELDS.find((f) => f.id === fieldId);
|
const field = FILTER_FIELDS.find((f) => f.id === fieldId);
|
||||||
|
|
||||||
operatorSelect.innerHTML = field
|
operatorSelect.innerHTML = field
|
||||||
? field.operators
|
? field.operators.map((op) => `<option value="${op.id}">${op.label}</option>`).join("")
|
||||||
.map((op) => `<option value="${op.id}">${op.label}</option>`)
|
|
||||||
.join("")
|
|
||||||
: '<option value="">Select field first...</option>';
|
: '<option value="">Select field first...</option>';
|
||||||
|
|
||||||
operatorSelect.disabled = !field;
|
operatorSelect.disabled = !field;
|
||||||
|
|
||||||
if (
|
if (field && field.operators.some((op) => op.id === operatorSelect.value && op.requiresValue)) {
|
||||||
field &&
|
|
||||||
field.operators.some(
|
|
||||||
(op) => op.id === operatorSelect.value && op.requiresValue,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
valueInput.classList.remove("hidden");
|
valueInput.classList.remove("hidden");
|
||||||
|
|
||||||
if (field.valueType === "select" && field.options) {
|
if (field.valueType === "select" && field.options) {
|
||||||
@@ -356,9 +334,7 @@ function onFieldChange(ruleElement: HTMLElement): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
operatorSelect.addEventListener("change", () => {
|
operatorSelect.addEventListener("change", () => {
|
||||||
const selectedOp = field?.operators.find(
|
const selectedOp = field?.operators.find((op) => op.id === operatorSelect.value);
|
||||||
(op) => op.id === operatorSelect.value,
|
|
||||||
);
|
|
||||||
if (selectedOp?.requiresValue) {
|
if (selectedOp?.requiresValue) {
|
||||||
valueInput.classList.remove("hidden");
|
valueInput.classList.remove("hidden");
|
||||||
} else {
|
} else {
|
||||||
@@ -384,15 +360,9 @@ function onBookSearchInput(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function searchBooks(): Promise<void> {
|
async function searchBooks(): Promise<void> {
|
||||||
const searchInput = document.getElementById(
|
const searchInput = document.getElementById("book-search") as HTMLInputElement;
|
||||||
"book-search",
|
const librarySelect = document.getElementById("section-library") as HTMLSelectElement;
|
||||||
) as HTMLInputElement;
|
const resultsContainer = document.getElementById("search-results") as HTMLElement;
|
||||||
const librarySelect = document.getElementById(
|
|
||||||
"section-library",
|
|
||||||
) as HTMLSelectElement;
|
|
||||||
const resultsContainer = document.getElementById(
|
|
||||||
"search-results",
|
|
||||||
) as HTMLElement;
|
|
||||||
|
|
||||||
const query = searchInput?.value.trim();
|
const query = searchInput?.value.trim();
|
||||||
const libraryId = librarySelect?.value;
|
const libraryId = librarySelect?.value;
|
||||||
@@ -426,9 +396,7 @@ async function searchBooks(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function displaySearchResults(books: BookInfo[]): void {
|
function displaySearchResults(books: BookInfo[]): void {
|
||||||
const resultsContainer = document.getElementById(
|
const resultsContainer = document.getElementById("search-results") as HTMLElement;
|
||||||
"search-results",
|
|
||||||
) as HTMLElement;
|
|
||||||
if (!resultsContainer) return;
|
if (!resultsContainer) return;
|
||||||
|
|
||||||
if (books.length === 0) {
|
if (books.length === 0) {
|
||||||
@@ -458,11 +426,7 @@ function displaySearchResults(books: BookInfo[]): void {
|
|||||||
resultsContainer.classList.remove("hidden");
|
resultsContainer.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
const addBookToSelection = (
|
const addBookToSelection = (bookId: string, title: string, author: string): void => {
|
||||||
bookId: string,
|
|
||||||
title: string,
|
|
||||||
author: string,
|
|
||||||
): void => {
|
|
||||||
if (selectedBooks.has(bookId)) {
|
if (selectedBooks.has(bookId)) {
|
||||||
showToast("Book already selected", "error");
|
showToast("Book already selected", "error");
|
||||||
return;
|
return;
|
||||||
@@ -508,12 +472,8 @@ function updateSelectedBooksDisplay(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadPreview(): Promise<void> {
|
async function loadPreview(): Promise<void> {
|
||||||
const previewContainer = document.getElementById(
|
const previewContainer = document.getElementById("preview-container") as HTMLElement;
|
||||||
"preview-container",
|
const librarySelect = document.getElementById("section-library") as HTMLSelectElement;
|
||||||
) as HTMLElement;
|
|
||||||
const librarySelect = document.getElementById(
|
|
||||||
"section-library",
|
|
||||||
) as HTMLSelectElement;
|
|
||||||
const libraryId = librarySelect?.value;
|
const libraryId = librarySelect?.value;
|
||||||
|
|
||||||
if (!libraryId) {
|
if (!libraryId) {
|
||||||
@@ -543,8 +503,7 @@ async function loadPreview(): Promise<void> {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Preview error:", error);
|
console.error("Preview error:", error);
|
||||||
previewContainer.innerHTML =
|
previewContainer.innerHTML = '<p class="text-center text-red-500">Failed to load preview</p>';
|
||||||
'<p class="text-center text-red-500">Failed to load preview</p>';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -556,15 +515,9 @@ function gatherFilterRules(): FilterRule[] {
|
|||||||
const rules: FilterRule[] = [];
|
const rules: FilterRule[] = [];
|
||||||
|
|
||||||
ruleElements.forEach((element, index) => {
|
ruleElements.forEach((element, index) => {
|
||||||
const fieldSelect = element.querySelector(
|
const fieldSelect = element.querySelector(".field-select") as HTMLSelectElement;
|
||||||
".field-select",
|
const operatorSelect = element.querySelector(".operator-select") as HTMLSelectElement;
|
||||||
) as HTMLSelectElement;
|
const valueInput = element.querySelector(".value-input") as HTMLInputElement;
|
||||||
const operatorSelect = element.querySelector(
|
|
||||||
".operator-select",
|
|
||||||
) as HTMLSelectElement;
|
|
||||||
const valueInput = element.querySelector(
|
|
||||||
".value-input",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
|
|
||||||
if (fieldSelect.value && operatorSelect.value) {
|
if (fieldSelect.value && operatorSelect.value) {
|
||||||
rules.push({
|
rules.push({
|
||||||
@@ -581,9 +534,7 @@ function gatherFilterRules(): FilterRule[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function displayPreview(items: BookInfo[]): void {
|
function displayPreview(items: BookInfo[]): void {
|
||||||
const previewContainer = document.getElementById(
|
const previewContainer = document.getElementById("preview-container") as HTMLElement;
|
||||||
"preview-container",
|
|
||||||
) as HTMLElement;
|
|
||||||
if (!previewContainer) return;
|
if (!previewContainer) return;
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
@@ -626,8 +577,7 @@ async function saveCustomSection(event: Event): Promise<void> {
|
|||||||
const name = formData.get("name") as string;
|
const name = formData.get("name") as string;
|
||||||
const icon = formData.get("icon") as string;
|
const icon = formData.get("icon") as string;
|
||||||
const description = formData.get("description") as string;
|
const description = formData.get("description") as string;
|
||||||
const matchType = (document.getElementById("match-type") as HTMLSelectElement)
|
const matchType = (document.getElementById("match-type") as HTMLSelectElement).value;
|
||||||
.value;
|
|
||||||
|
|
||||||
if (!libraryId || !name) {
|
if (!libraryId || !name) {
|
||||||
showToast("Please fill in required fields", "error");
|
showToast("Please fill in required fields", "error");
|
||||||
|
|||||||
+37
-106
@@ -7,9 +7,7 @@ const SCROLL_AMOUNT = 300;
|
|||||||
let scanListenerRegistered = false;
|
let scanListenerRegistered = false;
|
||||||
|
|
||||||
function scrollCarousel(collectionId: string, direction: number): void {
|
function scrollCarousel(collectionId: string, direction: number): void {
|
||||||
const track = document.getElementById(
|
const track = document.getElementById(`carousel-track-${collectionId}`) as HTMLElement;
|
||||||
`carousel-track-${collectionId}`,
|
|
||||||
) as HTMLElement;
|
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
|
|
||||||
const scrollAmount = direction * SCROLL_AMOUNT;
|
const scrollAmount = direction * SCROLL_AMOUNT;
|
||||||
@@ -17,20 +15,15 @@ function scrollCarousel(collectionId: string, direction: number): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openDashboardSettings(): Promise<void> {
|
async function openDashboardSettings(): Promise<void> {
|
||||||
const librarySelect = document.getElementById(
|
const librarySelect = document.getElementById("library-select") as HTMLSelectElement;
|
||||||
"library-select",
|
|
||||||
) as HTMLSelectElement;
|
|
||||||
const libraryId = librarySelect?.value || "";
|
const libraryId = librarySelect?.value || "";
|
||||||
|
|
||||||
// Empty select = the All Libraries view: its preferences are the GLOBAL
|
// Empty select = the All Libraries view: its preferences are the GLOBAL
|
||||||
// scope — the server returns that row when library_id is absent.
|
// scope — the server returns that row when library_id is absent.
|
||||||
const query = libraryId ? `?library_id=${encodeURIComponent(libraryId)}` : "";
|
const query = libraryId ? `?library_id=${encodeURIComponent(libraryId)}` : "";
|
||||||
const response = await fetch(
|
const response = await fetch(`/api/dashboard/preferences${query}`, {
|
||||||
`/api/dashboard/preferences${query}`,
|
|
||||||
{
|
|
||||||
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
|
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const prefs = await response.json();
|
const prefs = await response.json();
|
||||||
@@ -38,11 +31,8 @@ async function openDashboardSettings(): Promise<void> {
|
|||||||
// Scope indicator: make it unmistakable what a save will customize.
|
// Scope indicator: make it unmistakable what a save will customize.
|
||||||
const scopeEl = document.getElementById("dashboard-scope-label");
|
const scopeEl = document.getElementById("dashboard-scope-label");
|
||||||
if (scopeEl) {
|
if (scopeEl) {
|
||||||
const optionText =
|
const optionText = librarySelect?.selectedOptions[0]?.textContent || libraryId;
|
||||||
librarySelect?.selectedOptions[0]?.textContent || libraryId;
|
scopeEl.textContent = (libraryId ? optionText : optionText || "All Libraries")
|
||||||
scopeEl.textContent = (
|
|
||||||
libraryId ? optionText : optionText || "All Libraries"
|
|
||||||
)
|
|
||||||
.replace(/\s*\(\d+\)$/, "")
|
.replace(/\s*\(\d+\)$/, "")
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
@@ -52,16 +42,12 @@ async function openDashboardSettings(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const modal = document.getElementById(
|
const modal = document.getElementById("dashboard-settings-modal") as HTMLElement;
|
||||||
"dashboard-settings-modal",
|
|
||||||
) as HTMLElement;
|
|
||||||
modal?.classList.remove("hidden");
|
modal?.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyPreferencesToModal(prefs: any): void {
|
function applyPreferencesToModal(prefs: any): void {
|
||||||
const collectionList = document.getElementById(
|
const collectionList = document.getElementById("collection-list") as HTMLElement;
|
||||||
"collection-list",
|
|
||||||
) as HTMLElement;
|
|
||||||
if (!collectionList) return;
|
if (!collectionList) return;
|
||||||
|
|
||||||
const slider = document.querySelector(
|
const slider = document.querySelector(
|
||||||
@@ -73,16 +59,12 @@ function applyPreferencesToModal(prefs: any): void {
|
|||||||
display.textContent = String(prefs.items_per_section);
|
display.textContent = String(prefs.items_per_section);
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = collectionList.querySelectorAll(
|
const items = collectionList.querySelectorAll("[data-collection-id]") as NodeListOf<HTMLElement>;
|
||||||
"[data-collection-id]",
|
|
||||||
) as NodeListOf<HTMLElement>;
|
|
||||||
const orderedItems: HTMLElement[] = [];
|
const orderedItems: HTMLElement[] = [];
|
||||||
|
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
const collectionId = item.dataset.collectionId;
|
const collectionId = item.dataset.collectionId;
|
||||||
const checkbox = item.querySelector(
|
const checkbox = item.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||||
'input[type="checkbox"]',
|
|
||||||
) as HTMLInputElement;
|
|
||||||
|
|
||||||
const isHidden = prefs.hidden_collections.includes(collectionId);
|
const isHidden = prefs.hidden_collections.includes(collectionId);
|
||||||
if (checkbox) checkbox.checked = !isHidden;
|
if (checkbox) checkbox.checked = !isHidden;
|
||||||
@@ -99,32 +81,25 @@ function applyPreferencesToModal(prefs: any): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeDashboardSettings(): void {
|
function closeDashboardSettings(): void {
|
||||||
const modal = document.getElementById(
|
const modal = document.getElementById("dashboard-settings-modal") as HTMLElement;
|
||||||
"dashboard-settings-modal",
|
|
||||||
) as HTMLElement;
|
|
||||||
modal?.classList.add("hidden");
|
modal?.classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAndRenderSections(libraryId: string): Promise<void> {
|
async function fetchAndRenderSections(libraryId: string): Promise<void> {
|
||||||
const param = libraryId ? `library_id=${libraryId}` : "";
|
const param = libraryId ? `library_id=${libraryId}` : "";
|
||||||
const response = await fetch(
|
const response = await fetch(`/api/dashboard/sections?${param}`, {
|
||||||
`/api/dashboard/sections?${param}`,
|
|
||||||
{
|
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
);
|
|
||||||
if (!response.ok) throw new Error("Failed to load sections");
|
if (!response.ok) throw new Error("Failed to load sections");
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
renderDashboardCollections(data.sections);
|
renderDashboardCollections(data.sections);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveDashboardSettings(): Promise<void> {
|
async function saveDashboardSettings(): Promise<void> {
|
||||||
const collectionList = document.getElementById(
|
const collectionList = document.getElementById("collection-list") as HTMLElement;
|
||||||
"collection-list",
|
|
||||||
) as HTMLElement;
|
|
||||||
if (!collectionList) return;
|
if (!collectionList) return;
|
||||||
const collectionItems = collectionList.querySelectorAll(
|
const collectionItems = collectionList.querySelectorAll(
|
||||||
"[data-collection-id]",
|
"[data-collection-id]",
|
||||||
@@ -133,9 +108,7 @@ async function saveDashboardSettings(): Promise<void> {
|
|||||||
const collectionOrder: string[] = [];
|
const collectionOrder: string[] = [];
|
||||||
collectionItems.forEach((item) => {
|
collectionItems.forEach((item) => {
|
||||||
const collectionId = item.dataset.collectionId;
|
const collectionId = item.dataset.collectionId;
|
||||||
const checkbox = item.querySelector(
|
const checkbox = item.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||||
'input[type="checkbox"]',
|
|
||||||
) as HTMLInputElement;
|
|
||||||
if (collectionId) {
|
if (collectionId) {
|
||||||
collectionOrder.push(collectionId);
|
collectionOrder.push(collectionId);
|
||||||
if (checkbox && !checkbox.checked) {
|
if (checkbox && !checkbox.checked) {
|
||||||
@@ -144,12 +117,9 @@ async function saveDashboardSettings(): Promise<void> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
const itemsPerCollection =
|
const itemsPerCollection =
|
||||||
(document.querySelector("#items-count-display") as HTMLElement)
|
(document.querySelector("#items-count-display") as HTMLElement)?.textContent || "20";
|
||||||
?.textContent || "20";
|
|
||||||
try {
|
try {
|
||||||
const librarySelect = document.getElementById(
|
const librarySelect = document.getElementById("library-select") as HTMLSelectElement;
|
||||||
"library-select",
|
|
||||||
) as HTMLSelectElement;
|
|
||||||
const libraryId = librarySelect?.value || "";
|
const libraryId = librarySelect?.value || "";
|
||||||
const response = await apiPut("/dashboard/preferences", {
|
const response = await apiPut("/dashboard/preferences", {
|
||||||
// Empty select = the All Libraries view: an explicit JSON null is
|
// 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;">
|
style="scrollbar-width: none; -ms-overflow-style: none;">
|
||||||
${
|
${
|
||||||
section.items.length > 0
|
section.items.length > 0
|
||||||
? section.items
|
? section.items.map((item) => renderBookCard(item)).join("")
|
||||||
.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 class="text-center py-8 w-full" style="color: var(--text-secondary);"><p>No items in this collection</p></div>'
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -279,9 +247,7 @@ function renderSectionHTML(section: SectionData): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderDashboardCollections(sections: SectionData[]): void {
|
function renderDashboardCollections(sections: SectionData[]): void {
|
||||||
const container = document.getElementById(
|
const container = document.getElementById("collections-container") as HTMLElement;
|
||||||
"collections-container",
|
|
||||||
) as HTMLElement;
|
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const currentWood = container.getAttribute("data-wood");
|
const currentWood = container.getAttribute("data-wood");
|
||||||
@@ -342,7 +308,6 @@ function badgeTitleFor(badge: string): string {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function renderBookCard(book: BookInfo): string {
|
function renderBookCard(book: BookInfo): string {
|
||||||
const coverUrl = book.cover_image_path || "/static/placeholder-book.svg";
|
const coverUrl = book.cover_image_path || "/static/placeholder-book.svg";
|
||||||
const actionHref = book.has_conflict
|
const actionHref = book.has_conflict
|
||||||
@@ -354,11 +319,7 @@ function renderBookCard(book: BookInfo): string {
|
|||||||
: isAudio
|
: isAudio
|
||||||
? `Listen to ${book.title}`
|
? `Listen to ${book.title}`
|
||||||
: `Read ${book.title}`;
|
: `Read ${book.title}`;
|
||||||
const actionTitle = book.has_conflict
|
const actionTitle = book.has_conflict ? "Resolve progress conflict" : isAudio ? "Listen" : "Read";
|
||||||
? "Resolve progress conflict"
|
|
||||||
: isAudio
|
|
||||||
? "Listen"
|
|
||||||
: "Read";
|
|
||||||
const actionIcon = isAudio ? HEADPHONES_ICON : BOOK_OPEN_ICON;
|
const actionIcon = isAudio ? HEADPHONES_ICON : BOOK_OPEN_ICON;
|
||||||
const badgeIcon = badgeIconFor(book.badge || "");
|
const badgeIcon = badgeIconFor(book.badge || "");
|
||||||
|
|
||||||
@@ -393,20 +354,13 @@ function renderBookCard(book: BookInfo): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function reloadPage(): Promise<void> {
|
async function reloadPage(): Promise<void> {
|
||||||
const librarySelect = document.getElementById(
|
const librarySelect = document.getElementById("library-select") as HTMLSelectElement;
|
||||||
"library-select",
|
|
||||||
) as HTMLSelectElement;
|
|
||||||
const libraryId = librarySelect?.value || "";
|
const libraryId = librarySelect?.value || "";
|
||||||
|
|
||||||
await switchWithTransition("collections-container", () =>
|
await switchWithTransition("collections-container", () => fetchAndRenderSections(libraryId));
|
||||||
fetchAndRenderSections(libraryId),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateItemsCount(
|
function updateItemsCount(input: HTMLInputElement, displayTarget: string): void {
|
||||||
input: HTMLInputElement,
|
|
||||||
displayTarget: string,
|
|
||||||
): void {
|
|
||||||
const displayElement = document.getElementById(displayTarget) as HTMLElement;
|
const displayElement = document.getElementById(displayTarget) as HTMLElement;
|
||||||
if (displayElement) {
|
if (displayElement) {
|
||||||
displayElement.textContent = input.value;
|
displayElement.textContent = input.value;
|
||||||
@@ -414,9 +368,7 @@ function updateItemsCount(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initDragAndDrop(): void {
|
function initDragAndDrop(): void {
|
||||||
const collectionList = document.getElementById(
|
const collectionList = document.getElementById("collection-list") as HTMLElement;
|
||||||
"collection-list",
|
|
||||||
) as HTMLElement;
|
|
||||||
if (!collectionList) return;
|
if (!collectionList) return;
|
||||||
|
|
||||||
let draggedItem: HTMLElement | null = null;
|
let draggedItem: HTMLElement | null = null;
|
||||||
@@ -440,11 +392,7 @@ function initDragAndDrop(): void {
|
|||||||
collectionList.addEventListener("dragover", (e: Event) => {
|
collectionList.addEventListener("dragover", (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (
|
if (target.classList.contains("collection-item") && target !== draggedItem && draggedItem) {
|
||||||
target.classList.contains("collection-item") &&
|
|
||||||
target !== draggedItem &&
|
|
||||||
draggedItem
|
|
||||||
) {
|
|
||||||
const rect = target.getBoundingClientRect();
|
const rect = target.getBoundingClientRect();
|
||||||
const midY = rect.top + rect.height / 2;
|
const midY = rect.top + rect.height / 2;
|
||||||
if ((e as DragEvent).clientY < midY) {
|
if ((e as DragEvent).clientY < midY) {
|
||||||
@@ -509,8 +457,7 @@ function initDashboard() {
|
|||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "scroll-carousel": {
|
case "scroll-carousel": {
|
||||||
const collectionId =
|
const collectionId = target.dataset.collectionId || actionElem?.dataset.collectionId;
|
||||||
target.dataset.collectionId || actionElem?.dataset.collectionId;
|
|
||||||
const direction = parseInt(
|
const direction = parseInt(
|
||||||
target.dataset.direction || actionElem?.dataset.direction || "0",
|
target.dataset.direction || actionElem?.dataset.direction || "0",
|
||||||
);
|
);
|
||||||
@@ -531,8 +478,7 @@ function initDashboard() {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "restore-system-collection": {
|
case "restore-system-collection": {
|
||||||
const colName =
|
const colName = actionElem?.dataset.collectionName || target.dataset.collectionName;
|
||||||
actionElem?.dataset.collectionName || target.dataset.collectionName;
|
|
||||||
const colTitle =
|
const colTitle =
|
||||||
actionElem?.dataset.collectionTitle ||
|
actionElem?.dataset.collectionTitle ||
|
||||||
target.dataset.collectionTitle ||
|
target.dataset.collectionTitle ||
|
||||||
@@ -565,55 +511,40 @@ function initDashboard() {
|
|||||||
if (!scanListenerRegistered) {
|
if (!scanListenerRegistered) {
|
||||||
scanListenerRegistered = true;
|
scanListenerRegistered = true;
|
||||||
window.addEventListener("bookhoard:scan-complete", async () => {
|
window.addEventListener("bookhoard:scan-complete", async () => {
|
||||||
const librarySelect = document.getElementById(
|
const librarySelect = document.getElementById("library-select") as HTMLSelectElement;
|
||||||
"library-select",
|
|
||||||
) as HTMLSelectElement;
|
|
||||||
const libraryId = librarySelect?.value || "";
|
const libraryId = librarySelect?.value || "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const param = libraryId ? `library_id=${libraryId}` : "";
|
const param = libraryId ? `library_id=${libraryId}` : "";
|
||||||
const response = await fetch(
|
const response = await fetch(`/api/dashboard/sections?${param}`, {
|
||||||
`/api/dashboard/sections?${param}`,
|
|
||||||
{
|
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
);
|
|
||||||
if (!response.ok) return;
|
if (!response.ok) return;
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
const container = document.getElementById(
|
const container = document.getElementById("collections-container") as HTMLElement;
|
||||||
"collections-container",
|
|
||||||
) as HTMLElement;
|
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const freshSectionIds = new Set(
|
const freshSectionIds = new Set((data.sections as SectionData[]).map((s) => s.id));
|
||||||
(data.sections as SectionData[]).map((s) => s.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const section of data.sections as SectionData[]) {
|
for (const section of data.sections as SectionData[]) {
|
||||||
const track = document.getElementById(
|
const track = document.getElementById(`carousel-track-${section.id}`);
|
||||||
`carousel-track-${section.id}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!track) {
|
if (!track) {
|
||||||
container.insertAdjacentHTML(
|
container.insertAdjacentHTML("beforeend", renderSectionHTML(section));
|
||||||
"beforeend",
|
|
||||||
renderSectionHTML(section),
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
track.innerHTML = section.items.length > 0
|
track.innerHTML =
|
||||||
|
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 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>(
|
const existingSections = container.querySelectorAll<HTMLElement>(".dashboard-collection");
|
||||||
".dashboard-collection",
|
|
||||||
);
|
|
||||||
existingSections.forEach((sec) => {
|
existingSections.forEach((sec) => {
|
||||||
const secId = sec.getAttribute("data-collection-id");
|
const secId = sec.getAttribute("data-collection-id");
|
||||||
if (secId && !freshSectionIds.has(secId)) {
|
if (secId && !freshSectionIds.has(secId)) {
|
||||||
|
|||||||
+51
-146
@@ -30,15 +30,9 @@ async function handleAddDevice(event: Event): Promise<void> {
|
|||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
const deviceNameInput = document.getElementById(
|
const deviceNameInput = document.getElementById("device-name") as HTMLInputElement;
|
||||||
"device-name",
|
const deviceTypeInput = document.getElementById("device-type") as HTMLSelectElement;
|
||||||
) as HTMLInputElement;
|
const deviceIdentifierInput = document.getElementById("device-identifier") as HTMLInputElement;
|
||||||
const deviceTypeInput = document.getElementById(
|
|
||||||
"device-type",
|
|
||||||
) as HTMLSelectElement;
|
|
||||||
const deviceIdentifierInput = document.getElementById(
|
|
||||||
"device-identifier",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
|
|
||||||
if (!deviceNameInput || !deviceTypeInput || !deviceIdentifierInput) return;
|
if (!deviceNameInput || !deviceTypeInput || !deviceIdentifierInput) return;
|
||||||
|
|
||||||
@@ -59,10 +53,7 @@ async function handleAddDevice(event: Event): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
showToast(
|
showToast("Device registered! Check your device for sync instructions.", "success");
|
||||||
"Device registered! Check your device for sync instructions.",
|
|
||||||
"success",
|
|
||||||
);
|
|
||||||
hideAddDeviceModal();
|
hideAddDeviceModal();
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} else {
|
} else {
|
||||||
@@ -76,24 +67,12 @@ async function handleAddDevice(event: Event): Promise<void> {
|
|||||||
|
|
||||||
function showDeviceSettings(deviceId: string): void {
|
function showDeviceSettings(deviceId: string): void {
|
||||||
const modal = document.getElementById("device-settings-modal");
|
const modal = document.getElementById("device-settings-modal");
|
||||||
const deviceIdInput = document.getElementById(
|
const deviceIdInput = document.getElementById("settings-device-id") as HTMLInputElement;
|
||||||
"settings-device-id",
|
const deviceTypeInput = document.getElementById("settings-device-type") as HTMLInputElement;
|
||||||
) as HTMLInputElement;
|
const deviceNameInput = document.getElementById("settings-device-name") as HTMLInputElement;
|
||||||
const deviceTypeInput = document.getElementById(
|
const syncEnabledInput = document.getElementById("settings-sync-enabled") as HTMLInputElement;
|
||||||
"settings-device-type",
|
const autoSyncInput = document.getElementById("settings-auto-sync") as HTMLInputElement;
|
||||||
) as HTMLInputElement;
|
const syncFrequencyInput = document.getElementById("settings-sync-frequency") 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;
|
if (!modal || !deviceIdInput) return;
|
||||||
|
|
||||||
@@ -111,8 +90,7 @@ function showDeviceSettings(deviceId: string): void {
|
|||||||
.then((device) => {
|
.then((device) => {
|
||||||
if (deviceTypeInput) deviceTypeInput.value = device.device_type || "";
|
if (deviceTypeInput) deviceTypeInput.value = device.device_type || "";
|
||||||
if (deviceNameInput) deviceNameInput.value = device.device_name || "";
|
if (deviceNameInput) deviceNameInput.value = device.device_name || "";
|
||||||
if (syncEnabledInput)
|
if (syncEnabledInput) syncEnabledInput.checked = device.sync_enabled || false;
|
||||||
syncEnabledInput.checked = device.sync_enabled || false;
|
|
||||||
if (autoSyncInput) autoSyncInput.checked = device.auto_sync || false;
|
if (autoSyncInput) autoSyncInput.checked = device.auto_sync || false;
|
||||||
if (syncFrequencyInput)
|
if (syncFrequencyInput)
|
||||||
syncFrequencyInput.value = String(device.sync_frequency_minutes || 60);
|
syncFrequencyInput.value = String(device.sync_frequency_minutes || 60);
|
||||||
@@ -129,32 +107,22 @@ function showDeviceSettings(deviceId: string): void {
|
|||||||
const viewSettings = firstCollection.view_settings || {};
|
const viewSettings = firstCollection.view_settings || {};
|
||||||
const deviceSettings = viewSettings[deviceType] || {};
|
const deviceSettings = viewSettings[deviceType] || {};
|
||||||
|
|
||||||
const viewModeInput = document.getElementById(
|
const viewModeInput = document.getElementById("settings-view-mode") as HTMLInputElement;
|
||||||
"settings-view-mode",
|
const sortOrderInput = document.getElementById("settings-sort-order") as HTMLInputElement;
|
||||||
) as HTMLInputElement;
|
|
||||||
const sortOrderInput = document.getElementById(
|
|
||||||
"settings-sort-order",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const itemsPerPageInput = document.getElementById(
|
const itemsPerPageInput = document.getElementById(
|
||||||
"settings-items-per-page",
|
"settings-items-per-page",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
const showCoversInput = document.getElementById(
|
const showCoversInput = document.getElementById("settings-show-covers") as HTMLInputElement;
|
||||||
"settings-show-covers",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const showProgressInput = document.getElementById(
|
const showProgressInput = document.getElementById(
|
||||||
"settings-show-progress",
|
"settings-show-progress",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
|
|
||||||
if (viewModeInput)
|
if (viewModeInput) viewModeInput.value = deviceSettings.view_mode || "grid";
|
||||||
viewModeInput.value = deviceSettings.view_mode || "grid";
|
if (sortOrderInput) sortOrderInput.value = deviceSettings.sort_order || "name";
|
||||||
if (sortOrderInput)
|
|
||||||
sortOrderInput.value = deviceSettings.sort_order || "name";
|
|
||||||
if (itemsPerPageInput)
|
if (itemsPerPageInput)
|
||||||
itemsPerPageInput.value = String(deviceSettings.items_per_page || 24);
|
itemsPerPageInput.value = String(deviceSettings.items_per_page || 24);
|
||||||
if (showCoversInput)
|
if (showCoversInput) showCoversInput.checked = deviceSettings.show_covers !== false;
|
||||||
showCoversInput.checked = deviceSettings.show_covers !== false;
|
if (showProgressInput) showProgressInput.checked = deviceSettings.show_progress || false;
|
||||||
if (showProgressInput)
|
|
||||||
showProgressInput.checked = deviceSettings.show_progress || false;
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -165,9 +133,7 @@ function showDeviceSettings(deviceId: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showShelfMappings(deviceId: string): void {
|
function showShelfMappings(deviceId: string): void {
|
||||||
const mappingsDeviceIdInput = document.getElementById(
|
const mappingsDeviceIdInput = document.getElementById("mappings-device-id") as HTMLInputElement;
|
||||||
"mappings-device-id",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const modal = document.getElementById("shelf-mappings-modal");
|
const modal = document.getElementById("shelf-mappings-modal");
|
||||||
|
|
||||||
if (mappingsDeviceIdInput) mappingsDeviceIdInput.value = deviceId;
|
if (mappingsDeviceIdInput) mappingsDeviceIdInput.value = deviceId;
|
||||||
@@ -225,42 +191,29 @@ async function loadShelfMappings(deviceId: string): Promise<void> {
|
|||||||
container.innerHTML =
|
container.innerHTML =
|
||||||
'<p style="color: var(--text-secondary)">No shelf mappings configured. Click "Add Mapping" to create one.</p>';
|
'<p style="color: var(--text-secondary)">No shelf mappings configured. Click "Add Mapping" to create one.</p>';
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
container.innerHTML =
|
container.innerHTML = '<p style="color: var(--error)">Failed to load mappings</p>';
|
||||||
'<p style="color: var(--error)">Failed to load mappings</p>';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showAddMappingModal(): void {
|
function showAddMappingModal(): void {
|
||||||
const mappingsDeviceIdInput = document.getElementById(
|
const mappingsDeviceIdInput = document.getElementById("mappings-device-id") as HTMLInputElement;
|
||||||
"mappings-device-id",
|
const mappingIdInput = document.getElementById("mapping-id") as HTMLInputElement;
|
||||||
) as HTMLInputElement;
|
const mappingCollectionInput = document.getElementById("mapping-collection") as HTMLSelectElement;
|
||||||
const mappingIdInput = document.getElementById(
|
const mappingShelfNameInput = document.getElementById("mapping-shelf-name") as HTMLInputElement;
|
||||||
"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(
|
const mappingSyncDirectionInput = document.getElementById(
|
||||||
"mapping-sync-direction",
|
"mapping-sync-direction",
|
||||||
) as HTMLSelectElement;
|
) as HTMLSelectElement;
|
||||||
const modal = document.getElementById("add-mapping-modal");
|
const modal = document.getElementById("add-mapping-modal");
|
||||||
|
|
||||||
if (mappingsDeviceIdInput) {
|
if (mappingsDeviceIdInput) {
|
||||||
const mappingDeviceIdInput = document.getElementById(
|
const mappingDeviceIdInput = document.getElementById("mapping-device-id") as HTMLInputElement;
|
||||||
"mapping-device-id",
|
if (mappingDeviceIdInput) mappingDeviceIdInput.value = mappingsDeviceIdInput.value;
|
||||||
) as HTMLInputElement;
|
|
||||||
if (mappingDeviceIdInput)
|
|
||||||
mappingDeviceIdInput.value = mappingsDeviceIdInput.value;
|
|
||||||
}
|
}
|
||||||
if (mappingIdInput) mappingIdInput.value = "";
|
if (mappingIdInput) mappingIdInput.value = "";
|
||||||
if (mappingCollectionInput) mappingCollectionInput.value = "";
|
if (mappingCollectionInput) mappingCollectionInput.value = "";
|
||||||
if (mappingShelfNameInput) mappingShelfNameInput.value = "";
|
if (mappingShelfNameInput) mappingShelfNameInput.value = "";
|
||||||
if (mappingSyncDirectionInput)
|
if (mappingSyncDirectionInput) mappingSyncDirectionInput.value = "bidirectional";
|
||||||
mappingSyncDirectionInput.value = "bidirectional";
|
|
||||||
|
|
||||||
loadCollections();
|
loadCollections();
|
||||||
if (modal) modal.classList.remove("hidden");
|
if (modal) modal.classList.remove("hidden");
|
||||||
@@ -274,9 +227,7 @@ function hideAddMappingModal(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadCollections(): Promise<void> {
|
async function loadCollections(): Promise<void> {
|
||||||
const select = document.getElementById(
|
const select = document.getElementById("mapping-collection") as HTMLSelectElement;
|
||||||
"mapping-collection",
|
|
||||||
) as HTMLSelectElement;
|
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!select || !token) return;
|
if (!select || !token) return;
|
||||||
|
|
||||||
@@ -304,15 +255,9 @@ function editMapping(
|
|||||||
shelfName: string,
|
shelfName: string,
|
||||||
syncDirection: string,
|
syncDirection: string,
|
||||||
): void {
|
): void {
|
||||||
const mappingIdInput = document.getElementById(
|
const mappingIdInput = document.getElementById("mapping-id") as HTMLInputElement;
|
||||||
"mapping-id",
|
const mappingCollectionInput = document.getElementById("mapping-collection") as HTMLSelectElement;
|
||||||
) as HTMLInputElement;
|
const mappingShelfNameInput = document.getElementById("mapping-shelf-name") as HTMLInputElement;
|
||||||
const mappingCollectionInput = document.getElementById(
|
|
||||||
"mapping-collection",
|
|
||||||
) as HTMLSelectElement;
|
|
||||||
const mappingShelfNameInput = document.getElementById(
|
|
||||||
"mapping-shelf-name",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const mappingSyncDirectionInput = document.getElementById(
|
const mappingSyncDirectionInput = document.getElementById(
|
||||||
"mapping-sync-direction",
|
"mapping-sync-direction",
|
||||||
) as HTMLSelectElement;
|
) as HTMLSelectElement;
|
||||||
@@ -321,8 +266,7 @@ function editMapping(
|
|||||||
if (mappingIdInput) mappingIdInput.value = mappingId;
|
if (mappingIdInput) mappingIdInput.value = mappingId;
|
||||||
if (mappingCollectionInput) mappingCollectionInput.value = collectionId;
|
if (mappingCollectionInput) mappingCollectionInput.value = collectionId;
|
||||||
if (mappingShelfNameInput) mappingShelfNameInput.value = shelfName;
|
if (mappingShelfNameInput) mappingShelfNameInput.value = shelfName;
|
||||||
if (mappingSyncDirectionInput)
|
if (mappingSyncDirectionInput) mappingSyncDirectionInput.value = syncDirection;
|
||||||
mappingSyncDirectionInput.value = syncDirection;
|
|
||||||
|
|
||||||
if (modal) modal.classList.remove("hidden");
|
if (modal) modal.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
@@ -331,18 +275,10 @@ async function handleSaveMapping(event: Event): Promise<void> {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
const mappingsDeviceIdInput = document.getElementById(
|
const mappingsDeviceIdInput = document.getElementById("mapping-device-id") as HTMLInputElement;
|
||||||
"mapping-device-id",
|
const mappingIdInput = document.getElementById("mapping-id") as HTMLInputElement;
|
||||||
) as HTMLInputElement;
|
const mappingCollectionInput = document.getElementById("mapping-collection") as HTMLSelectElement;
|
||||||
const mappingIdInput = document.getElementById(
|
const mappingShelfNameInput = document.getElementById("mapping-shelf-name") as HTMLInputElement;
|
||||||
"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(
|
const mappingSyncDirectionInput = document.getElementById(
|
||||||
"mapping-sync-direction",
|
"mapping-sync-direction",
|
||||||
) as HTMLSelectElement;
|
) 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;
|
if (!confirm("Are you sure you want to delete this mapping?")) return;
|
||||||
|
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
const mappingsDeviceIdInput = document.getElementById(
|
const mappingsDeviceIdInput = document.getElementById("mappings-device-id") as HTMLInputElement;
|
||||||
"mappings-device-id",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
if (!token || !mappingsDeviceIdInput) return;
|
if (!token || !mappingsDeviceIdInput) return;
|
||||||
|
|
||||||
const deviceId = mappingsDeviceIdInput.value;
|
const deviceId = mappingsDeviceIdInput.value;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(`/api/devices/${deviceId}/collections/${mappingId}`, {
|
||||||
`/api/devices/${deviceId}/collections/${mappingId}`,
|
|
||||||
{
|
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
showToast("Mapping deleted", "success");
|
showToast("Mapping deleted", "success");
|
||||||
@@ -435,9 +366,7 @@ async function handleSaveDeviceSettings(event: Event): Promise<void> {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
const settingsDeviceIdInput = document.getElementById(
|
const settingsDeviceIdInput = document.getElementById("settings-device-id") as HTMLInputElement;
|
||||||
"settings-device-id",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const settingsDeviceTypeInput = document.getElementById(
|
const settingsDeviceTypeInput = document.getElementById(
|
||||||
"settings-device-type",
|
"settings-device-type",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
@@ -447,9 +376,7 @@ async function handleSaveDeviceSettings(event: Event): Promise<void> {
|
|||||||
const settingsSyncEnabledInput = document.getElementById(
|
const settingsSyncEnabledInput = document.getElementById(
|
||||||
"settings-sync-enabled",
|
"settings-sync-enabled",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
const settingsAutoSyncInput = document.getElementById(
|
const settingsAutoSyncInput = document.getElementById("settings-auto-sync") as HTMLInputElement;
|
||||||
"settings-auto-sync",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const settingsSyncFrequencyInput = document.getElementById(
|
const settingsSyncFrequencyInput = document.getElementById(
|
||||||
"settings-sync-frequency",
|
"settings-sync-frequency",
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
@@ -463,27 +390,14 @@ async function handleSaveDeviceSettings(event: Event): Promise<void> {
|
|||||||
device_name: settingsDeviceNameInput.value,
|
device_name: settingsDeviceNameInput.value,
|
||||||
sync_enabled: settingsSyncEnabledInput?.checked || false,
|
sync_enabled: settingsSyncEnabledInput?.checked || false,
|
||||||
auto_sync: settingsAutoSyncInput?.checked || false,
|
auto_sync: settingsAutoSyncInput?.checked || false,
|
||||||
sync_frequency_minutes: parseInt(
|
sync_frequency_minutes: parseInt(settingsSyncFrequencyInput?.value || "60", 10),
|
||||||
settingsSyncFrequencyInput?.value || "60",
|
|
||||||
10,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const viewModeInput = document.getElementById(
|
const viewModeInput = document.getElementById("settings-view-mode") as HTMLSelectElement;
|
||||||
"settings-view-mode",
|
const sortOrderInput = document.getElementById("settings-sort-order") as HTMLSelectElement;
|
||||||
) as HTMLSelectElement;
|
const itemsPerPageInput = document.getElementById("settings-items-per-page") as HTMLInputElement;
|
||||||
const sortOrderInput = document.getElementById(
|
const showCoversInput = document.getElementById("settings-show-covers") as HTMLInputElement;
|
||||||
"settings-sort-order",
|
const showProgressInput = document.getElementById("settings-show-progress") as HTMLInputElement;
|
||||||
) 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 = {
|
const viewSettings = {
|
||||||
view_mode: viewModeInput?.value || "grid",
|
view_mode: viewModeInput?.value || "grid",
|
||||||
@@ -536,10 +450,7 @@ async function handleSaveDeviceSettings(event: Event): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyToClipboard(
|
async function copyToClipboard(text: string, description: string): Promise<void> {
|
||||||
text: string,
|
|
||||||
description: string,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
showToast(`${description} copied!`, "success");
|
showToast(`${description} copied!`, "success");
|
||||||
@@ -551,16 +462,10 @@ async function copyToClipboard(
|
|||||||
|
|
||||||
async function handleRevokeDevice(): Promise<void> {
|
async function handleRevokeDevice(): Promise<void> {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
const settingsDeviceIdInput = document.getElementById(
|
const settingsDeviceIdInput = document.getElementById("settings-device-id") as HTMLInputElement;
|
||||||
"settings-device-id",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
if (!token || !settingsDeviceIdInput) return;
|
if (!token || !settingsDeviceIdInput) return;
|
||||||
|
|
||||||
if (
|
if (!confirm("Are you sure you want to revoke this device? It will no longer be able to sync."))
|
||||||
!confirm(
|
|
||||||
"Are you sure you want to revoke this device? It will no longer be able to sync.",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const deviceId = settingsDeviceIdInput.value;
|
const deviceId = settingsDeviceIdInput.value;
|
||||||
|
|||||||
+10
-5
@@ -30,9 +30,7 @@ function toggleSection(el: HTMLElement): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initializeDocsSearch(): void {
|
function initializeDocsSearch(): void {
|
||||||
const searchInput = document.getElementById(
|
const searchInput = document.getElementById("docs-search") as HTMLInputElement;
|
||||||
"docs-search",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const searchResults = document.getElementById("docs-search-results");
|
const searchResults = document.getElementById("docs-search-results");
|
||||||
|
|
||||||
if (!searchInput || !searchResults) return;
|
if (!searchInput || !searchResults) return;
|
||||||
@@ -132,7 +130,8 @@ function initializeCodeCopyButtons(): void {
|
|||||||
wrapper.appendChild(pre);
|
wrapper.appendChild(pre);
|
||||||
|
|
||||||
const button = document.createElement("button");
|
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.textContent = "Copy";
|
||||||
button.onclick = async () => {
|
button.onclick = async () => {
|
||||||
await navigator.clipboard.writeText(block.textContent || "");
|
await navigator.clipboard.writeText(block.textContent || "");
|
||||||
@@ -154,4 +153,10 @@ Alpine.data("docs", () => ({
|
|||||||
initializeCodeCopyButtons,
|
initializeCodeCopyButtons,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export { toggleSidebar, toggleSection, initializeDocsSearch, highlightCurrentPage, initializeCodeCopyButtons };
|
export {
|
||||||
|
toggleSidebar,
|
||||||
|
toggleSection,
|
||||||
|
initializeDocsSearch,
|
||||||
|
highlightCurrentPage,
|
||||||
|
initializeCodeCopyButtons,
|
||||||
|
};
|
||||||
+7
-29
@@ -50,61 +50,39 @@ function onDelegatedKeydown(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDataAttribute(
|
function getDataAttribute(element: HTMLElement, name: string): string | undefined {
|
||||||
element: HTMLElement,
|
|
||||||
name: string,
|
|
||||||
): string | undefined {
|
|
||||||
return element.dataset[name];
|
return element.dataset[name];
|
||||||
}
|
}
|
||||||
|
|
||||||
function setDataAttribute(
|
function setDataAttribute(element: HTMLElement, name: string, value: string): void {
|
||||||
element: HTMLElement,
|
|
||||||
name: string,
|
|
||||||
value: string,
|
|
||||||
): void {
|
|
||||||
element.dataset[name] = value;
|
element.dataset[name] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onClick(
|
function onClick(element: HTMLElement | null, handler: (event: MouseEvent) => void): void {
|
||||||
element: HTMLElement | null,
|
|
||||||
handler: (event: MouseEvent) => void,
|
|
||||||
): void {
|
|
||||||
if (element) {
|
if (element) {
|
||||||
element.addEventListener("click", handler);
|
element.addEventListener("click", handler);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSubmit(
|
function onSubmit(element: HTMLFormElement | null, handler: (event: Event) => void): void {
|
||||||
element: HTMLFormElement | null,
|
|
||||||
handler: (event: Event) => void,
|
|
||||||
): void {
|
|
||||||
if (element) {
|
if (element) {
|
||||||
element.addEventListener("submit", handler);
|
element.addEventListener("submit", handler);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onChange(
|
function onChange(element: HTMLElement | null, handler: (event: Event) => void): void {
|
||||||
element: HTMLElement | null,
|
|
||||||
handler: (event: Event) => void,
|
|
||||||
): void {
|
|
||||||
if (element) {
|
if (element) {
|
||||||
element.addEventListener("change", handler);
|
element.addEventListener("change", handler);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeydown(
|
function onKeydown(element: HTMLElement | null, handler: (event: KeyboardEvent) => void): void {
|
||||||
element: HTMLElement | null,
|
|
||||||
handler: (event: KeyboardEvent) => void,
|
|
||||||
): void {
|
|
||||||
if (element) {
|
if (element) {
|
||||||
element.addEventListener("keydown", handler);
|
element.addEventListener("keydown", handler);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onInput(
|
function onInput(element: HTMLElement | null, handler: (event: Event) => void): void {
|
||||||
element: HTMLElement | null,
|
|
||||||
handler: (event: Event) => void,
|
|
||||||
): void {
|
|
||||||
if (element) {
|
if (element) {
|
||||||
element.addEventListener("input", handler);
|
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";
|
arity: "none" | "one" | "two" | "many";
|
||||||
}
|
}
|
||||||
|
|
||||||
const op = (
|
const op = (key: string, label: string, negated: boolean, arity: OpMeta["arity"]): OpMeta => ({
|
||||||
key: string,
|
key,
|
||||||
label: string,
|
label,
|
||||||
negated: boolean,
|
negated,
|
||||||
arity: OpMeta["arity"],
|
arity,
|
||||||
): OpMeta => ({ key, label, negated, arity });
|
});
|
||||||
|
|
||||||
// The map key DERIVES from meta.key — the wire value and the display
|
// The map key DERIVES from meta.key — the wire value and the display
|
||||||
// label cannot drift apart again (a mis-keyed entry threw every
|
// 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("isbn", "ISBN", "Book facts", "text", ["is", "is_not", "has", "missing"], "has"),
|
||||||
F("asin", "ASIN", "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_size", "File size", "Book facts", "number", NUM_OPS, "at_least"),
|
||||||
F("file_availability", "File availability", "Book facts", "fixed",
|
F(
|
||||||
["is", "is_not"], "is", false, ["available", "missing"]),
|
"file_availability",
|
||||||
|
"File availability",
|
||||||
|
"Book facts",
|
||||||
|
"fixed",
|
||||||
|
["is", "is_not"],
|
||||||
|
"is",
|
||||||
|
false,
|
||||||
|
["available", "missing"],
|
||||||
|
),
|
||||||
F("cover", "Cover", "Book facts", "none", ["has", "missing"], "has"),
|
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),
|
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("volume", "Volume", "Comics & manga", "number", NUM_OPS, "at_least"),
|
||||||
F("format_group", "Format group", "Comics & manga", "text", ["is", "is_not"], "is", true),
|
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("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(
|
||||||
F("has_fixed_layout", "Fixed layout", "Comics & manga", "none", ["is_true", "is_false"], "is_true"),
|
"is_reflowable",
|
||||||
F("is_black_and_white", "Black & white", "Comics & manga", "none", ["is_true", "is_false"], "is_true"),
|
"Reflowable",
|
||||||
F("manga_type", "Manga type", "Comics & manga", "text", ["is", "is_not", "empty"], "is", true),
|
"Comics & manga",
|
||||||
F("reading_direction", "Reading direction", "Comics & manga", "text", ["is", "is_not", "empty"], "is", true),
|
"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",
|
group: "My reading",
|
||||||
fields: [
|
fields: [
|
||||||
F("status", "Reading status", "My reading", "fixed", ["is"], "is", false,
|
F("status", "Reading status", "My reading", "fixed", ["is"], "is", false, [
|
||||||
["unread", "reading", "finished"]),
|
"unread",
|
||||||
|
"reading",
|
||||||
|
"finished",
|
||||||
|
]),
|
||||||
F("progress_percent", "Progress percent", "My reading", "number", NUM_OPS, "at_least"),
|
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("last_read", "Last read", "My reading", "days", DATE_OPS, "within_days"),
|
||||||
F("my_rating", "My rating", "My reading", "fixed",
|
F(
|
||||||
["is", "at_least", "at_most", "unrated"], "at_least", false,
|
"my_rating",
|
||||||
["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]),
|
"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("review", "Has review", "My reading", "none", ["has", "missing"], "has"),
|
||||||
F("notes", "Notes", "My reading", "none", ["has", "missing", "within_days"], "has"),
|
F("notes", "Notes", "My reading", "none", ["has", "missing", "within_days"], "has"),
|
||||||
F("highlights", "Highlights", "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(
|
||||||
F("highlight_text", "Highlight text", "My reading", "text", ["contains", "not_contains", "empty"], "contains", true),
|
"note_text",
|
||||||
F("time_spent", "Time spent (minutes)", "My reading", "number", ["between", "at_least", "at_most"], "at_least"),
|
"Note text",
|
||||||
F("pages_read", "Pages read", "My reading", "number", ["between", "at_least", "at_most"], "at_least"),
|
"My reading",
|
||||||
F("collection", "In collection", "My reading", "id",
|
"text",
|
||||||
["in", "not_in", "in_any", "in_none"], "in", true),
|
["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",
|
group: "Library",
|
||||||
fields: [
|
fields: [F("library", "Library", "Library", "id", ["is", "is_not"], "is", true)],
|
||||||
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.
|
// Grouped ops per field, precomputed in the field's op order.
|
||||||
const OPS_BY_FIELD = new Map<string, OpMeta[]>();
|
const OPS_BY_FIELD = new Map<string, OpMeta[]>();
|
||||||
for (const [, f] of FIELD_BY_KEY) {
|
for (const [, f] of FIELD_BY_KEY) {
|
||||||
OPS_BY_FIELD.set(
|
OPS_BY_FIELD.set(f.key, f.ops.map((k) => OPS[k]).filter(Boolean));
|
||||||
f.key,
|
|
||||||
f.ops.map((k) => OPS[k]).filter(Boolean),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flat-expressible fields: key → the flat wire param a `contains`/`is`
|
// Flat-expressible fields: key → the flat wire param a `contains`/`is`
|
||||||
@@ -237,8 +331,7 @@ export interface FilterGroupState {
|
|||||||
implicit?: boolean;
|
implicit?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isRule = (n: FilterRuleState | FilterGroupState): n is FilterRuleState =>
|
export const isRule = (n: FilterRuleState | FilterGroupState): n is FilterRuleState => "field" in n;
|
||||||
"field" in n;
|
|
||||||
|
|
||||||
// flatParamNames are the wire params the flat deep-link contract pins
|
// flatParamNames are the wire params the flat deep-link contract pins
|
||||||
// (plus the two flat extras the builder owns).
|
// (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.
|
// 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(",");
|
const joined = rule.values.join(",");
|
||||||
if (!joined) return null;
|
if (!joined) return null;
|
||||||
const flat = FLAT_PARAMS[rule.field];
|
const flat = FLAT_PARAMS[rule.field];
|
||||||
if (flat && rule.op === "contains") return { [flat]: joined };
|
if (flat && rule.op === "contains") return { [flat]: joined };
|
||||||
if (flat && rule.op === "is" &&
|
if (
|
||||||
["age_rating", "format_group", "manga_type", "reading_direction"].includes(rule.field)) {
|
flat &&
|
||||||
|
rule.op === "is" &&
|
||||||
|
["age_rating", "format_group", "manga_type", "reading_direction"].includes(rule.field)
|
||||||
|
) {
|
||||||
return { [flat]: joined };
|
return { [flat]: joined };
|
||||||
}
|
}
|
||||||
if (flat && rule.op === "is" &&
|
if (flat && rule.op === "is" && ["story_arc", "imprint", "edition"].includes(rule.field)) {
|
||||||
["story_arc", "imprint", "edition"].includes(rule.field)) {
|
|
||||||
return { [flat]: joined };
|
return { [flat]: joined };
|
||||||
}
|
}
|
||||||
if (rule.field === "cover") {
|
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
|
// isFlatable — the smart-serialization rule: a single ALL group of
|
||||||
// rules, one rule per field, every rule flat-expressible.
|
// 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;
|
if (tree.mode !== "all") return false;
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
for (const child of tree.children) {
|
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
|
// groups left empty, and can exclude the rule being edited (suggestion
|
||||||
// symmetry). Returns null when nothing complete remains — callers then
|
// symmetry). Returns null when nothing complete remains — callers then
|
||||||
// send NO tree at all (an empty group is a server-side 400).
|
// send NO tree at all (an empty group is a server-side 400).
|
||||||
function pruneIncomplete(
|
export function pruneIncomplete(
|
||||||
group: FilterGroupState,
|
group: FilterGroupState,
|
||||||
excludePath: number[] | null,
|
excludePath: number[] | null,
|
||||||
path: number[] = [],
|
path: number[] = [],
|
||||||
@@ -362,8 +457,7 @@ function projectRows(
|
|||||||
const childPath = [...path, idx];
|
const childPath = [...path, idx];
|
||||||
const key = childPath.join("-");
|
const key = childPath.join("-");
|
||||||
// The slot chip: how this child joins the row above it.
|
// The slot chip: how this child joins the row above it.
|
||||||
const slotChip: "AND" | "OR" =
|
const slotChip: "AND" | "OR" = idx === 0 || group.mode === "all" ? "AND" : "OR";
|
||||||
idx === 0 || group.mode === "all" ? "AND" : "OR";
|
|
||||||
if (!isRule(child)) {
|
if (!isRule(child)) {
|
||||||
const g = child as FilterGroupState;
|
const g = child as FilterGroupState;
|
||||||
if (g.implicit) {
|
if (g.implicit) {
|
||||||
@@ -412,10 +506,7 @@ function projectRows(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const prev = group.children[idx - 1];
|
const prev = group.children[idx - 1];
|
||||||
const clickable =
|
const clickable = idx > 0 && !!prev && (isRule(prev) || !!(prev as FilterGroupState).implicit);
|
||||||
idx > 0 &&
|
|
||||||
!!prev &&
|
|
||||||
(isRule(prev) || !!(prev as FilterGroupState).implicit);
|
|
||||||
out.push({
|
out.push({
|
||||||
key,
|
key,
|
||||||
path: childPath,
|
path: childPath,
|
||||||
@@ -682,8 +773,7 @@ export function filterBuilderData() {
|
|||||||
return FIELD_GROUPS.map((g) => ({
|
return FIELD_GROUPS.map((g) => ({
|
||||||
group: g.group,
|
group: g.group,
|
||||||
fields: g.fields.filter(
|
fields: g.fields.filter(
|
||||||
(f) =>
|
(f) => f.label.toLowerCase().includes(q) || f.key.toLowerCase().includes(q),
|
||||||
f.label.toLowerCase().includes(q) || f.key.toLowerCase().includes(q),
|
|
||||||
),
|
),
|
||||||
})).filter((g) => g.fields.length > 0);
|
})).filter((g) => g.fields.length > 0);
|
||||||
},
|
},
|
||||||
@@ -719,9 +809,8 @@ export function filterBuilderData() {
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set(rule.field, typed || " ");
|
params.set(rule.field, typed || " ");
|
||||||
if (others) params.set("qf", JSON.stringify(others));
|
if (others) params.set("qf", JSON.stringify(others));
|
||||||
const currentLibraryId = (
|
const currentLibraryId = (document.getElementById("library-select") as HTMLSelectElement)
|
||||||
document.getElementById("library-select") as HTMLSelectElement
|
?.value;
|
||||||
)?.value;
|
|
||||||
if (currentLibraryId) params.set("library_id", currentLibraryId);
|
if (currentLibraryId) params.set("library_id", currentLibraryId);
|
||||||
// Browse (no typing) shows a slightly wider window than filter-
|
// Browse (no typing) shows a slightly wider window than filter-
|
||||||
// as-you-type.
|
// as-you-type.
|
||||||
@@ -790,9 +879,8 @@ export function filterBuilderData() {
|
|||||||
}
|
}
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set("qf", JSON.stringify(completed));
|
params.set("qf", JSON.stringify(completed));
|
||||||
const currentLibraryId = (
|
const currentLibraryId = (document.getElementById("library-select") as HTMLSelectElement)
|
||||||
document.getElementById("library-select") as HTMLSelectElement
|
?.value;
|
||||||
)?.value;
|
|
||||||
if (currentLibraryId) params.set("library_id", currentLibraryId);
|
if (currentLibraryId) params.set("library_id", currentLibraryId);
|
||||||
this.matchCountLoading = true;
|
this.matchCountLoading = true;
|
||||||
try {
|
try {
|
||||||
@@ -911,13 +999,25 @@ export function filterBuilderData() {
|
|||||||
if (cover && cover.value) {
|
if (cover && cover.value) {
|
||||||
rules.push({ field: "cover", op: cover.value === "true" ? "has" : "missing", values: [] });
|
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) {
|
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) {
|
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;
|
const yearMin = form.querySelector('input[name="year_min"]') as HTMLInputElement | null;
|
||||||
if (yearMin && yearMin.value) {
|
if (yearMin && yearMin.value) {
|
||||||
|
|||||||
+1
-5
@@ -21,11 +21,7 @@ const restoreLibrarySelection = (): void => {
|
|||||||
const savedLibrary = getSelectedLibrary();
|
const savedLibrary = getSelectedLibrary();
|
||||||
if (!savedLibrary) return;
|
if (!savedLibrary) return;
|
||||||
|
|
||||||
const dropdownSelectors = [
|
const dropdownSelectors = ["#library-select", "#selected-library", "#library_id"];
|
||||||
"#library-select",
|
|
||||||
"#selected-library",
|
|
||||||
"#library_id",
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const selector of dropdownSelectors) {
|
for (const selector of dropdownSelectors) {
|
||||||
const dropdown = document.querySelector(selector) as HTMLSelectElement;
|
const dropdown = document.querySelector(selector) as HTMLSelectElement;
|
||||||
|
|||||||
@@ -43,11 +43,7 @@ export async function switchWithTransition(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
container.classList.add(
|
container.classList.add("opacity-0", "transition-opacity", "duration-150");
|
||||||
"opacity-0",
|
|
||||||
"transition-opacity",
|
|
||||||
"duration-150",
|
|
||||||
);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
|
||||||
loading.classList.remove("hidden");
|
loading.classList.remove("hidden");
|
||||||
@@ -68,12 +64,7 @@ export async function switchWithTransition(
|
|||||||
container.classList.remove("transition-opacity", "duration-300");
|
container.classList.remove("transition-opacity", "duration-300");
|
||||||
}, 300);
|
}, 300);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
container.classList.remove(
|
container.classList.remove("opacity-0", "transition-opacity", "duration-150", "duration-300");
|
||||||
"opacity-0",
|
|
||||||
"transition-opacity",
|
|
||||||
"duration-150",
|
|
||||||
"duration-300",
|
|
||||||
);
|
|
||||||
showToast("Failed to load library", "error");
|
showToast("Failed to load library", "error");
|
||||||
console.error("Switch library error:", error);
|
console.error("Switch library error:", error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -82,22 +73,20 @@ export async function switchWithTransition(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LibrarySwitcherOptions {
|
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 {
|
export function initLibrarySwitcher(options: LibrarySwitcherOptions): void {
|
||||||
const librarySelect = document.getElementById(
|
const librarySelect = document.getElementById("library-select") as HTMLSelectElement;
|
||||||
"library-select",
|
|
||||||
) as HTMLSelectElement;
|
|
||||||
if (!librarySelect) return;
|
if (!librarySelect) return;
|
||||||
|
|
||||||
const stored = localStorage.getItem("selectedLibrary");
|
const stored = localStorage.getItem("selectedLibrary");
|
||||||
if (stored === ALL_LIBRARIES) {
|
if (stored === ALL_LIBRARIES) {
|
||||||
librarySelect.value = "";
|
librarySelect.value = "";
|
||||||
} else if (stored) {
|
} else if (stored) {
|
||||||
const option = librarySelect.querySelector(
|
const option = librarySelect.querySelector(`option[value="${stored}"]`);
|
||||||
`option[value="${stored}"]`,
|
|
||||||
);
|
|
||||||
if (option) {
|
if (option) {
|
||||||
librarySelect.value = stored;
|
librarySelect.value = stored;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-8
@@ -73,10 +73,7 @@ function renderUnlinkedBooks(books: UnlinkedBookData[]): void {
|
|||||||
.join("");
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function linkBook(
|
async function linkBook(progressId: string, mediaItemId: string): Promise<void> {
|
||||||
progressId: string,
|
|
||||||
mediaItemId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
@@ -151,10 +148,7 @@ async function getSuggestions(progressId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showSuggestionsModal(
|
function showSuggestionsModal(progressId: string, suggestions: PotentialMatchData[]): void {
|
||||||
progressId: string,
|
|
||||||
suggestions: PotentialMatchData[],
|
|
||||||
): void {
|
|
||||||
const modal = document.getElementById("match-modal");
|
const modal = document.getElementById("match-modal");
|
||||||
const content = document.getElementById("match-modal-content");
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
return /[0-9]/.test(password);
|
||||||
}
|
}
|
||||||
function hasSpecialChar(password: string): boolean {
|
function hasSpecialChar(password: string): boolean {
|
||||||
return /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password);
|
return /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>?]/.test(password);
|
||||||
}
|
}
|
||||||
function passwordsMatch(password: string, confirm: string): boolean {
|
function passwordsMatch(password: string, confirm: string): boolean {
|
||||||
return password.length > 0 && password === confirm;
|
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 {
|
function ensureChecklist(containerId: string, rules: PasswordRules, hasConfirm: boolean): void {
|
||||||
const container = document.getElementById(containerId);
|
const container = document.getElementById(containerId);
|
||||||
if (!container) return;
|
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 (container.tagName === "UL") list = container; // container IS the list
|
||||||
if (!list) {
|
if (!list) {
|
||||||
container.insertAdjacentHTML(
|
container.insertAdjacentHTML(
|
||||||
|
|||||||
@@ -60,17 +60,14 @@ export function buildPageText(
|
|||||||
const t = item.transform ?? [1, 0, 0, 1, 0, 0];
|
const t = item.transform ?? [1, 0, 0, 1, 0, 0];
|
||||||
const baseline = t[5] ?? 0;
|
const baseline = t[5] ?? 0;
|
||||||
const x = t[4] ?? 0;
|
const x = t[4] ?? 0;
|
||||||
const size =
|
const size = Math.abs(item.height) || Math.abs(t[3]) || Math.abs(t[0]) || 10;
|
||||||
Math.abs(item.height) || Math.abs(t[3]) || Math.abs(t[0]) || 10;
|
|
||||||
const w = Math.abs(item.width) || 0;
|
const w = Math.abs(item.width) || 0;
|
||||||
const h = size;
|
const h = size;
|
||||||
|
|
||||||
let sep = "";
|
let sep = "";
|
||||||
if (text && !text.endsWith(" ") && prevRight != null) {
|
if (text && !text.endsWith(" ") && prevRight != null) {
|
||||||
const newLine =
|
const newLine =
|
||||||
item.hasEOL ||
|
item.hasEOL || prevBaseline == null || Math.abs(baseline - prevBaseline) > size * 0.5;
|
||||||
prevBaseline == null ||
|
|
||||||
Math.abs(baseline - prevBaseline) > size * 0.5;
|
|
||||||
const gap = x - prevRight;
|
const gap = x - prevRight;
|
||||||
if (newLine || gap > size * 0.2) sep = " ";
|
if (newLine || gap > size * 0.2) sep = " ";
|
||||||
}
|
}
|
||||||
@@ -106,9 +103,7 @@ export async function extractPdfPages(
|
|||||||
const page = await pdf.getPage(i + 1);
|
const page = await pdf.getPage(i + 1);
|
||||||
const viewport = page.getViewport({ scale: 1 });
|
const viewport = page.getViewport({ scale: 1 });
|
||||||
const tc = await page.getTextContent();
|
const tc = await page.getTextContent();
|
||||||
pages.push(
|
pages.push(buildPageText(tc.items, viewport.width, viewport.height, i));
|
||||||
buildPageText(tc.items, viewport.width, viewport.height, i),
|
|
||||||
);
|
|
||||||
onProgress?.((i + 1) / num);
|
onProgress?.((i + 1) / num);
|
||||||
}
|
}
|
||||||
return pages;
|
return pages;
|
||||||
|
|||||||
+223
-449
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();
|
const settings = await response.json();
|
||||||
localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(settings));
|
localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(settings));
|
||||||
return settings;
|
return settings;
|
||||||
} catch (error) {
|
} catch {
|
||||||
const local = localStorage.getItem(LOCALSTORAGE_KEY);
|
const local = localStorage.getItem(LOCALSTORAGE_KEY);
|
||||||
return local ? JSON.parse(local) : getDefaultSettings();
|
return local ? JSON.parse(local) : getDefaultSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export async function saveSettings(
|
export async function saveSettings(settings: Partial<ReaderSettings>): Promise<void> {
|
||||||
settings: Partial<ReaderSettings>,
|
|
||||||
): Promise<void> {
|
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
const current = await loadSettings();
|
const current = await loadSettings();
|
||||||
const updated = { ...current, ...settings };
|
const updated = { ...current, ...settings };
|
||||||
@@ -41,7 +39,7 @@ export async function saveSettings(
|
|||||||
body: JSON.stringify(settings),
|
body: JSON.stringify(settings),
|
||||||
});
|
});
|
||||||
localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
|
localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
|
||||||
} catch (error) {
|
} catch {
|
||||||
localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
|
localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-18
@@ -5,9 +5,7 @@ const SEARCH_DEBOUNCE_MS = 300;
|
|||||||
const SEARCH_MIN_CHARS = 2;
|
const SEARCH_MIN_CHARS = 2;
|
||||||
|
|
||||||
function initializeSearch(): void {
|
function initializeSearch(): void {
|
||||||
const searchInput = document.getElementById(
|
const searchInput = document.getElementById("header-search") as HTMLInputElement | null;
|
||||||
"header-search",
|
|
||||||
) as HTMLInputElement | null;
|
|
||||||
const mobileSearchInput = document.getElementById(
|
const mobileSearchInput = document.getElementById(
|
||||||
"header-search-mobile",
|
"header-search-mobile",
|
||||||
) as HTMLInputElement | null;
|
) as HTMLInputElement | null;
|
||||||
@@ -107,11 +105,9 @@ function selectSearchResult(items: NodeListOf<Element>, index: number): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getActiveSearchInput(): HTMLInputElement | null {
|
function getActiveSearchInput(): HTMLInputElement | null {
|
||||||
return (
|
return document.activeElement?.id === "header-search-mobile"
|
||||||
document.activeElement?.id === "header-search-mobile"
|
|
||||||
? (document.getElementById("header-search-mobile") as HTMLInputElement)
|
? (document.getElementById("header-search-mobile") as HTMLInputElement)
|
||||||
: (document.getElementById("header-search") as HTMLInputElement)
|
: (document.getElementById("header-search") as HTMLInputElement);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function performSearch(query: string): void {
|
function performSearch(query: string): void {
|
||||||
@@ -138,12 +134,7 @@ function performSearch(query: string): void {
|
|||||||
}
|
}
|
||||||
return response.json();
|
return response.json();
|
||||||
})
|
})
|
||||||
.then(
|
.then((data: { error?: string; results?: MediaItemSummary[] } | MediaItemSummary[]) => {
|
||||||
(
|
|
||||||
data:
|
|
||||||
| { error?: string; results?: MediaItemSummary[] }
|
|
||||||
| MediaItemSummary[],
|
|
||||||
) => {
|
|
||||||
hideSearchLoading();
|
hideSearchLoading();
|
||||||
|
|
||||||
const resultActiveId = getActiveSearchInput()?.id;
|
const resultActiveId = getActiveSearchInput()?.id;
|
||||||
@@ -157,8 +148,7 @@ function performSearch(query: string): void {
|
|||||||
} else {
|
} else {
|
||||||
showNoResults(query, resultActiveId);
|
showNoResults(query, resultActiveId);
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
)
|
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
hideSearchLoading();
|
hideSearchLoading();
|
||||||
console.error("Search error:", error);
|
console.error("Search error:", error);
|
||||||
@@ -292,9 +282,7 @@ function createSearchResultsContainer(activeId?: string): void {
|
|||||||
searchResults.style.cssText =
|
searchResults.style.cssText =
|
||||||
"background-color: var(--bg-secondary); border-color: var(--border)";
|
"background-color: var(--bg-secondary); border-color: var(--border)";
|
||||||
|
|
||||||
const searchInput = document.getElementById(
|
const searchInput = document.getElementById(activeId || "header-search");
|
||||||
activeId || "header-search",
|
|
||||||
);
|
|
||||||
if (searchInput) {
|
if (searchInput) {
|
||||||
const searchContainer = searchInput.closest(".relative");
|
const searchContainer = searchInput.closest(".relative");
|
||||||
if (searchContainer) {
|
if (searchContainer) {
|
||||||
|
|||||||
+12
-7
@@ -36,7 +36,8 @@ function initSetup(): void {
|
|||||||
if (savedTheme && savedTheme !== "tokyo-night") {
|
if (savedTheme && savedTheme !== "tokyo-night") {
|
||||||
applyTheme(savedTheme);
|
applyTheme(savedTheme);
|
||||||
}
|
}
|
||||||
}Alpine.data("setupWizard", () => ({
|
}
|
||||||
|
Alpine.data("setupWizard", () => ({
|
||||||
currentStep: 1 as number,
|
currentStep: 1 as number,
|
||||||
loading: false as boolean,
|
loading: false as boolean,
|
||||||
errorMsg: "" as string,
|
errorMsg: "" as string,
|
||||||
@@ -110,7 +111,9 @@ function initSetup(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
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()
|
getToken()
|
||||||
) {
|
) {
|
||||||
this.currentStep = 2;
|
this.currentStep = 2;
|
||||||
@@ -140,11 +143,12 @@ function initSetup(): void {
|
|||||||
async saveBaseUrl() {
|
async saveBaseUrl() {
|
||||||
if (!this.admin.baseUrl) return;
|
if (!this.admin.baseUrl) return;
|
||||||
try {
|
try {
|
||||||
await handleVoidResponse(
|
await handleVoidResponse(await apiPut("/system/config", { base_url: this.admin.baseUrl }));
|
||||||
await apiPut("/system/config", { base_url: this.admin.baseUrl })
|
|
||||||
);
|
|
||||||
} catch {
|
} 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) {
|
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 {
|
} else {
|
||||||
data.directories.forEach((dir) => {
|
data.directories.forEach((dir) => {
|
||||||
const fullPath = data.current_path === "/" ? `/${dir}` : `${data.current_path}/${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
|
// htmx is loaded as a classic script on every page and used from TS for
|
||||||
// partial swaps (rendering-ownership rule: templ renders, JS swaps).
|
// 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 {
|
interface HtmxAjaxOptions {
|
||||||
target?: string;
|
target?: string;
|
||||||
swap?: string;
|
swap?: string;
|
||||||
@@ -10,13 +12,12 @@ interface HtmxAjaxOptions {
|
|||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Window {
|
interface Htmx {
|
||||||
htmx: {
|
ajax(method: string, url: string, options?: HtmxAjaxOptions): Promise<boolean>;
|
||||||
ajax(
|
|
||||||
method: string,
|
|
||||||
url: string,
|
|
||||||
options?: HtmxAjaxOptions,
|
|
||||||
): Promise<boolean>;
|
|
||||||
process(element: Element): void;
|
process(element: Element): void;
|
||||||
};
|
trigger(element: Element | string, eventName: string, detail?: Record<string, unknown>): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
htmx: Htmx;
|
||||||
}
|
}
|
||||||
+64
-28
@@ -137,9 +137,13 @@ let libraryDataLoaded = false;
|
|||||||
|
|
||||||
// Page-scoped helpers -----------------------------------------------------
|
// 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");
|
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 {
|
function text(id: string, value: string): void {
|
||||||
@@ -147,11 +151,6 @@ function text(id: string, value: string): void {
|
|||||||
if (el) el.textContent = value;
|
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 {
|
function markRangeButtons(active: string): void {
|
||||||
document.querySelectorAll<HTMLElement>(".stats-range-btn").forEach((btn) => {
|
document.querySelectorAll<HTMLElement>(".stats-range-btn").forEach((btn) => {
|
||||||
const isActive = btn.dataset.range === active;
|
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;
|
const hasData = summary.total_minutes > 0 || summary.session_count > 0;
|
||||||
document.getElementById("stats-empty")?.classList.toggle("hidden", hasData);
|
document.getElementById("stats-empty")?.classList.toggle("hidden", hasData);
|
||||||
const windowLabel = document.getElementById("stats-window-label");
|
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;
|
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);
|
renderPagesCard(summary);
|
||||||
text(
|
text(
|
||||||
"stats-streaks",
|
"stats-streaks",
|
||||||
@@ -315,7 +318,9 @@ function renderSummary(summary: Summary): void {
|
|||||||
);
|
);
|
||||||
text(
|
text(
|
||||||
"stats-books-finished",
|
"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");
|
const mostActive = document.getElementById("stats-most-active");
|
||||||
@@ -349,7 +354,9 @@ async function renderDailyCharts(daily: DailyEntry[]): Promise<void> {
|
|||||||
tooltip: {
|
tooltip: {
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: (ctx) => {
|
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}`;
|
return `${Math.round(v * 10) / 10} ${unit}`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -357,7 +364,11 @@ async function renderDailyCharts(daily: DailyEntry[]): Promise<void> {
|
|||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
x: { grid: { display: false }, ticks: { color: secondary, maxTicksLimit: 10 } },
|
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;
|
pagesChart = null;
|
||||||
listeningChart = 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) {
|
if (minutesCanvas) {
|
||||||
minutesChart = new ChartCtor(minutesCanvas, {
|
minutesChart = new ChartCtor(minutesCanvas, {
|
||||||
type: "bar",
|
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) {
|
if (pagesCanvas) {
|
||||||
pagesChart = new ChartCtor(pagesCanvas, {
|
pagesChart = new ChartCtor(pagesCanvas, {
|
||||||
type: "line",
|
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
|
// Listening per Day (hrs) — the audio half of the side-by-side pages
|
||||||
// split; its panel is hidden by renderPagesCard unless the range has
|
// split; its panel is hidden by renderPagesCard unless the range has
|
||||||
// audio minutes.
|
// 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) {
|
if (listeningCanvas) {
|
||||||
listeningChart = new ChartCtor(listeningCanvas, {
|
listeningChart = new ChartCtor(listeningCanvas, {
|
||||||
type: "bar",
|
type: "bar",
|
||||||
@@ -426,26 +443,32 @@ async function renderDailyCharts(daily: DailyEntry[]): Promise<void> {
|
|||||||
function renderLibraries(rows: LibraryRow[]): void {
|
function renderLibraries(rows: LibraryRow[]): void {
|
||||||
const container = document.getElementById("stats-library-breakdown");
|
const container = document.getElementById("stats-library-breakdown");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
renderBars(container, rows.map((r) => ({
|
renderBars(
|
||||||
|
container,
|
||||||
|
rows.map((r) => ({
|
||||||
label: r.name,
|
label: r.name,
|
||||||
sub: `${fmtMinutes(r.minutes)} · ${r.sessions} sessions`,
|
sub: `${fmtMinutes(r.minutes)} · ${r.sessions} sessions`,
|
||||||
value: r.minutes,
|
value: r.minutes,
|
||||||
})));
|
})),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDevices(rows: DeviceRow[]): void {
|
function renderDevices(rows: DeviceRow[]): void {
|
||||||
const container = document.getElementById("stats-device-breakdown");
|
const container = document.getElementById("stats-device-breakdown");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
renderBars(container, rows.map((r) => ({
|
renderBars(
|
||||||
|
container,
|
||||||
|
rows.map((r) => ({
|
||||||
label: r.name || "Web",
|
label: r.name || "Web",
|
||||||
sub: `${r.books} ${r.books === 1 ? "book" : "books"} · ${r.sessions} sessions`,
|
sub: `${r.books} ${r.books === 1 ? "book" : "books"} · ${r.sessions} sessions`,
|
||||||
value: r.minutes,
|
value: r.minutes,
|
||||||
})));
|
})),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtMinutes(minutes: number): string {
|
function fmtMinutes(minutes: number): string {
|
||||||
return minutes >= 60
|
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`;
|
: `${minutes} min`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,10 +545,9 @@ async function loadLibraries(): Promise<VisibleLibrary[]> {
|
|||||||
function fillLibrarySelector(rows: VisibleLibrary[]): void {
|
function fillLibrarySelector(rows: VisibleLibrary[]): void {
|
||||||
const select = document.getElementById("stats-library-select") as HTMLSelectElement | null;
|
const select = document.getElementById("stats-library-select") as HTMLSelectElement | null;
|
||||||
if (!select) return;
|
if (!select) return;
|
||||||
select.innerHTML = `<option value="">All libraries</option>` +
|
select.innerHTML =
|
||||||
rows
|
`<option value="">All libraries</option>` +
|
||||||
.map((r) => `<option value="${escapeHtml(r.id)}">${escapeHtml(r.name)}</option>`)
|
rows.map((r) => `<option value="${escapeHtml(r.id)}">${escapeHtml(r.name)}</option>`).join("");
|
||||||
.join("");
|
|
||||||
// Honor a URL-seeded selection initStatsPage may have set.
|
// Honor a URL-seeded selection initStatsPage may have set.
|
||||||
if (currentLibraryID) select.value = currentLibraryID;
|
if (currentLibraryID) select.value = currentLibraryID;
|
||||||
}
|
}
|
||||||
@@ -545,9 +567,15 @@ async function loadLibraryUsage(): Promise<void> {
|
|||||||
"lib-stat-narrators",
|
"lib-stat-narrators",
|
||||||
];
|
];
|
||||||
targets.forEach((id) => text(id, "–"));
|
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);
|
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 {
|
try {
|
||||||
@@ -598,7 +626,10 @@ function renderLibraryUsage(payload: LibraryUsage): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const gb = payload.total_size_bytes / 1e9;
|
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);
|
const itemTotal = Math.max(payload.items, 1);
|
||||||
renderBars(
|
renderBars(
|
||||||
@@ -619,7 +650,12 @@ function renderLibraryUsage(payload: LibraryUsage): void {
|
|||||||
})),
|
})),
|
||||||
"No authors recorded in this pool yet.",
|
"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);
|
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,
|
setRefreshToken,
|
||||||
setSelectedLibrary,
|
setSelectedLibrary,
|
||||||
setTheme,
|
setTheme,
|
||||||
setToken
|
setToken,
|
||||||
};
|
};
|
||||||
+6
-19
@@ -68,9 +68,7 @@ const applyTheme = (theme: string): void => {
|
|||||||
|
|
||||||
// Load theme from localStorage or use default
|
// Load theme from localStorage or use default
|
||||||
const loadTheme = (): void => {
|
const loadTheme = (): void => {
|
||||||
const storedTheme = localStorage.getItem(
|
const storedTheme = localStorage.getItem(THEME_STORAGE_KEY) as ThemeType | null;
|
||||||
THEME_STORAGE_KEY,
|
|
||||||
) as ThemeType | null;
|
|
||||||
const theme = storedTheme || DEFAULT_THEME;
|
const theme = storedTheme || DEFAULT_THEME;
|
||||||
applyTheme(theme);
|
applyTheme(theme);
|
||||||
};
|
};
|
||||||
@@ -145,9 +143,7 @@ const updateThemeIndicators = (): void => {
|
|||||||
const revealCurrentTheme = (mode: string): void => {
|
const revealCurrentTheme = (mode: string): void => {
|
||||||
const match = document.body.className.match(/theme-([\w-]+)/);
|
const match = document.body.className.match(/theme-([\w-]+)/);
|
||||||
const current = match ? match[1] : DEFAULT_THEME;
|
const current = match ? match[1] : DEFAULT_THEME;
|
||||||
const btn = document.querySelector<HTMLElement>(
|
const btn = document.querySelector<HTMLElement>(`.theme-btn[data-theme="${current}"]`);
|
||||||
`.theme-btn[data-theme="${current}"]`,
|
|
||||||
);
|
|
||||||
if (btn && btn.dataset.mode === mode) {
|
if (btn && btn.dataset.mode === mode) {
|
||||||
btn.scrollIntoView({ block: "nearest" });
|
btn.scrollIntoView({ block: "nearest" });
|
||||||
}
|
}
|
||||||
@@ -160,12 +156,9 @@ const initializeTheme = (): void => {
|
|||||||
updateThemeIndicators();
|
updateThemeIndicators();
|
||||||
|
|
||||||
// Set theme select value to current theme
|
// Set theme select value to current theme
|
||||||
const themeSelect = document.getElementById(
|
const themeSelect = document.getElementById("theme-select") as HTMLSelectElement;
|
||||||
"theme-select",
|
|
||||||
) as HTMLSelectElement;
|
|
||||||
if (themeSelect) {
|
if (themeSelect) {
|
||||||
const currentTheme =
|
const currentTheme = localStorage.getItem(THEME_STORAGE_KEY) || DEFAULT_THEME;
|
||||||
localStorage.getItem(THEME_STORAGE_KEY) || DEFAULT_THEME;
|
|
||||||
themeSelect.value = currentTheme;
|
themeSelect.value = currentTheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,11 +198,7 @@ const applyWoodPaneling = (paneling: WoodPanelingType): void => {
|
|||||||
if (!container || !body) return;
|
if (!container || !body) return;
|
||||||
|
|
||||||
// Remove all wood background classes from container
|
// Remove all wood background classes from container
|
||||||
container.classList.remove(
|
container.classList.remove("bg-wood-light", "bg-wood-dark", "bg-wood-mahogany");
|
||||||
"bg-wood-light",
|
|
||||||
"bg-wood-dark",
|
|
||||||
"bg-wood-mahogany",
|
|
||||||
);
|
|
||||||
container.removeAttribute("data-wood");
|
container.removeAttribute("data-wood");
|
||||||
|
|
||||||
// Remove from body
|
// Remove from body
|
||||||
@@ -231,9 +220,7 @@ const applyWoodPaneling = (paneling: WoodPanelingType): void => {
|
|||||||
|
|
||||||
// Load wood paneling from localStorage on page load
|
// Load wood paneling from localStorage on page load
|
||||||
const loadWoodPaneling = (): void => {
|
const loadWoodPaneling = (): void => {
|
||||||
const stored = localStorage.getItem(
|
const stored = localStorage.getItem(WOOD_STORAGE_KEY) as WoodPanelingType | null;
|
||||||
WOOD_STORAGE_KEY,
|
|
||||||
) as WoodPanelingType | null;
|
|
||||||
if (stored) {
|
if (stored) {
|
||||||
applyWoodPaneling(stored);
|
applyWoodPaneling(stored);
|
||||||
} else {
|
} 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
+40
-21
@@ -1,7 +1,7 @@
|
|||||||
// Toast notification system for backend errors
|
// Toast notification system for backend errors
|
||||||
// Displays toast notifications at the top of the page
|
// 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;
|
const TOAST_DEFAULT_DURATION = 5000;
|
||||||
|
|
||||||
@@ -11,8 +11,7 @@ const createToastContainer = (): HTMLElement => {
|
|||||||
if (!container) {
|
if (!container) {
|
||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
container.id = "toast-container";
|
container.id = "toast-container";
|
||||||
container.className =
|
container.className = "fixed top-5 right-5 z-[9999] flex flex-col gap-2.5 pointer-events-none";
|
||||||
"fixed top-5 right-5 z-[9999] flex flex-col gap-2.5 pointer-events-none";
|
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
}
|
}
|
||||||
return container;
|
return container;
|
||||||
@@ -40,20 +39,32 @@ const getToastConfig = (type: ToastType) => {
|
|||||||
bgClass: "bg-blue-500/90",
|
bgClass: "bg-blue-500/90",
|
||||||
icon: "ℹ️",
|
icon: "ℹ️",
|
||||||
},
|
},
|
||||||
|
warning: {
|
||||||
|
bgClass: "bg-yellow-500/90",
|
||||||
|
icon: "⚠️",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
return configs[type];
|
return configs[type];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create a toast element
|
// Create a toast element
|
||||||
const createToastElement = (message: string, type: ToastType): HTMLElement => {
|
const createToastElement = (
|
||||||
|
message: string,
|
||||||
|
type: ToastType,
|
||||||
|
action?: { label: string; onClick: () => void },
|
||||||
|
): HTMLElement => {
|
||||||
const toast = document.createElement("div");
|
const toast = document.createElement("div");
|
||||||
const config = getToastConfig(type);
|
const config = getToastConfig(type);
|
||||||
|
|
||||||
toast.className = `${config.bgClass} text-white p-4 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px] max-w-[500px] text-sm leading-relaxed pointer-events-auto opacity-0 -translate-y-5 transition-all duration-300 border border-white/10`;
|
toast.className = `${config.bgClass} text-white p-4 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px] max-w-[500px] text-sm leading-relaxed pointer-events-auto opacity-0 -translate-y-5 transition-all duration-300 border border-white/10`;
|
||||||
|
|
||||||
|
const actionHtml = action
|
||||||
|
? `<button class="toast-action bg-transparent border border-white/40 text-white cursor-pointer text-xs font-semibold px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 hover:bg-white/10 transition-colors">${toastEscapeHtml(action.label)}</button>`
|
||||||
|
: "";
|
||||||
toast.innerHTML = `
|
toast.innerHTML = `
|
||||||
<span class="text-xl flex-shrink-0">${config.icon}</span>
|
<span class="text-xl flex-shrink-0">${config.icon}</span>
|
||||||
<span class="flex-1 break-words">${toastEscapeHtml(message)}</span>
|
<span class="flex-1 break-words">${toastEscapeHtml(message)}</span>
|
||||||
|
${actionHtml}
|
||||||
<button class="toast-close bg-transparent border-0 text-white cursor-pointer text-lg p-0 w-5 h-5 flex items-center justify-center opacity-70 hover:opacity-100 flex-shrink-0 transition-opacity">
|
<button class="toast-close bg-transparent border-0 text-white cursor-pointer text-lg p-0 w-5 h-5 flex items-center justify-center opacity-70 hover:opacity-100 flex-shrink-0 transition-opacity">
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -65,6 +76,16 @@ const createToastElement = (message: string, type: ToastType): HTMLElement => {
|
|||||||
closeBtn.onclick = () => removeToast(toast);
|
closeBtn.onclick = () => removeToast(toast);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Action button: run the handler and dismiss (an action consumes the
|
||||||
|
// toast — leaving it up after acting invites double-fires).
|
||||||
|
const actionBtn = toast.querySelector(".toast-action") as HTMLElement | null;
|
||||||
|
if (actionBtn && action) {
|
||||||
|
actionBtn.onclick = () => {
|
||||||
|
removeToast(toast);
|
||||||
|
action.onClick();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return toast;
|
return toast;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -87,14 +108,17 @@ const removeToast = (toast: HTMLElement): void => {
|
|||||||
}, 300);
|
}, 300);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Show toast notification
|
// Show toast notification. `action` (optional): a labeled button for
|
||||||
|
// one-click follow-ups (e.g. the sleep timer's "Resume"); acting
|
||||||
|
// dismisses the toast.
|
||||||
const showToast = (
|
const showToast = (
|
||||||
message: string,
|
message: string,
|
||||||
type: ToastType,
|
type: ToastType,
|
||||||
duration: number = TOAST_DEFAULT_DURATION,
|
duration: number = TOAST_DEFAULT_DURATION,
|
||||||
|
action?: { label: string; onClick: () => void },
|
||||||
): void => {
|
): void => {
|
||||||
const container = createToastContainer();
|
const container = createToastContainer();
|
||||||
const toast = createToastElement(message, type);
|
const toast = createToastElement(message, type, action);
|
||||||
container.appendChild(toast);
|
container.appendChild(toast);
|
||||||
animateToastIn(toast);
|
animateToastIn(toast);
|
||||||
|
|
||||||
@@ -110,7 +134,7 @@ const parseXHRError = (xhr: XMLHttpRequest): string => {
|
|||||||
try {
|
try {
|
||||||
const response = JSON.parse(xhr.responseText);
|
const response = JSON.parse(xhr.responseText);
|
||||||
errorMessage = response.error || response.message || errorMessage;
|
errorMessage = response.error || response.message || errorMessage;
|
||||||
} catch (e) {
|
} catch {
|
||||||
errorMessage = xhr.responseText || errorMessage;
|
errorMessage = xhr.responseText || errorMessage;
|
||||||
}
|
}
|
||||||
return errorMessage;
|
return errorMessage;
|
||||||
@@ -141,11 +165,7 @@ const setupHTMXListeners = (): void => {
|
|||||||
if (customEvent.detail.xhr) {
|
if (customEvent.detail.xhr) {
|
||||||
const xhr = customEvent.detail.xhr;
|
const xhr = customEvent.detail.xhr;
|
||||||
|
|
||||||
if (
|
if (customEvent.detail.succeeded === false && xhr.status >= 400 && xhr.status < 600) {
|
||||||
customEvent.detail.succeeded === false &&
|
|
||||||
xhr.status >= 400 &&
|
|
||||||
xhr.status < 600
|
|
||||||
) {
|
|
||||||
const errorMessage = parseXHRError(xhr);
|
const errorMessage = parseXHRError(xhr);
|
||||||
showToast(errorMessage, "error");
|
showToast(errorMessage, "error");
|
||||||
} else if (xhr.status >= 200 && xhr.status < 300) {
|
} else if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
@@ -159,7 +179,7 @@ const setupHTMXListeners = (): void => {
|
|||||||
customEvent.detail.target.innerHTML = "";
|
customEvent.detail.target.innerHTML = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
// Not valid JSON — ignore
|
// Not valid JSON — ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,7 +208,10 @@ function isSuggestionFetch(input: Parameters<typeof fetch>[0]): boolean {
|
|||||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
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
|
// The builder's live-count endpoint is a background hint too — a
|
||||||
// failed count must never interrupt with a toast.
|
// 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;
|
return false;
|
||||||
}
|
}
|
||||||
if (url.includes("/api/media-items/filter-count?")) return true;
|
if (url.includes("/api/media-items/filter-count?")) return true;
|
||||||
@@ -198,9 +221,7 @@ function isSuggestionFetch(input: Parameters<typeof fetch>[0]): boolean {
|
|||||||
// Setup fetch interceptor
|
// Setup fetch interceptor
|
||||||
const setupFetchInterceptor = (): void => {
|
const setupFetchInterceptor = (): void => {
|
||||||
const originalFetch = window.fetch;
|
const originalFetch = window.fetch;
|
||||||
window.fetch = async (
|
window.fetch = async (...args: Parameters<typeof fetch>): Promise<Response> => {
|
||||||
...args: Parameters<typeof fetch>
|
|
||||||
): Promise<Response> => {
|
|
||||||
try {
|
try {
|
||||||
const response = await originalFetch(...args);
|
const response = await originalFetch(...args);
|
||||||
|
|
||||||
@@ -233,9 +254,7 @@ const setupFetchInterceptor = (): void => {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorMessage = await parseFetchError(response);
|
const errorMessage = await parseFetchError(response);
|
||||||
if (isSuggestionFetch(args[0])) {
|
if (isSuggestionFetch(args[0])) {
|
||||||
console.warn(
|
console.warn(`Suggestion fetch failed (${response.status}): ${errorMessage}`);
|
||||||
`Suggestion fetch failed (${response.status}): ${errorMessage}`,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
showToast(errorMessage, "error");
|
showToast(errorMessage, "error");
|
||||||
}
|
}
|
||||||
@@ -264,5 +283,5 @@ const initializeToastSystem = (): void => {
|
|||||||
|
|
||||||
initializeToastSystem();
|
initializeToastSystem();
|
||||||
|
|
||||||
export { showToast };
|
export { showToast, getToastConfig };
|
||||||
export type { ToastType };
|
export type { ToastType };
|
||||||
Vendored
+8
-11
@@ -59,8 +59,8 @@ interface MediaItemSummary {
|
|||||||
library_type_name: string;
|
library_type_name: string;
|
||||||
|
|
||||||
// NEW: Reading direction fields for manga/comics
|
// NEW: Reading direction fields for manga/comics
|
||||||
manga_type?: 'unknown' | 'no' | 'yes' | 'yes_and_right_to_left';
|
manga_type?: "unknown" | "no" | "yes" | "yes_and_right_to_left";
|
||||||
reading_direction?: 'auto' | 'ltr' | 'rtl' | 'vertical';
|
reading_direction?: "auto" | "ltr" | "rtl" | "vertical";
|
||||||
|
|
||||||
// NEW: Universal metadata fields (apply to ebooks, audiobooks, comics)
|
// NEW: Universal metadata fields (apply to ebooks, audiobooks, comics)
|
||||||
series_count?: number;
|
series_count?: number;
|
||||||
@@ -74,7 +74,11 @@ interface MediaItemSummary {
|
|||||||
// NEW: Comic-specific fields
|
// NEW: Comic-specific fields
|
||||||
story_arc?: string;
|
story_arc?: string;
|
||||||
is_black_and_white?: boolean;
|
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;
|
scan_information?: string;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
}
|
}
|
||||||
@@ -162,14 +166,7 @@ interface PotentialMatchData {
|
|||||||
// Used in: collection_rules.ts
|
// Used in: collection_rules.ts
|
||||||
interface CollectionRule {
|
interface CollectionRule {
|
||||||
id: string;
|
id: string;
|
||||||
field:
|
field: "genre" | "series" | "author" | "language" | "publisher" | "copyright_year" | "tags";
|
||||||
| "genre"
|
|
||||||
| "series"
|
|
||||||
| "author"
|
|
||||||
| "language"
|
|
||||||
| "publisher"
|
|
||||||
| "copyright_year"
|
|
||||||
| "tags";
|
|
||||||
operator:
|
operator:
|
||||||
| "equals"
|
| "equals"
|
||||||
| "not_equals"
|
| "not_equals"
|
||||||
|
|||||||
Vendored
+1
-9
@@ -51,15 +51,7 @@ interface TOCNode {
|
|||||||
// Parser Types (Procedural, not OOP)
|
// Parser Types (Procedural, not OOP)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
type ParserFormat =
|
type ParserFormat = "epub" | "fb2" | "txt" | "html" | "mobi" | "azw3" | "docx" | "rtf";
|
||||||
| "epub"
|
|
||||||
| "fb2"
|
|
||||||
| "txt"
|
|
||||||
| "html"
|
|
||||||
| "mobi"
|
|
||||||
| "azw3"
|
|
||||||
| "docx"
|
|
||||||
| "rtf";
|
|
||||||
|
|
||||||
interface ParserCapabilities {
|
interface ParserCapabilities {
|
||||||
canParse(mimeType: string, extension: string): boolean;
|
canParse(mimeType: string, extension: string): boolean;
|
||||||
|
|||||||
+22
-76
@@ -4,11 +4,7 @@ import { getToken } from "./storage";
|
|||||||
|
|
||||||
let selectedMediaItem: string | null = null;
|
let selectedMediaItem: string | null = null;
|
||||||
|
|
||||||
function searchMatches(
|
function searchMatches(progressId: string, sha256: string, title: string): void {
|
||||||
progressId: string,
|
|
||||||
sha256: string,
|
|
||||||
title: string,
|
|
||||||
): void {
|
|
||||||
const container = document.getElementById(`matches-${progressId}`);
|
const container = document.getElementById(`matches-${progressId}`);
|
||||||
const matchesList = document.getElementById(`matches-list-${progressId}`);
|
const matchesList = document.getElementById(`matches-list-${progressId}`);
|
||||||
|
|
||||||
@@ -38,18 +34,8 @@ function searchMatches(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function autoLinkBook(
|
function autoLinkBook(progressId: string, mediaItemId: string, confidence: number): void {
|
||||||
progressId: string,
|
if (!confirm("Link this book? The confidence score is " + Math.round(confidence * 100) + "%")) {
|
||||||
mediaItemId: string,
|
|
||||||
confidence: number,
|
|
||||||
): void {
|
|
||||||
if (
|
|
||||||
!confirm(
|
|
||||||
"Link this book? The confidence score is " +
|
|
||||||
Math.round(confidence * 100) +
|
|
||||||
"%",
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,12 +74,8 @@ function autoLinkBook(
|
|||||||
|
|
||||||
function showManualLinkModal(progressId: string, bookTitle: string): void {
|
function showManualLinkModal(progressId: string, bookTitle: string): void {
|
||||||
const modal = document.getElementById("manual-link-modal");
|
const modal = document.getElementById("manual-link-modal");
|
||||||
const progressIdInput = document.getElementById(
|
const progressIdInput = document.getElementById("link-progress-id") as HTMLInputElement;
|
||||||
"link-progress-id",
|
const bookTitleInput = document.getElementById("link-book-title") as HTMLInputElement;
|
||||||
) as HTMLInputElement;
|
|
||||||
const bookTitleInput = document.getElementById(
|
|
||||||
"link-book-title",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const searchResults = document.getElementById("link-search-results");
|
const searchResults = document.getElementById("link-search-results");
|
||||||
|
|
||||||
if (modal) modal.classList.remove("hidden");
|
if (modal) modal.classList.remove("hidden");
|
||||||
@@ -107,9 +89,7 @@ function showManualLinkModal(progressId: string, bookTitle: string): void {
|
|||||||
|
|
||||||
function hideManualLinkModal(): void {
|
function hideManualLinkModal(): void {
|
||||||
const modal = document.getElementById("manual-link-modal");
|
const modal = document.getElementById("manual-link-modal");
|
||||||
const searchInput = document.getElementById(
|
const searchInput = document.getElementById("link-search-input") as HTMLInputElement;
|
||||||
"link-search-input",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
|
|
||||||
if (modal) modal.classList.add("hidden");
|
if (modal) modal.classList.add("hidden");
|
||||||
if (searchInput) searchInput.value = "";
|
if (searchInput) searchInput.value = "";
|
||||||
@@ -117,9 +97,7 @@ function hideManualLinkModal(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function searchBooksForLink(): void {
|
function searchBooksForLink(): void {
|
||||||
const searchInput = document.getElementById(
|
const searchInput = document.getElementById("link-search-input") as HTMLInputElement;
|
||||||
"link-search-input",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const resultsContainer = document.getElementById("link-search-results");
|
const resultsContainer = document.getElementById("link-search-results");
|
||||||
|
|
||||||
if (!searchInput || !resultsContainer) return;
|
if (!searchInput || !resultsContainer) return;
|
||||||
@@ -195,18 +173,10 @@ function confirmManualLink(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressIdInput = document.getElementById(
|
const progressIdInput = document.getElementById("link-progress-id") as HTMLInputElement;
|
||||||
"link-progress-id",
|
const confidenceInput = document.getElementById("link-confidence") as HTMLInputElement;
|
||||||
) as HTMLInputElement;
|
const bookTitleInput = document.getElementById("link-book-title") as HTMLInputElement;
|
||||||
const confidenceInput = document.getElementById(
|
const sha256Input = document.getElementById("link-book-sha256") as HTMLInputElement;
|
||||||
"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;
|
if (!progressIdInput) return;
|
||||||
|
|
||||||
@@ -245,9 +215,7 @@ function confirmManualLink(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleAllUnlinked(): void {
|
function toggleAllUnlinked(): void {
|
||||||
const selectAll = document.getElementById(
|
const selectAll = document.getElementById("select-all-unlinked") as HTMLInputElement;
|
||||||
"select-all-unlinked",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
if (!selectAll) return;
|
if (!selectAll) return;
|
||||||
|
|
||||||
document.querySelectorAll(".unlinked-checkbox").forEach((cb) => {
|
document.querySelectorAll(".unlinked-checkbox").forEach((cb) => {
|
||||||
@@ -257,9 +225,7 @@ function toggleAllUnlinked(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getSelectedUnlinked(): { progressId: string; title: string }[] {
|
function getSelectedUnlinked(): { progressId: string; title: string }[] {
|
||||||
return Array.from(
|
return Array.from(document.querySelectorAll(".unlinked-checkbox:checked")).map((cb) => ({
|
||||||
document.querySelectorAll(".unlinked-checkbox:checked"),
|
|
||||||
).map((cb) => ({
|
|
||||||
progressId: cb.getAttribute("data-progress-id") || "",
|
progressId: cb.getAttribute("data-progress-id") || "",
|
||||||
title: cb.getAttribute("data-title") || "",
|
title: cb.getAttribute("data-title") || "",
|
||||||
}));
|
}));
|
||||||
@@ -280,11 +246,7 @@ async function bulkAutoLink(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (!confirm(`Auto-link ${selected.length} books with high confidence matches (≥80%)?`)) {
|
||||||
!confirm(
|
|
||||||
`Auto-link ${selected.length} books with high confidence matches (≥80%)?`,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,10 +265,7 @@ async function bulkAutoLink(): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
showToast(
|
showToast(`Auto-linked ${result.auto_linked} books successfully`, "success");
|
||||||
`Auto-linked ${result.auto_linked} books successfully`,
|
|
||||||
"success",
|
|
||||||
);
|
|
||||||
setTimeout(() => window.location.reload(), 1500);
|
setTimeout(() => window.location.reload(), 1500);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Auto-link failed", error);
|
console.error("Auto-link failed", error);
|
||||||
@@ -324,14 +283,11 @@ async function bulkGetSuggestions(): Promise<void> {
|
|||||||
const token = getToken();
|
const token = getToken();
|
||||||
for (const book of selected) {
|
for (const book of selected) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(`/sync/unlinked-books/${book.progressId}/suggestions`, {
|
||||||
`/sync/unlinked-books/${book.progressId}/suggestions`,
|
|
||||||
{
|
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
displaySuggestions(book.progressId, result.suggestions, result.action);
|
displaySuggestions(book.progressId, result.suggestions, result.action);
|
||||||
@@ -341,11 +297,7 @@ async function bulkGetSuggestions(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function displaySuggestions(
|
function displaySuggestions(progressId: string, suggestions: any[], _action: string): void {
|
||||||
progressId: string,
|
|
||||||
suggestions: any[],
|
|
||||||
_action: string,
|
|
||||||
): void {
|
|
||||||
const container = document.getElementById(`matches-${progressId}`);
|
const container = document.getElementById(`matches-${progressId}`);
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
@@ -356,15 +308,13 @@ function displaySuggestions(
|
|||||||
listContainer.innerHTML = "";
|
listContainer.innerHTML = "";
|
||||||
|
|
||||||
if (suggestions.length === 0) {
|
if (suggestions.length === 0) {
|
||||||
listContainer.innerHTML =
|
listContainer.innerHTML = '<p style="color: var(--text-secondary)">No matches found</p>';
|
||||||
'<p style="color: var(--text-secondary)">No matches found</p>';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
suggestions.forEach((match) => {
|
suggestions.forEach((match) => {
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
div.className =
|
div.className = "p-3 border rounded cursor-pointer hover:bg-opacity-80 transition-colors";
|
||||||
"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.style.cssText = `background-color: var(--bg-primary); border-color: var(--border);`;
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
@@ -400,8 +350,7 @@ function showBulkManualLink(): void {
|
|||||||
"info",
|
"info",
|
||||||
);
|
);
|
||||||
window.location.href =
|
window.location.href =
|
||||||
"/library?mode=link&unlinked=" +
|
"/library?mode=link&unlinked=" + selected.map((s) => s.progressId).join(",");
|
||||||
selected.map((s) => s.progressId).join(",");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupEventDelegation(): void {
|
function setupEventDelegation(): void {
|
||||||
@@ -420,10 +369,7 @@ function setupEventDelegation(): void {
|
|||||||
parseFloat(card.dataset.confidence || "0"),
|
parseFloat(card.dataset.confidence || "0"),
|
||||||
);
|
);
|
||||||
} else if (action === "select-book") {
|
} else if (action === "select-book") {
|
||||||
selectBookForLink(
|
selectBookForLink(card.dataset.mediaItemId || "", card.dataset.cover || "");
|
||||||
card.dataset.mediaItemId || "",
|
|
||||||
card.dataset.cover || "",
|
|
||||||
);
|
|
||||||
} else if (action === "select-match") {
|
} else if (action === "select-match") {
|
||||||
autoLinkBook(
|
autoLinkBook(
|
||||||
card.dataset.progressId || "",
|
card.dataset.progressId || "",
|
||||||
|
|||||||
@@ -120,9 +120,4 @@ function createWebSocket(config: {
|
|||||||
return ws;
|
return ws;
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export { addListener, createWebSocket, disconnectWebSocket, removeListener };
|
||||||
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: }
|
*,: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