Compare commits

...
9 Commits
Author SHA1 Message Date
John O'Keefe fbdd4ab9f9 feat(audio-player): Resume Rewind select in the settings popover
Release / build-and-push (push) Successful in 4m43s
The player window could only change Skip and Speed; resume rewind
lived exclusively on the profile page. The user-facing settings now
match Speed's precedent — changeable from the player, saved to the
account, applied immediately.

The select mirrors the default-speed row (CSP-safe @change bound to
one Alpine method; templ emits the option list from the shared
ResumeRewindOptions allow-list and the :selected expression against
the live resumeRewind field). Applying updates the running player's
value first — the next open/unpause uses it with no reload — then
PUTs audio_resume_rewind_seconds to the profile endpoint as the
string wire shape the server binds. normalizeResumeRewind gates the
select's value through 0/5/10 before both steps.
2026-11-01 21:29:19 -05:00
John O'Keefe ef827a3a05 test(api): pin the resume-rewind contract
Release / build-and-push (push) Successful in 3m2s
TestAudioResumeRewind drives the whole surface against the shared dev
DB with the fresh per-test regular user (the live admin is never
touched; the fixture book lives in a test-named library that cascades
away): default 0, profile PUT "5" rides the chapters payload,
non-allow-listed values 400 without side effects, explicit "0"
resets everywhere, and an omitted JSON key leaves the column
untouched.

Changelog entry in docs/contract_pins.md (additive chapters key +
the profile field's string-shaped PUT, keep_screen_awake precedent)
and the get_profile.md example gains the preference keys.

Debug note for the next agent: the first runs here 401'd with
'Ghost session rejected' — NOT a harness or auth bug. The tests never
execute schema.sql; the shared dev DB gets its schema from the app
container's boot. The rebuilt tests image ran the new GetUser
(selecting audio_resume_rewind_seconds) against a DB whose schema
came from the old app image — every GetUser errored, and the
ghost-session guard turned that into 401s. Applying the schema.sql
ALTER to the dev DB (what the next app rebuild does anyway) turned
the suite green. Until make rebuild-app runs, the running app
container serves the old binary.
2026-11-01 19:38:08 -05:00
John O'Keefe 7b7ce39826 feat(audio-player): apply the resume-rewind setting on open and unpause
The chapters payload now carries resume_rewind_seconds (server-side
per-user setting); the player applies it at two events, each rewinding
once from where playback currently sits — never stacked:

- open-resume: restorePosition starts the rewind early from the saved
  spot (percentage and chapter+offset branches, clamped at the book
  start). Deep links (?loc=) stay exempt — an explicit jump is not a
  resume.
- unpause: a genuine pause→play steps back again (the re-orientation
  gap). Programmatic plays — post-restore autoplay and track-switch
  autoplays — set a suppress flag: their play event is the session
  start the open-rewind already positioned, not an unpause.

Math lives in audio-rewind.ts (pure, unit-tested) after the
audio-scrubber pattern; payload values gate through the 0/5/10
allow-list so stale or foreign servers degrade to exact resume.
2026-11-01 17:21:35 -05:00
John O'Keefe 9cf9e1d305 feat(api): expose and validate the audio resume rewind setting
GET/PUT /api/auth/profile now carries audio_resume_rewind_seconds
(always-present on the response: 0 is a real value, exact resume; the
request shape is the keep_screen_awake string precedent so the form
select can send an explicit 0=Off while JSON clients that omit it
leave the column untouched — a plain int with an omitempty contract
could not express 'set to off'). Strict allow-list {0,5,10}: the UI
offers exactly three choices and the API refuses everything else.

The audiobook chapters endpoint embeds resume_rewind_seconds in its
existing payload — it already loads the user row for playback speed,
so both players (web + Android, phone UI and the Android Auto service)
get the value with the fetch they perform anyway; zero extra round
trips.

Profile page gains the Resume Rewind select (Off / 5 s before /
10 s before) next to Default Playback Speed.
2026-11-01 17:19:58 -05:00
John O'Keefe daffe499eb feat(users): audio_resume_rewind_seconds column
Per-user resume rewind for the audiobook/podcast player: how many
seconds (0/5/10) before the saved position playback starts, both on
open-resume and again on every genuine unpause. Motivated by the car
startup case — exact resume loses the listener's place, and the
PlaybackRequestResolver path (phone UI, Android Auto, media buttons)
all funnel through one start position.

0 = exact resume (default), so the column is inert until the profile
page sets it. Follows the default_audio_speed / keep_screen_awake
precedent: idempotent ALTER in schema.sql (re-runs on every boot),
explicit GetUser SELECT, dedicated :exec update. Handler-side strict
allow-list {0,5,10} lands with the API slice.
2026-11-01 17:16:35 -05:00
John O'Keefe 0d613ba0e3 fix(docs): folder-index pages expand the sidebar and highlight — same rule for every page
The filter manual is the docs tree's first FOLDER-index page: it serves
at /docs/user/filters while its nav entry carries /index
(/docs/user/filters/index). The sidebar's two checks — group expansion
(containsURL) and active-link styling (docsNavClass) — compared URLs
EXACTLY, so the folder URL matched neither: no submenu expansion, no
highlight (2026-11-01 user report: "the left sidebar should open to
the submenu and highlight the page").

Fix is structural, not per-page: both checks normalize folder-index
URLs (a trailing /index is the same page as its directory), so EVERY
folder-index page — current and future — expands and highlights like
a file page. No per-page registration exists anywhere in the docs
system to redo: the sidebar derives from the docs tree, and this was
the one URL shape its equality rule missed.

Pins: navigation (section + group expand for the folder URL, group
present) and docsNavClass (folder URL highlights the Overview item,
normalization symmetric, file pages keep their highlight, no
cross-highlighting).
2026-11-01 16:34:25 -05:00
John O'Keefe a4dc03984c feat(docs): "?" help affordance on every filter mount + docs loader fixes
The filter manual (1ed1602e) was reachable only by knowing /docs
already existed — nothing in the UI pointed at it (the parked manual
plan's open decision). Every filter-builder mount now carries a "?"
in its header linking to the guide (opens in a new tab so drawer and
editor state survive): the All Books drawer and the collection rules
page — the two mounts sharing FilterBuilderRows. The docs home
indexes the manual (guide, cookbook, generated field reference).

Two docs-loader fixes surfaced by pinning this:
- Folder URLs resolve to their index page: /docs/user/filters now
  serves user/filters/index.md (the manual is a folder: guide +
  cookbook + generated reference); previously only root /docs had
  index handling.
- The docs folder path is resolved by walking upward (bounded):
  `go test` runs test binaries from the PACKAGE directory, so the
  relative "docs" path resolved to a folder that didn't exist there
  and every /docs route 404'd in tests while working in prod —
  found by the new pin, fixed so tests exercise the docs routes
  exactly as prod serves them.

Pins: TestFilterHelpAffordance (folder URL serves the guide; drawer
and rules pages carry the link). internal/docs suite + full suite
green (239s).
2026-11-01 16:22:35 -05:00
John O'Keefe 1ed1602e70 docs(filters): user manual — guide, cookbook, generated field reference
The filter manual (docs/user/filters/), folded in from the parked plan
now that the UI matches the backend (group ALL/ANY header toggle,
2344f568):

- index.md — the guide: the three homes of filters (drawer/saved
  filters, smart collections, dashboard sections), the books/episodes
  scope door, rule anatomy, value picking (chips with x, live counts
  under other rules), GROUPS via the new header toggle (+ the row
  chips as the fine-tuning tool), the live match count, today's
  defaults (reversed listening order, family-lens scoping,
  un-downloaded episodes), saving and shareable views.
- cookbook.md — seven exact-clicks recipes (born-reversed briefing,
  OR-across-authors, big-unread-fantasy, downloaded-unplayed,
  new-arrivals, dashboard shelf, shareable views). Every claim
  cross-checked against the generated reference — which caught the
  one wrong operator label in my first draft ("within last" vs the
  real "within the last"), the drift protection working as designed.
- fields.md — GENERATED from FilterFieldsPayload by
  TestFieldReferenceDocIsCurrent: the drift test fails the build if
  the registry ever changes without regenerating the docs (user
  decision: docs forced to match working code; FILTER_DOCS_REGEN=1
  regenerates).
- searching-and-filtering.md cross-links to the folder.
2026-11-01 15:46:34 -05:00
John O'Keefe 2344f5686a feat(filter-builder): the group header is the ALL/ANY switch
The group header ("MATCH"/"GROUP") was a dead label while the engine
honored group modes all along — buildGroupClause parenthesizes nested
groups and joins each by its own mode, and validation accepted
all/any from day one. The only thing missing was the UI: + Group
birthed ALL groups and no control anywhere could flip one, so the
user's target structures (GROUP(GROUP(... or ...) ...) — 3-level
mixed-mode nesting) were constructible only through the invisible
row-level chips, and standalone ANY-groups not at all (2026-11-01
user report: "MATCH cannot be clicked").

- The header is now the control: MATCH ALL ⇄ MATCH ANY (root — a
  synthetic header row, since the root was never projected and its
  mode was invisible too), GROUP ALL ⇄ GROUP ANY (nested). ANY is
  accented; aria-pressed carries the state. Labels are path-based
  (root reads Match, explicit groups read Group) instead of
  depth-based, which previously mislabeled top-level groups.
- The row-level chips are untouched — they mix AND/OR inside a group
  (A AND (B OR C)), which a global mode toggle cannot express; the
  header is the discoverable entry point, the chips the refinement.
- Dropdown audit (the "would it bug out" check): suggestTree is
  mode-generic — root-ANY drops group siblings, sibling ANY-groups
  survive into the constraint with their mode intact, empty groups
  never emit (no 400s) — now pinned. Deep-nesting Go pin: a 3-level
  mixed-mode tree evaluated through composed search + the suggestion
  endpoint (200, never the empty-group 400). 192 web tests green.
2026-11-01 15:43:52 -05:00
42 changed files with 2154 additions and 195 deletions

No files matched your search

+82
View File
@@ -903,3 +903,85 @@ func TestFilterFlatEquivalence(t *testing.T) {
require.Equal(t, statusFlat, statusTree, "same result status")
assert.ElementsMatch(t, qfTitles(t, flatRows), qfTitles(t, treeRows))
}
// ---- deep group nesting (2026-11-01 group ALL/ANY header toggle): ----
// the UI can now produce 3+-level mixed-mode trees; pin the engine's
// parenthesized evaluation for the user's target structure
// GROUP(GROUP(Field and (Field or Field)) or GROUP(Field and Field and
// (Field or Field)) and GROUP(Field)) — plus a suggestion request
// carrying a nested-any constraint (the mode-aware menu under real
// nesting) — must be 200, never the empty-group 400.
func TestFilterDeepGroupNesting(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
userID := getRegularUserID(t, setup.DB)
libUUID, _ := builderFixtureLib(t, setup, userID, "Deep")
titles := func(qf string) []string {
p := url.Values{"library_id": {libUUID.String()}, "qf": {qf}}
status, rows := qfSearch(t, setup, setup.RegularToken, p)
require.Contains(t, []int{http.StatusOK, http.StatusNotFound}, status)
return qfTitles(t, rows)
}
// root ALL[
// ANY[ ALL[fantasy, ANY[>=250p, abridged]] , ALL[Dar, >=500p] ],
// author not_contains Quill
// ]
// => Tide (fantasy + 300p), Wick (Dar + 700p); Fen dies at the root
// AND (Quill); Stone fails branch A (history) and branch B (Voss).
deep := map[string]interface{}{
"mode": "all",
"children": []interface{}{
map[string]interface{}{
"mode": "any",
"children": []interface{}{
map[string]interface{}{
"mode": "all",
"children": []interface{}{
qfRule("genre", "is", "fantasy"),
map[string]interface{}{
"mode": "any",
"children": []interface{}{
qfRule("page_count", "at_least", "250"),
qfRule("abridged", "is_true"),
},
},
},
},
map[string]interface{}{
"mode": "all",
"children": []interface{}{
qfRule("author", "contains", "Dar"),
qfRule("page_count", "at_least", "500"),
},
},
},
},
qfRule("author", "not_contains", "Quill"),
},
}
deepJSON, merr := json.Marshal(deep)
require.NoError(t, merr)
got := titles(string(deepJSON))
assert.ElementsMatch(t, []string{"Deep Tide", "Deep Wick"}, got,
"3-level mixed-mode nesting evaluates parenthesized")
// The same shape riding the SUGGEST endpoint (the mode-aware menu's
// constraint) — nested any must be a 200, never the empty-group 400.
p := url.Values{
"library_id": {libUUID.String()},
"author": {""},
"limit": {"20"},
"qf": {string(deepJSON)},
}
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?"+p.Encode(), nil)
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusOK, resp.StatusCode,
"a nested-any constraint rides the suggestion endpoint cleanly: %s", p.Encode())
}
+57
View File
@@ -2520,3 +2520,60 @@ func TestFilterLabelsEndpoint(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, code, "%s", string(body))
})
}
// ---- the "?" affordance: the builder links to its manual ----
// TestFilterHelpAffordance pins the help links (2026-11-01): every
// filter-builder mount carries a "?" linking to the manual, and the
// manual's FOLDER URL resolves to its index page (the docs loader's
// folder-index fallback — the guide is docs/user/filters/index.md).
func TestFilterHelpAffordance(t *testing.T) {
setup := setupTestServer(t)
regToken := setup.RegularToken
t.Run("docs_folder_url_serves_the_guide", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/docs/user/filters", nil)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode, "the folder URL resolves to index.md; body: %.400s", string(body))
assert.Contains(t, string(body), "Filters — the complete guide")
})
t.Run("drawer_carries_help_link", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/bookshelf", nil)
req.Header.Set("Authorization", "Bearer "+regToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, string(body), `href="/docs/user/filters"`,
"the All Books drawer links to the filter guide")
})
t.Run("rules_page_carries_help_link", func(t *testing.T) {
marker := fmt.Sprintf("helpaff%d", time.Now().UnixNano()%100000)
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections",
`{"name":"test HelpAff `+marker+`","filter_tree":{"scope":"episodes","mode":"all","children":[]}}`)
require.Equal(t, http.StatusCreated, code, "%s", string(body))
var coll struct {
ID string `json:"id"`
}
require.NoError(t, json.Unmarshal(body, &coll))
t.Cleanup(func() {
podcastAuthed(t, setup, regToken, "DELETE", "/api/collections/"+coll.ID, "")
})
req, _ := http.NewRequest("GET", setup.Server.URL+"/collections/"+coll.ID+"/rules", nil)
req.Header.Set("Authorization", "Bearer "+regToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
pageBody, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, string(pageBody), `href="/docs/user/filters"`,
"the collection rules editor links to the filter guide")
})
}
+123
View File
@@ -0,0 +1,123 @@
package main
// Resume rewind (per-user server setting): seconds before the saved
// position the audiobook/podcast player starts (0 = exact resume).
// Contract: the profile endpoint owns reads/writes with a strict
// 0/5/10 allow-list (the request shape is the keep_screen_awake string
// precedent — the form select must be able to send an explicit Off),
// and the chapters payload carries the value so every client (web +
// Android, incl. the Auto service) gets it with its existing fetch.
// All writes ride the fresh per-test regular user — the shared admin
// account is never touched (AGENTS doctrine). Fixture book lives in a
// test-named library that cascades away with the library delete.
import (
"encoding/json"
"fmt"
"net/http"
"path/filepath"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
func TestAudioResumeRewind(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
admin, _ := moveTestAdminID(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Resume Rewind %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
// 664 Hz tone: not the shared helper's 440 or the speed test's 552,
// so the scanner's content dedup never skips this book.
writeTestTrackTone(t, filepath.Join(folder, "Rewind Book", "01 one.mp3"), 664)
scanTestFolder(t, setup, admin, folder)
var bookPath string
for _, item := range listLibraryAudioItems(t, setup, libID) {
if strings.Contains(filepath.ToSlash(item.FilePath), "Rewind Book") && bookPath == "" {
bookPath = item.FilePath
}
}
require.NotEmpty(t, bookPath, "Rewind Book item must exist")
book, found := findAudioItemByPath(t, setup, libID, bookPath)
require.True(t, found)
bookID := uuid.UUID(book.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() }
}
getPayload := func(itemID uuid.UUID) (rewind int) {
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 {
ResumeRewindSeconds int `json:"resume_rewind_seconds"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&data))
return data.ResumeRewindSeconds
}
getProfileRewind := func() int {
resp, closer := authed("GET", setup.Server.URL+"/api/auth/profile", "")
defer closer()
require.Equal(t, http.StatusOK, resp.StatusCode)
var profile struct {
AudioResumeRewind int `json:"audio_resume_rewind_seconds"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&profile))
return profile.AudioResumeRewind
}
t.Run("defaults to off (exact resume)", func(t *testing.T) {
require.Equal(t, 0, getProfileRewind())
require.Equal(t, 0, getPayload(bookID))
})
t.Run("profile PUT selects 5 and rides the chapters payload", func(t *testing.T) {
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", `{"audio_resume_rewind_seconds":"5"}`)
defer closer()
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, 5, getProfileRewind())
require.Equal(t, 5, getPayload(bookID))
})
t.Run("non-allow-listed values are rejected and change nothing", func(t *testing.T) {
for _, body := range []string{`{"audio_resume_rewind_seconds":"7"}`, `{"audio_resume_rewind_seconds":"-5"}`, `{"audio_resume_rewind_seconds":"abc"}`} {
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", body)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
closer()
}
require.Equal(t, 5, getProfileRewind())
})
t.Run("explicit off resets to 0 everywhere", func(t *testing.T) {
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", `{"audio_resume_rewind_seconds":"0"}`)
defer closer()
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, 0, getProfileRewind())
require.Equal(t, 0, getPayload(bookID))
})
t.Run("JSON clients that omit the key leave it untouched", func(t *testing.T) {
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", `{"timezone":"UTC"}`)
defer closer()
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, 0, getProfileRewind())
})
}
+8
View File
@@ -48,6 +48,14 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS default_audio_speed REAL NOT NULL DEF
-- mid-session. On by default; the profile page's On/Off dropdown owns it.
ALTER TABLE users ADD COLUMN IF NOT EXISTS keep_screen_awake BOOLEAN NOT NULL DEFAULT TRUE;
-- Audio resume rewind: seconds before the saved position the
-- audiobook/podcast player starts (on open-resume AND again on every
-- genuine unpause — the re-orientation gap, never stacked). Strict
-- allow-list 0/5/10 enforced handler-side; 0 = exact resume. The
-- profile page's dropdown owns it; the chapters payload carries it to
-- every client (web + Android, incl. the car path).
ALTER TABLE users ADD COLUMN IF NOT EXISTS audio_resume_rewind_seconds INT NOT NULL DEFAULT 0;
-- Create system_settings table
CREATE TABLE IF NOT EXISTS system_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+17
View File
@@ -720,3 +720,20 @@ the briefing acceptance now starts from the page's FIRST card (feed
position 5 — the born-reversed default's book order) and chains into
feed position 1. The acceptance meaning is unchanged: play from what
you see, chained back-to-back within the live briefing set.
## 2026-11-01 — audio resume rewind setting
- `GET /api/media-items/:id/chapters` gained one ADDITIVE response key:
`resume_rewind_seconds` (int, the requesting user's setting; 0 for
anonymous). Existing keys unchanged. The web player and the Android
app both gate the value through the 0/5/10 allow-list, so servers
without the key (absent → 0) degrade to exact resume.
- `GET /api/auth/profile` now always carries
`audio_resume_rewind_seconds` (int, never omitted — 0 is a real
value, exact resume). `PUT /api/auth/profile` accepts the key as the
keep_screen_awake-style STRING ("0"/"5"/"10") so the profile form's
select can send an explicit Off; JSON clients that omit it leave the
column untouched, and anything outside {0,5,10} is 400.
Pinned (TestAudioResumeRewind): default 0, PUT "5" rides the
chapters payload, non-allow-listed rejections change nothing,
explicit "0" resets everywhere, omitted key leaves it untouched.
+2
View File
@@ -36,6 +36,8 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
"first_name": "John",
"last_name": "Doe",
"role": "user",
"default_audio_speed": 1.5,
"audio_resume_rewind_seconds": 5,
"keep_screen_awake": true
}
```
+5
View File
@@ -17,6 +17,11 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
- **Sync Configuration**
- [Universal Sync Guide](user/sync-guide.md) - Understanding sync, book matching, conflicts
- **Filters**
- [Filters — the complete guide](user/filters/index.md) - rules, groups (ALL/ANY), saved filters, smart collections
- [Filter cookbook](user/filters/cookbook.md) - ready-made recipes, exact clicks
- [Every filter field](user/filters/fields.md) - the generated, always-current reference
- **Frontend & Settings** (Coming Soon)
- Frontend Guide
- User Areas Guide
+78
View File
@@ -0,0 +1,78 @@
# Filter cookbook
Real questions, exact clicks. Every recipe uses the same builder described in
[the guide](index.md) — same fields, same rules, wherever filters live.
Recipes marked **collection** become smart collections that stay current forever; the rest are
drawer filters you can save with a name.
---
## "New chapters of 7th Son, automatically" — collection
1. **Collections** → open (or create) a collection → **Rules**.
2. The scope is already *Episodes*. Add a rule: **Feed** → **in** → pick
*7th Son: Book One* (it becomes a chip — hit the × if you pick wrong).
3. Add a second rule: **Title** → **contains** → `Chapter`.
4. The header should read **MATCH ALL** (it does by default — every rule must match).
5. **Apply to Collection**. Done: new chapters appear on their own, and the listening order
starts at **Feed order (reversed)** — chapter 1 first.
---
## "Lewis Carroll or George Orwell" — drawer
1. **Filters** on the All Books page. Add: **Author** → **contains** → `Carroll`.
2. Add a second rule: **Author** → **contains** → `Orwell`.
3. Click the group header **MATCH ALL** once — it flips to **MATCH ANY** (or click the `AND`
chip between the two rules, same effect).
4. **Apply Filters**. Either author is enough.
*Flip it back to ALL and you'd get only books they both wrote — which is none, and the counter
will tell you so before you apply.*
---
## "Long fantasy books I haven't started" — drawer
1. **Genre** → **is** → `fantasy`.
2. **Page count** → **at least** → `400`.
3. **My reading** → **Reading status** → **is** → *Unfinished* (or your app's wording — the
status list is the fixed dropdown on that field).
4. **MATCH ALL** stays on: all three must hold.
5. Apply — then **Save** it as *"Big unread fantasy"* and it's in the Load menu forever.
---
## "Downloaded podcast episodes I haven't played" — collection
1. A collection with an Episodes scope (see the first recipe — drop the Title rule if you want
whole feeds).
2. **Downloaded** → **is true**.
3. **Played** → **is false**.
4. **MATCH ALL** on. **Apply to Collection**.
---
## "Added to my library this month" — drawer
1. **Date added** → **within the last** → `30` (the days box — the field's page in
[Every filter field](fields.md) has its exact questions).
2. Optionally **MATCH ALL** with **Library** → **in** → your audiobooks library.
3. Save as *"New arrivals"*.
---
## "A dashboard shelf that builds itself" — dashboard
1. Create a collection with the rules above (or open one).
2. On the dashboard, add a section pointed at that collection.
3. The shelf re-fills itself as your library changes — no maintenance.
---
## Sharing a filtered view
Apply any filter, then copy the address bar. The filter rides in the URL — send it to another
user of your server and they open the same narrowed view (they see it through their own
libraries, not yours).
+755
View File
@@ -0,0 +1,755 @@
<!-- GENERATED by TestFieldReferenceDocIsCurrent (internal/services/filter_docs_test.go)
from the filter registry — the same source the real menus render. DO NOT EDIT by hand:
regenerate with FILTER_DOCS_REGEN=1 go test ./internal/services -run TestFieldReferenceDocIsCurrent -->
# Every filter field
This is the complete list of everything the filters can ask about, generated straight from the
same source the app's menus render — it cannot drift from what you see in the UI.
Filters come in two worlds:
- **Books** — rules about books and your reading of them.
- **Episodes** — rules about podcast episodes (feeds, downloaded, played).
Every rule is **a field + a question + your values**. The "Ask" column is the question;
the "Your values" column is what you provide. The **default** question is what a new rule
starts with.
## Books
### Book facts
**Title** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Subtitle** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Description** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Summary** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| has | has a value *(default)* |
| missing | is empty or missing |
**Language** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Page count** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Chapter count** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Total characters** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Community rating** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Date added** — Type a number of days.
| Ask | Means |
| --- | --- |
| within the last | — *(default)* |
| between | falls between two numbers |
| is empty | — |
**Date imported** — Type a number of days.
| Ask | Means |
| --- | --- |
| within the last | — *(default)* |
| between | falls between two numbers |
| is empty | — |
**Web URL** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| has | has a value *(default)* |
| missing | is empty or missing |
**Metadata notes** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| has | has a value *(default)* |
| missing | is empty or missing |
**ISBN** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick |
| isn't | matches anything but this |
| has | has a value *(default)* |
| missing | is empty or missing |
**ASIN** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick |
| isn't | matches anything but this |
| has | has a value *(default)* |
| missing | is empty or missing |
**File size** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**File availability** — Choose from a fixed list of options.
| Ask | Means |
| --- | --- |
| is | matches exactly *(default)* |
| isn't | matches anything but this |
**Explicit** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| is true | is switched on *(default)* |
| is false | is switched off |
**Abridged** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| is true | is switched on *(default)* |
| is false | is switched off |
**Manually edited** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| is true | is switched on *(default)* |
| is false | is switched off |
**Cover** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| has | has a value *(default)* |
| missing | is empty or missing |
**Added by** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick |
| has | has a value *(default)* |
| missing | is empty or missing |
### Comics & manga
**Series** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Series number** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Series count** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Volume** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Format group** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick *(default)* |
| isn't | matches anything but this |
**File type (mime)** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick *(default)* |
| isn't | matches anything but this |
**Reflowable** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| is true | is switched on *(default)* |
| is false | is switched off |
**Fixed layout** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| is true | is switched on *(default)* |
| is false | is switched off |
**Black & white** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| is true | is switched on *(default)* |
| is false | is switched off |
**Manga type** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick *(default)* |
| isn't | matches anything but this |
| is empty | — |
**Reading direction** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick *(default)* |
| isn't | matches anything but this |
| is empty | — |
### Library
**Library** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick *(default)* |
| isn't | matches anything but this |
### My reading
**Reading status** — Choose from a fixed list of options.
| Ask | Means |
| --- | --- |
| is | matches exactly *(default)* |
**Progress percent** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Last read** — Type a number of days.
| Ask | Means |
| --- | --- |
| within the last | — *(default)* |
| between | falls between two numbers |
| is empty | — |
**My rating** — Type a number.
| Ask | Means |
| --- | --- |
| is | matches exactly |
| at least | is at least this *(default)* |
| at most | is at most this |
| unrated | — |
**Has review** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| has | has a value *(default)* |
| missing | is empty or missing |
**Notes** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| has | has a value *(default)* |
| missing | is empty or missing |
| within the last | — |
**Highlights** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| has | has a value *(default)* |
| missing | is empty or missing |
| within the last | — |
**Note text** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is empty | — |
**Highlight text** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is empty | — |
**Time spent (minutes)** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
**Pages read** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
**In collection** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| in | is one of the ones you pick (any one is enough) *(default)* |
| isn't in | is none of the ones you pick |
| in any | is in at least one of the collections you pick |
| in none | — |
### People
**Author** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Narrator** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Contributor** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
### Publishing
**Publisher** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Imprint** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Edition** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Published year** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Published date** — Type a number of days.
| Ask | Means |
| --- | --- |
| within the last | — *(default)* |
| between | falls between two numbers |
| is empty | — |
### Topics
**Genre** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Tags** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Story arc** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Age rating** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick *(default)* |
| isn't | matches anything but this |
| is empty | — |
## Podcast episodes
### Book facts
**Title** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Description** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Language** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Chapter count** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Date added** — Type a number of days.
| Ask | Means |
| --- | --- |
| within the last | — *(default)* |
| between | falls between two numbers |
| is empty | — |
**Date imported** — Type a number of days.
| Ask | Means |
| --- | --- |
| within the last | — *(default)* |
| between | falls between two numbers |
| is empty | — |
**File size** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**File availability** — Choose from a fixed list of options.
| Ask | Means |
| --- | --- |
| is | matches exactly *(default)* |
| isn't | matches anything but this |
**Explicit** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| is true | is switched on *(default)* |
| is false | is switched off |
### Comics & manga
**File type (mime)** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick *(default)* |
| isn't | matches anything but this |
### Episodes
**Feed** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| in | is one of the ones you pick (any one is enough) *(default)* |
| isn't in | is none of the ones you pick |
| has | has a value |
| missing | is empty or missing |
**Downloaded** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| is true | is switched on *(default)* |
| is false | is switched off |
**Played** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| is true | is switched on |
| is false | is switched off *(default)* |
### Library
**Library** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick *(default)* |
| isn't | matches anything but this |
### My reading
**Progress percent** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Last read** — Type a number of days.
| Ask | Means |
| --- | --- |
| within the last | — *(default)* |
| between | falls between two numbers |
| is empty | — |
**My rating** — Type a number.
| Ask | Means |
| --- | --- |
| is | matches exactly |
| at least | is at least this *(default)* |
| at most | is at most this |
| unrated | — |
**Has review** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| has | has a value *(default)* |
| missing | is empty or missing |
**Notes** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| has | has a value *(default)* |
| missing | is empty or missing |
| within the last | — |
**Highlights** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| has | has a value *(default)* |
| missing | is empty or missing |
| within the last | — |
**Note text** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is empty | — |
**Highlight text** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is empty | — |
**Time spent (minutes)** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
**In collection** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| in | is one of the ones you pick (any one is enough) *(default)* |
| isn't in | is none of the ones you pick |
| in any | is in at least one of the collections you pick |
| in none | — |
### Publishing
**Published year** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Published date** — Type a number of days.
| Ask | Means |
| --- | --- |
| within the last | — *(default)* |
| between | falls between two numbers |
| is empty | — |
---
*Anything here that doesn't match what the app actually shows is a bug — this page is generated
from the app's own menus and a test fails if they ever disagree.*
+91
View File
@@ -0,0 +1,91 @@
# Filters — the complete guide
Filters answer one question: *of everything in my library, show me the ones that match.*
You'll meet the same filter system in three places:
| Place | What it's for |
| --- | --- |
| **The Filters drawer** (All Books / Podcasts pages) | A quick, temporary narrowing of what you see. Save it if you want to keep it. |
| **Smart collections** (a collection's **Rules** page) | A filter that *lives* — the collection's contents update automatically forever. |
| **Dashboard sections** | The same rules, feeding a shelf on your home screen. |
One system, one set of skills, three payoffs.
## Two worlds: Books and Episodes
Rules about **books** (authors, series, your reading progress) and rules about **podcast episodes**
(feeds, downloaded, played) are two different worlds. The field menus only show the fields that
make sense where you are — you'll never see a podcast field on the All Books page. The complete
list of every field is in [Every filter field](fields.md).
## Anatomy of a rule
Every rule is **a field + a question + your values**:
> **Author** *contains* `asc`
> **Feed** *in* `7th Son: Book One`
> **Page count** *at least* `400`
The field is *what* you're asking about. The question (contains, at least, is empty…) is *how*.
Your values are *what counts as a match*. The complete questions per field are in
[Every filter field](fields.md).
## Picking values
Two kinds of value boxes:
- **Dropdown boxes** (Feed, Library, Collection, Added by): start typing and real names appear,
with live counts of how much matches. Click to pick — each pick becomes a **chip** with a little
**×** to remove it. Pick as many as you like; *any one of them* matching is enough.
- **Typed boxes** (Author, Title, Page count…): just type. Several words means "contains these
words"; number boxes take numbers; date boxes take a number of days.
The dropdown's counts are **live under your other rules**: if you've already narrowed to
*Title contains Chapter*, the feed menu shows how many Chapter-titled episodes each of your feeds
has. Rules already picked disappear from their own menu (removing a chip brings them back).
## Groups: ALL and ANY
When you have several rules, the group header — **MATCH ALL** or **MATCH ANY** — decides how they
combine:
- **MATCH ALL** — *every* rule must match. (The default, and what you want almost always.)
- **MATCH ANY** — *at least one* rule matching is enough. This is how you ask for
"Lewis Carroll **or** George Orwell".
**Click the header to switch it.** It says what it does, and clicking flips it.
`+ group` adds a nested group with its own header (**GROUP ALL / GROUP ANY**), so bigger shapes
build naturally:
> Match ALL: Author *contains* `Carroll` **AND** GROUP ANY (Title contains `Alice` **OR** Title
> contains `Wonderland`)
For fine-tuning inside an ALL group, the small **AND / OR** chip between two rules mixes them —
click `AND` and it becomes `OR`, joining those neighbors together. (The header switch and these
chips are two different tools: the header sets the group's overall combine; a chip makes just two
neighboring rules special.)
## The live count
While you build, the counter at the bottom — *N books match* — runs your rules against the real
library. It's the exact number you'll get when you apply.
## Defaults worth knowing
- **Podcast listening order**: podcast pages and new podcast collections start at
**Feed order (reversed)** — book order, chapter 1 first. Change it per feed or collection from
the order dropdown; your choice sticks.
- **Hidden libraries** (family lens) never match — a filter can't accidentally surface a library
that's hidden from you.
- **Episodes that aren't downloaded** stay out of playback chains (they still appear in lists).
## Keeping a filter
- **Save** (drawer): name it; it appears in the **Load** menu from then on.
- **Apply to Collection** (rules page): the filter becomes the smart collection — live forever.
- Filtered views are **shareable**: the URL carries the filter, so a bookmark or sent link opens
the same view.
+9
View File
@@ -35,3 +35,12 @@ You can combine tags with other filters:
- Tags use fuzzy matching - "Sci Fi" will match "Science Fiction"
- Best matching tags appear first in results
- Autocomplete shows existing tags as you type with relevance scores
## The complete filter guide
The filters described above are one system that also powers smart collections and dashboard
sections — groups with ALL/ANY, saved filters, and every field listed. The full manual lives in
[this folder](filters/index.md): the [guide](filters/index.md), the
[cookbook](filters/cookbook.md) of ready-made recipes, and the generated
[field reference](filters/fields.md).
+1
View File
@@ -654,4 +654,5 @@ type Users struct {
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
KeepScreenAwake bool `db:"keep_screen_awake" json:"keep_screen_awake"`
AudioResumeRewindSeconds int32 `db:"audio_resume_rewind_seconds" json:"audio_resume_rewind_seconds"`
}
+1
View File
@@ -894,6 +894,7 @@ type Querier interface {
UpdateSystemSetting(ctx context.Context, arg UpdateSystemSettingParams) error
// Update universal progress
UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error)
UpdateUserAudioResumeRewind(ctx context.Context, arg UpdateUserAudioResumeRewindParams) error
UpdateUserDefaultAudioSpeed(ctx context.Context, arg UpdateUserDefaultAudioSpeedParams) error
UpdateUserKeepScreenAwake(ctx context.Context, arg UpdateUserKeepScreenAwakeParams) error
UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDevicesParams) (Users, error)
+19 -1
View File
@@ -11407,6 +11407,7 @@ SELECT
u.timezone,
u.default_audio_speed,
u.keep_screen_awake,
u.audio_resume_rewind_seconds,
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
FROM users u
WHERE u.id = $1
@@ -11426,6 +11427,7 @@ type GetUserRow struct {
Timezone pgtype.Text `db:"timezone" json:"timezone"`
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
KeepScreenAwake bool `db:"keep_screen_awake" json:"keep_screen_awake"`
AudioResumeRewindSeconds int32 `db:"audio_resume_rewind_seconds" json:"audio_resume_rewind_seconds"`
DeviceCount int64 `db:"device_count" json:"device_count"`
}
@@ -11446,6 +11448,7 @@ func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, erro
&i.Timezone,
&i.DefaultAudioSpeed,
&i.KeepScreenAwake,
&i.AudioResumeRewindSeconds,
&i.DeviceCount,
)
return i, err
@@ -20287,6 +20290,20 @@ func (q *Queries) UpdateUniversalProgress(ctx context.Context, arg UpdateUnivers
return i, err
}
const UpdateUserAudioResumeRewind = `-- name: UpdateUserAudioResumeRewind :exec
UPDATE users SET audio_resume_rewind_seconds = $2, updated_at = NOW() WHERE id = $1
`
type UpdateUserAudioResumeRewindParams struct {
ID pgtype.UUID `db:"id" json:"id"`
AudioResumeRewindSeconds int32 `db:"audio_resume_rewind_seconds" json:"audio_resume_rewind_seconds"`
}
func (q *Queries) UpdateUserAudioResumeRewind(ctx context.Context, arg UpdateUserAudioResumeRewindParams) error {
_, err := q.db.Exec(ctx, UpdateUserAudioResumeRewind, arg.ID, arg.AudioResumeRewindSeconds)
return err
}
const UpdateUserDefaultAudioSpeed = `-- name: UpdateUserDefaultAudioSpeed :exec
UPDATE users SET default_audio_speed = $2, updated_at = NOW() WHERE id = $1
`
@@ -20317,7 +20334,7 @@ func (q *Queries) UpdateUserKeepScreenAwake(ctx context.Context, arg UpdateUserK
const UpdateUserMaxDevices = `-- name: UpdateUserMaxDevices :one
UPDATE users SET max_devices = $2, updated_at = NOW() WHERE id = $1
RETURNING id, email, username, password_hash, first_name, last_name, role, theme, max_devices, created_at, timezone, updated_at, default_audio_speed, keep_screen_awake
RETURNING id, email, username, password_hash, first_name, last_name, role, theme, max_devices, created_at, timezone, updated_at, default_audio_speed, keep_screen_awake, audio_resume_rewind_seconds
`
type UpdateUserMaxDevicesParams struct {
@@ -20343,6 +20360,7 @@ func (q *Queries) UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDev
&i.UpdatedAt,
&i.DefaultAudioSpeed,
&i.KeepScreenAwake,
&i.AudioResumeRewindSeconds,
)
return i, err
}
+4
View File
@@ -30,6 +30,7 @@ SELECT
u.timezone,
u.default_audio_speed,
u.keep_screen_awake,
u.audio_resume_rewind_seconds,
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
FROM users u
WHERE u.id = $1;
@@ -526,6 +527,9 @@ UPDATE users SET default_audio_speed = $2, updated_at = NOW() WHERE id = $1;
-- name: UpdateUserKeepScreenAwake :exec
UPDATE users SET keep_screen_awake = $2, updated_at = NOW() WHERE id = $1;
-- name: UpdateUserAudioResumeRewind :exec
UPDATE users SET audio_resume_rewind_seconds = $2, updated_at = NOW() WHERE id = $1;
-- Per-book playback speed preference (player plan §8).
-- name: GetAudioPlaybackSpeed :one
+31 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"bookhoard/templates"
@@ -24,7 +25,23 @@ type DocsHandler struct {
docsFS fs.FS
}
// resolveDocsPath — the docs folder lives at the repo root, but `go test`
// runs test binaries from the PACKAGE directory (/cmd/server/tests), so a
// plain relative "docs" resolves to a folder that doesn't exist there and
// every /docs route 404s in tests (2026-11-01). Walk upward, bounded,
// until the folder is real; prod (/app) matches on the first try.
func resolveDocsPath(p string) string {
for i := 0; i < 6; i++ {
if fi, err := os.Stat(filepath.Join(p, "index.md")); err == nil && !fi.IsDir() {
return p
}
p = filepath.Join("..", p)
}
return "docs"
}
func NewDocsHandler(docsPath string) *DocsHandler {
docsPath = resolveDocsPath(docsPath)
// Create goldmark renderer with syntax highlighting
md := goldmark.New(
goldmark.WithExtensions(
@@ -74,11 +91,24 @@ func (h *DocsHandler) LoadDocument(docPath string) (*templates.Document, error)
}
}
// Read the markdown file
// Read the markdown file. Folder URLs resolve to their index page:
// /docs/user/filters → user/filters/index.md (2026-11-01 — the
// filter manual is a folder: guide + cookbook + generated field
// reference).
content, err := fs.ReadFile(h.docsFS, docPath)
if err != nil {
if !strings.HasSuffix(docPath, "index.md") {
alt := strings.TrimSuffix(docPath, ".md") + "/index.md"
if altContent, altErr := fs.ReadFile(h.docsFS, alt); altErr == nil {
docPath = alt
content = altContent
} else {
return nil, fmt.Errorf("failed to read document: %w", err)
}
} else {
return nil, fmt.Errorf("failed to read document: %w", err)
}
}
// Convert markdown to HTML
var buf bytes.Buffer
+15 -2
View File
@@ -321,10 +321,23 @@ func (h *DocsHandler) BuildNavigation(currentPath string) *templates.Navigation
return nav
}
// containsURL reports whether currentPath is one of the given doc URLs.
// normalizeDocURL — folder-index pages SERVE at their directory URL
// (/docs/user/filters) while their nav entry carries the /index suffix
// (/docs/user/filters/index): same page, two spellings. Both sidebar
// checks (expansion + highlight) compare normalized forms, so folder
// pages expand and highlight like file pages — no per-page special
// cases (2026-11-01, the filter manual's folder).
func normalizeDocURL(u string) string {
u = strings.TrimRight(u, "/")
return strings.TrimSuffix(u, "/index")
}
// containsURL reports whether currentPath is one of the given doc URLs
// (folder-index URLs normalized — see normalizeDocURL).
func containsURL(currentPath string, urls []string) bool {
current := normalizeDocURL(currentPath)
for _, u := range urls {
if currentPath == u {
if current == normalizeDocURL(u) {
return true
}
}
+40
View File
@@ -4,6 +4,8 @@ import (
"os"
"path/filepath"
"testing"
"bookhoard/templates"
)
func TestPlaceDoc(t *testing.T) {
@@ -205,3 +207,41 @@ func TestBuildNavigationNothingCurrent(t *testing.T) {
t.Errorf("expected zero sections open when nothing matches (docs home / 404 renders fully closed), got %d", open)
}
}
// TestBuildNavigationFolderIndex (2026-11-01): folder-index pages serve
// at their directory URL (/docs/user/filters) while their nav entries
// carry /index (/docs/user/filters/index) — the sidebar must EXPAND the
// containing section/group and HIGHLIGHT the index item for the folder
// URL, exactly like a file page. The filter manual's folder is the
// first page of this shape.
func TestBuildNavigationFolderIndex(t *testing.T) {
// The REAL docs tree: the Filters group only exists because the
// filter manual (guide/cookbook/fields) lives in user/filters/.
handler := NewDocsHandler("../../docs")
nav := handler.BuildNavigation("/docs/user/filters")
var userGuide *templates.NavSection
for i := range nav.Sections {
if nav.Sections[i].Title == "User Guide" {
userGuide = &nav.Sections[i]
}
}
if userGuide == nil {
t.Fatal("User Guide section missing")
}
if userGuide.Collapsed {
t.Error("User Guide section must expand for /docs/user/filters")
}
var filters *templates.NavGroup
for i := range userGuide.Groups {
if userGuide.Groups[i].Title == "Filters" {
filters = &userGuide.Groups[i]
}
}
if filters == nil {
t.Fatal("Filters group missing from User Guide")
}
if filters.Collapsed {
t.Error("Filters group must expand for its folder URL")
}
}
+29
View File
@@ -16,6 +16,7 @@ import (
"math"
"net/http"
"os"
"strconv"
"strings"
"time"
@@ -126,6 +127,10 @@ type UserProfile struct {
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty"`
// Whether the web reader/player should hold a Screen Wake Lock.
KeepScreenAwake bool `json:"keep_screen_awake"`
// Resume rewind: seconds before the saved position the audio
// player starts (open-resume and every genuine unpause). 0 is a
// real value (exact resume) — never omitted.
AudioResumeRewind int `json:"audio_resume_rewind_seconds"`
}
type ProfileUpdateRequest struct {
@@ -142,6 +147,11 @@ type ProfileUpdateRequest struct {
// 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"`
// Resume rewind seconds, as the form select sends it ("0"/"5"/"10").
// The string shape (keep_screen_awake precedent) lets the form
// select an explicit 0=Off while JSON clients that omit it decode
// as "" and leave the column untouched.
AudioResumeRewind string `json:"audio_resume_rewind_seconds,omitempty" form:"audio_resume_rewind_seconds" validate:"omitempty,oneof=0 5 10"`
}
// Register handles POST /api/auth/register
@@ -551,6 +561,7 @@ func (h *AuthHandler) GetProfile(c *echo.Context) error {
Theme: user.Theme.String,
DefaultAudioSpeed: float64(user.DefaultAudioSpeed),
KeepScreenAwake: user.KeepScreenAwake,
AudioResumeRewind: int(user.AudioResumeRewindSeconds),
})
}
@@ -715,6 +726,24 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
}
}
// Resume-rewind preference. The form select always sends it
// ("0"/"5"/"10"); JSON clients that omit it decode as "" and leave
// the column untouched. Strict allow-list — the three choices the
// UI offers are the only values the API accepts.
if req.AudioResumeRewind != "" {
rewind, err := strconv.Atoi(req.AudioResumeRewind)
if err != nil || (rewind != 0 && rewind != 5 && rewind != 10) {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "audio_resume_rewind_seconds must be 0, 5, or 10"})
}
err = h.db.UpdateUserAudioResumeRewind(c.Request().Context(), database.UpdateUserAudioResumeRewindParams{
ID: targetUserUUID,
AudioResumeRewindSeconds: int32(rewind),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
// Keep-screen-awake preference. The form dropdown always sends it
// ("true"/"false"); JSON clients that omit it decode as "" and leave
// the column untouched.
+8 -2
View File
@@ -3101,14 +3101,19 @@ func (mh *MediaHandler) GetAudioChapters(c *echo.Context) error {
// 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.
// user — anonymous readers just get 1.0. The resume rewind rides
// the same user row (0 = exact resume).
playbackSpeed, defaultSpeed := 1.0, 1.0
resumeRewind := 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 {
if err == nil {
if userRow.DefaultAudioSpeed > 0 {
defaultSpeed = float64(userRow.DefaultAudioSpeed)
}
resumeRewind = int(userRow.AudioResumeRewindSeconds)
}
bookSpeed, err := mh.db.GetAudioPlaybackSpeed(c.Request().Context(), database.GetAudioPlaybackSpeedParams{
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
@@ -3130,6 +3135,7 @@ func (mh *MediaHandler) GetAudioChapters(c *echo.Context) error {
"tracks": tracks,
"playback_speed": playbackSpeed,
"default_playback_speed": defaultSpeed,
"resume_rewind_seconds": resumeRewind,
})
}
+1
View File
@@ -76,6 +76,7 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
LastName: utils.PgTextString(userDB.LastName),
CreatedAt: userDB.CreatedAt.Time,
DefaultAudioSpeed: float64(userDB.DefaultAudioSpeed),
AudioResumeRewind: int(userDB.AudioResumeRewindSeconds),
KeepScreenAwake: userDB.KeepScreenAwake,
ShowPodcastsNav: showPodcasts,
}, nil
+157
View File
@@ -0,0 +1,157 @@
package services
import (
"fmt"
"os"
"sort"
"strings"
"testing"
)
// The user manual's field reference (docs/user/filters/fields.md) is
// GENERATED from FilterFieldsPayload — the single source of truth the
// real menus render from — and pinned by this test: if the registry
// changes without regenerating the docs, this fails. Docs cannot drift
// from working code (2026-11-01, user decision).
//
// Regenerate with: FILTER_DOCS_REGEN=1 go test ./internal/services -run TestFieldReferenceDocIsCurrent
const fieldRefPath = "../../docs/user/filters/fields.md"
func TestFieldReferenceDocIsCurrent(t *testing.T) {
doc := GenerateFieldReferenceDoc()
if os.Getenv("FILTER_DOCS_REGEN") == "1" {
if err := os.WriteFile(fieldRefPath, []byte(doc), 0o644); err != nil {
t.Fatalf("write field reference: %v", err)
}
t.Log("rewrote " + fieldRefPath)
return
}
existing, err := os.ReadFile(fieldRefPath)
if err != nil {
t.Fatalf("docs/user/filters/fields.md is missing — regenerate with FILTER_DOCS_REGEN=1 go test ./internal/services -run TestFieldReferenceDocIsCurrent: %v", err)
}
if string(existing) != doc {
t.Fatal("docs/user/filters/fields.md is STALE — the filter registry changed without regenerating the docs. " +
"Run FILTER_DOCS_REGEN=1 go test ./internal/services -run TestFieldReferenceDocIsCurrent and commit the refreshed file.")
}
}
// kindPhrase — what the value cell looks like for a user, per value kind.
func kindPhrase(kind string) string {
switch kind {
case VKText:
return "Type words."
case VKNumber:
return "Type a number."
case VKDays:
return "Type a number of days."
case VKId:
return "Pick from a dropdown of real names — picks become removable chips, and you can pick several."
case VKFixed:
return "Choose from a fixed list of options."
case VKNone:
return "No value needed — the question is the whole rule."
}
return ""
}
// opPhrase — what each operator asks, in user words.
func opPhrase(op, kind string) string {
switch op {
case "contains":
return "contains these words"
case "not_contains":
return "does NOT contain these words"
case "is":
if kind == VKId {
return "is one of the ones you pick"
}
return "matches exactly"
case "is_not", "isn't":
return "matches anything but this"
case "in":
return "is one of the ones you pick (any one is enough)"
case "not_in":
return "is none of the ones you pick"
case "in_any":
return "is in at least one of the collections you pick"
case "none_of":
return "is in none of the collections you pick"
case "has":
return "has a value"
case "missing":
return "is empty or missing"
case "is_true":
return "is switched on"
case "is_false":
return "is switched off"
case "between":
return "falls between two numbers"
case "at_least":
return "is at least this"
case "at_most":
return "is at most this"
}
return "—"
}
func GenerateFieldReferenceDoc() string {
var b strings.Builder
b.WriteString("<!-- GENERATED by TestFieldReferenceDocIsCurrent (internal/services/filter_docs_test.go)\n")
b.WriteString(" from the filter registry — the same source the real menus render. DO NOT EDIT by hand:\n")
b.WriteString(" regenerate with FILTER_DOCS_REGEN=1 go test ./internal/services -run TestFieldReferenceDocIsCurrent -->\n\n")
b.WriteString("# Every filter field\n\n")
b.WriteString("This is the complete list of everything the filters can ask about, generated straight from the\n")
b.WriteString("same source the app's menus render — it cannot drift from what you see in the UI.\n\n")
b.WriteString("Filters come in two worlds:\n\n")
b.WriteString("- **Books** — rules about books and your reading of them.\n")
b.WriteString("- **Episodes** — rules about podcast episodes (feeds, downloaded, played).\n\n")
b.WriteString("Every rule is **a field + a question + your values**. The \"Ask\" column is the question;\n")
b.WriteString("the \"Your values\" column is what you provide. The **default** question is what a new rule\n")
b.WriteString("starts with.\n\n")
for _, scope := range []string{ScopeBooks, ScopeEpisodes} {
scopeTitle := "Books"
if scope == ScopeEpisodes {
scopeTitle = "Podcast episodes"
}
fmt.Fprintf(&b, "## %s\n\n", scopeTitle)
fields := FilterFieldsPayload(scope)
groups := []string{}
byGroup := map[string][]FilterFieldWire{}
for _, f := range fields {
if _, seen := byGroup[f.Group]; !seen {
groups = append(groups, f.Group)
}
byGroup[f.Group] = append(byGroup[f.Group], f)
}
sort.Strings(groups)
for _, g := range groups {
fmt.Fprintf(&b, "### %s\n\n", g)
for _, f := range byGroup[g] {
fmt.Fprintf(&b, "**%s** — %s\n\n", f.Label, kindPhrase(f.ValueKind))
lines := make([]string, 0, len(f.Ops))
for _, op := range f.Ops {
label := op.Label
if label == "" {
label = op.Key
}
def := ""
if op.Key == f.DefaultOp {
def = " *(default)*"
}
lines = append(lines, fmt.Sprintf("| %s | %s%s |", label, opPhrase(op.Key, f.ValueKind), def))
}
b.WriteString("| Ask | Means |\n| --- | --- |\n")
b.WriteString(strings.Join(lines, "\n"))
b.WriteString("\n\n")
}
}
}
b.WriteString("---\n\n")
b.WriteString("*Anything here that doesn't match what the app actually shows is a bug — this page is generated\n")
b.WriteString("from the app's own menus and a test fails if they ever disagree.*\n")
return b.String()
}
+19
View File
@@ -255,6 +255,25 @@ templ AudioReader(user User, metadata ReaderMetadata) {
}
</select>
</div>
<!-- Resume rewind — player-side control of the
account setting (the profile page keeps
its select; same PUT, applied live). -->
<div class="flex items-center justify-between gap-3 px-1 py-2 rounded-lg">
<span class="text-sm" style="color: var(--text-secondary);">Resume rewind</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="Resume rewind"
@change="applyResumeRewindFromSelect($event)"
>
for _, secs := range ResumeRewindOptions {
<option
value={ fmt.Sprintf("%d", secs) }
:selected={ fmt.Sprintf("resumeRewind === %d", secs) }
>{ ResumeRewindLabel(secs) }</option>
}
</select>
</div>
</div>
</div>
</span>
+136 -87
View File
@@ -394,166 +394,188 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</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);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</select></div><!-- Resume rewind — player-side control of the\n\t\t\t\t\t\t\t account setting (the profile page keeps\n\t\t\t\t\t\t\t its select; same PUT, applied live). --><div class=\"flex items-center justify-between gap-3 px-1 py-2 rounded-lg\"><span class=\"text-sm\" style=\"color: var(--text-secondary);\">Resume rewind</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=\"Resume rewind\" @change=\"applyResumeRewindFromSelect($event)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if metadata.CoverImagePath != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<img src=\"")
for _, secs := range ResumeRewindOptions {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", secs))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 268, Col: 36}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 271, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" alt=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" :selected=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("resumeRewind === %d", secs))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 269, Col: 27}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 272, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" class=\"w-full h-full object-cover\" data-fallback=\"hide\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<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, 37, "</div><div class=\"max-w-md px-4\"><h1 class=\"font-semibold text-lg leading-snug line-clamp-2\" style=\"color: var(--text-primary);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(ResumeRewindLabel(secs))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 278, Col: 117}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 273, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</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=\"onScrubInput($event)\" @change=\"commitScrub()\" 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=\"railTickStyle(tick)\"></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=\"cycleEndTLine truncated
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if metadata.PodcastSequence != nil {
if metadata.PodcastSequence.PrevURL != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<button type=\"button\" @click=\"goToPrevEpisode()\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Previous episode\" title=\"Previous episode\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = audioStepPrevIcon().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<span class=\"rounded-full p-2.5 opacity-30 cursor-not-allowed\" aria-disabled=\"true\" title=\"No previous episode\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = audioStepPrevIcon().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</span> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<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);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</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
}
templ_7745c5c3_Err = audioStepPrevIcon().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<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><!-- Outer right button: books = next chapter; podcasts = next\n\t\t\t\t\t EPISODE (disabled at the end of the feed). -->")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if metadata.PodcastSequence != nil {
if metadata.PodcastSequence.NextURL != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<button type=\"button\" @click=\"goToNextEpisode(false)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"")
if metadata.CoverImagePath != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("Next episode: " + metadata.PodcastSequence.NextTitle)
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 471, Col: 74}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 287, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" title=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue("Next: " + metadata.PodcastSequence.NextTitle)
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 472, Col: 61}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 288, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = audioStepNextIcon().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</button>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" class=\"w-full h-full object-cover\" data-fallback=\"hide\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<span class=\"rounded-full p-2.5 opacity-30 cursor-not-allowed\" aria-disabled=\"true\" title=\"No next episode\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<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 = audioStepNextIcon().Render(ctx, templ_7745c5c3_Buffer)
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</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
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</span>")
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 297, Col: 117}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</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=\"onScrubInput($event)\" @change=\"commitScrub()\" 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=\"railTickStyle(tick)\"></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=\"cycleEndTLine truncated
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if metadata.PodcastSequence != nil {
if metadata.PodcastSequence.PrevURL != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<button type=\"button\" @click=\"goToPrevEpisode()\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Previous episode\" title=\"Previous episode\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = audioStepPrevIcon().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<span class=\"rounded-full p-2.5 opacity-30 cursor-not-allowed\" aria-disabled=\"true\" title=\"No previous episode\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = audioStepPrevIcon().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<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);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<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);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = audioStepPrevIcon().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<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><!-- Outer right button: books = next chapter; podcasts = next\n\t\t\t\t\t EPISODE (disabled at the end of the feed). -->")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if metadata.PodcastSequence != nil {
if metadata.PodcastSequence.NextURL != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<button type=\"button\" @click=\"goToNextEpisode(false)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue("Next episode: " + metadata.PodcastSequence.NextTitle)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 490, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\" title=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue("Next: " + metadata.PodcastSequence.NextTitle)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 491, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -561,12 +583,39 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</button>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<span class=\"rounded-full p-2.5 opacity-30 cursor-not-allowed\" aria-disabled=\"true\" title=\"No next episode\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = audioStepNextIcon().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</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></svg></button><div x-show=\"skipOpen\" 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-[7.5rem]\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;\"><template x-for=\"secs in skipChoices\" :key=\"'skip' + secs\"><button type=\"button\" @click=\"pickSkip(secs)\" class=\"w-full text-left px-3 py-1.5 rounded-md text-sm\" :class=\"isSkipActive(secs) ? 'font-semibold' : ''\" :style=\"isSkipActive(secs) ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'\" x-text=\"secs + ' seconds'\"></button></template></div></div><div class=\"relative\" @click.outside=\"speedOpen = false\"><button type=\"button\" @click=\"speedOpen = !speedOpen\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :aria-label=\"'Playback speed, currently ' + speed + ' times'\" style=\"color: var(--text-secondary);\"><span>Speed</span> <span class=\"font-semibold\" style=\"color: var(--text-primary);\" x-text=\"speed + '×'\">1×</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=\"speedOpen\" 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-[7rem]\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;\"><template x-for=\"rate in speeds\" :key=\"'speed' + rate\"><button type=\"button\" @click=\"pickSpeed(rate)\" class=\"w-full text-left px-3 py-1.5 rounded-md text-sm\" :class=\"isSpeedActive(rate) ? 'font-semibold' : ''\" :style=\"isSpeedActive(rate) ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'\" x-text=\"rate + '×' + (isDefaultSpeed(rate) ? ' · default' : '')\"></button></template><!-- §8: pin the current rate as the user default —\n\t\t\t\t\t\t books without their own speed pick it up --><button type=\"button\" x-show=\"!isDefaultSpeed(speed)\" @click=\"pickDefaultSpeed(speed)\" class=\"w-full text-left px-3 py-1.5 mt-1 rounded-md text-xs border-t\" style=\"border-color: var(--border); color: var(--text-secondary);\" x-text=\"'Make ' + speed + '× my default'\"></button></div></div><button type=\"button\" @click=\"toggleBookmarkCapture()\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :class=\"bookmarkCaptureOpen ? 'opacity-100' : 'opacity-70'\" aria-label=\"Add bookmark at current position\" 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=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path> <path d=\"M12 7v6M9 10h6\"></path></svg> <span>Bookmark</span></button><div class=\"relative\" @click.outside=\"sleepOpen = false\"><button type=\"button\" @click=\"sleepOpen = !sleepOpen\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :aria-label=\"'Sleep timer, currently ' + sleepLabelStr\" style=\"color: var(--text-secondary);\"><svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z\"></path></svg> <span class=\"font-semibold tabular-nums\" style=\"color: var(--text-primary);\" x-text=\"sleepLabelStr === 'Off' ? 'Sleep' : sleepLabelStr\">Sleep</span> <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button><div xLine truncated
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<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);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = audioStepNextIcon().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</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></svg></button><div x-show=\"skipOpen\" 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-[7.5rem]\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;\"><template x-for=\"secs in skipChoices\" :key=\"'skip' + secs\"><button type=\"button\" @click=\"pickSkip(secs)\" class=\"w-full text-left px-3 py-1.5 rounded-md text-sm\" :class=\"isSkipActive(secs) ? 'font-semibold' : ''\" :style=\"isSkipActive(secs) ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'\" x-text=\"secs + ' seconds'\"></button></template></div></div><div class=\"relative\" @click.outside=\"speedOpen = false\"><button type=\"button\" @click=\"speedOpen = !speedOpen\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :aria-label=\"'Playback speed, currently ' + speed + ' times'\" style=\"color: var(--text-secondary);\"><span>Speed</span> <span class=\"font-semibold\" style=\"color: var(--text-primary);\" x-text=\"speed + '×'\">1×</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=\"speedOpen\" 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-[7rem]\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;\"><template x-for=\"rate in speeds\" :key=\"'speed' + rate\"><button type=\"button\" @click=\"pickSpeed(rate)\" class=\"w-full text-left px-3 py-1.5 rounded-md text-sm\" :class=\"isSpeedActive(rate) ? 'font-semibold' : ''\" :style=\"isSpeedActive(rate) ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'\" x-text=\"rate + '×' + (isDefaultSpeed(rate) ? ' · default' : '')\"></button></template><!-- §8: pin the current rate as the user default —\n\t\t\t\t\t\t books without their own speed pick it up --><button type=\"button\" x-show=\"!isDefaultSpeed(speed)\" @click=\"pickDefaultSpeed(speed)\" class=\"w-full text-left px-3 py-1.5 mt-1 rounded-md text-xs border-t\" style=\"border-color: var(--border); color: var(--text-secondary);\" x-text=\"'Make ' + speed + '× my default'\"></button></div></div><button type=\"button\" @click=\"toggleBookmarkCapture()\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :class=\"bookmarkCaptureOpen ? 'opacity-100' : 'opacity-70'\" aria-label=\"Add bookmark at current position\" 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=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path> <path d=\"M12 7v6M9 10h6\"></path></svg> <span>Bookmark</span></button><div class=\"relative\" @click.outside=\"sleepOpen = false\"><button type=\"button\" @click=\"sleepOpen = !sleepOpen\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :aria-label=\"'Sleep timer, currently ' + sleepLabelStr\" style=\"color: var(--text-secondary);\"><svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z\"></path></svg> <span class=\"font-semibold tabular-nums\" style=\"color: var(--text-primary);\" x-text=\"sleepLabelStr === 'Off' ? 'Sleep' : sleepLabelStr\">Sleep</span> <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button><div xLine truncated
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+1
View File
@@ -133,6 +133,7 @@ templ BookShelf(
>
<div class="sticky top-0 flex items-center justify-between px-6 py-4 border-b" style="background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
<h2 class="text-lg font-bold" style="color: var(--text-primary)">Filters</h2>
<a href="/docs/user/filters" target="_blank" rel="noopener" class="icon-btn" aria-label="Filter guide" title="How filters work">?</a>
<button type="button" @click="filtersOpen = false" class="icon-btn" aria-label="Close filters">
@Icon("close", "h-5 w-5")
</button>
+21 -21
View File
@@ -171,7 +171,7 @@ func BookShelf(
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<span>Clear</span></button></div><!-- Filter drawer --><div x-show=\"filtersOpen\" x-cloak class=\"fixed inset-0 z-[65]\" @keydown.escape.window=\"filtersOpen = false\"><div class=\"absolute inset-0\" style=\"background-color: var(--surface-overlay);\" @click=\"filtersOpen = false\"></div><div class=\"absolute right-0 top-0 bottom-0 w-full max-w-md overflow-y-auto\" style=\"background-color: var(--bg-secondary); box-shadow: var(--shadow-pop);\" x-transition:enter=\"transition ease-out duration-200\" x-transition:enter-start=\"translate-x-full\" x-transition:enter-end=\"translate-x-0\" x-transition:leave=\"transition ease-in duration-150\" x-transition:leave-start=\"translate-x-0\" x-transition:leave-end=\"translate-x-full\"><div class=\"sticky top-0 flex items-center justify-between px-6 py-4 border-b\" style=\"background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\"><h2 class=\"text-lg font-bold\" style=\"color: var(--text-primary)\">Filters</h2><button type=\"button\" @click=\"filtersOpen = false\" class=\"icon-btn\" aria-label=\"Close filters\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<span>Clear</span></button></div><!-- Filter drawer --><div x-show=\"filtersOpen\" x-cloak class=\"fixed inset-0 z-[65]\" @keydown.escape.window=\"filtersOpen = false\"><div class=\"absolute inset-0\" style=\"background-color: var(--surface-overlay);\" @click=\"filtersOpen = false\"></div><div class=\"absolute right-0 top-0 bottom-0 w-full max-w-md overflow-y-auto\" style=\"background-color: var(--bg-secondary); box-shadow: var(--shadow-pop);\" x-transition:enter=\"transition ease-out duration-200\" x-transition:enter-start=\"translate-x-full\" x-transition:enter-end=\"translate-x-0\" x-transition:leave=\"transition ease-in duration-150\" x-transition:leave-start=\"translate-x-0\" x-transition:leave-end=\"translate-x-full\"><div class=\"sticky top-0 flex items-center justify-between px-6 py-4 border-b\" style=\"background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\"><h2 class=\"text-lg font-bold\" style=\"color: var(--text-primary)\">Filters</h2><a href=\"/docs/user/filters\" target=\"_blank\" rel=\"noopener\" class=\"icon-btn\" aria-label=\"Filter guide\" title=\"How filters work\">?</a> <button type=\"button\" @click=\"filtersOpen = false\" class=\"icon-btn\" aria-label=\"Close filters\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -197,7 +197,7 @@ func BookShelf(
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 154, Col: 77}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 155, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -215,7 +215,7 @@ func BookShelf(
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 156, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 157, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -236,7 +236,7 @@ func BookShelf(
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 161, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 162, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
@@ -249,7 +249,7 @@ func BookShelf(
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 161, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 162, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -262,7 +262,7 @@ func BookShelf(
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 161, Col: 77}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 162, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -280,7 +280,7 @@ func BookShelf(
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 163, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 164, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
@@ -293,7 +293,7 @@ func BookShelf(
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 163, Col: 48}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 164, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -306,7 +306,7 @@ func BookShelf(
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 163, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 164, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
@@ -325,7 +325,7 @@ func BookShelf(
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 166, Col: 34}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 167, Col: 34}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
@@ -338,7 +338,7 @@ func BookShelf(
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 166, Col: 56}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 167, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
@@ -351,7 +351,7 @@ func BookShelf(
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 166, Col: 76}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 167, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
@@ -369,7 +369,7 @@ func BookShelf(
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 168, Col: 34}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 169, Col: 34}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
@@ -382,7 +382,7 @@ func BookShelf(
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 168, Col: 47}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 169, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
@@ -395,7 +395,7 @@ func BookShelf(
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 168, Col: 67}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 169, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
@@ -415,7 +415,7 @@ func BookShelf(
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(JSONArg(services.FilterFieldsPayload("books")))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 181, Col: 76}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 182, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
@@ -428,7 +428,7 @@ func BookShelf(
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(labelsJSON)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 182, Col: 36}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 183, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
@@ -458,7 +458,7 @@ func BookShelf(
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 223, Col: 59}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 224, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
@@ -481,7 +481,7 @@ func BookShelf(
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 232, Col: 126}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 233, Col: 126}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
@@ -512,7 +512,7 @@ func BookShelf(
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 241, Col: 32}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 242, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
@@ -525,7 +525,7 @@ func BookShelf(
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 246, Col: 126}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 247, Col: 126}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
if templ_7745c5c3_Err != nil {
+1
View File
@@ -60,6 +60,7 @@ templ CollectionRules(user User, collection CollectionData, fieldsJSON string, t
>
<div class="flex items-center justify-between mb-3 px-1">
<h2 class="text-lg font-bold" style="color: var(--text-primary)">Filter tree</h2>
<a href="/docs/user/filters" target="_blank" rel="noopener" class="icon-btn" aria-label="Filter guide" title="How the filter tree works">?</a>
</div>
@FilterBuilderRows("item")
<div class="mt-4 pt-4 border-t flex justify-end gap-2" style="border-color: var(--border);">
+1 -1
View File
@@ -219,7 +219,7 @@ func CollectionRules(user User, collection CollectionData, fieldsJSON string, tr
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" class=\"card p-6\"><div class=\"flex items-center justify-between mb-3 px-1\"><h2 class=\"text-lg font-bold\" style=\"color: var(--text-primary)\">Filter tree</h2></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" class=\"card p-6\"><div class=\"flex items-center justify-between mb-3 px-1\"><h2 class=\"text-lg font-bold\" style=\"color: var(--text-primary)\">Filter tree</h2><a href=\"/docs/user/filters\" target=\"_blank\" rel=\"noopener\" class=\"icon-btn\" aria-label=\"Filter guide\" title=\"How the filter tree works\">?</a></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+20
View File
@@ -144,3 +144,23 @@ func TestDocsNotFoundRenders(t *testing.T) {
t.Error("404 page must not pull the tailwind CDN")
}
}
// docsNavClass folder-index normalization (2026-11-01): folder-index
// pages serve at their directory URL while nav entries carry /index —
// the Overview item must highlight for the folder URL, file pages keep
// their own highlight, and cross-highlighting stays impossible.
func TestDocsNavClassFolderIndex(t *testing.T) {
active := docsNavClass("/docs/user/filters", "/docs/user/filters/index", "pl-9")
if !strings.Contains(active, "bg-brand/15") {
t.Errorf("folder-index item must highlight as active, got %q", active)
}
if !strings.Contains(docsNavClass("/docs/user/filters/index", "/docs/user/filters", "pl-9"), "bg-brand/15") {
t.Errorf("normalization is symmetric (either URL order highlights)")
}
if !strings.Contains(docsNavClass("/docs/user/filters/cookbook", "/docs/user/filters/cookbook", "pl-9"), "bg-brand/15") {
t.Error("file-page highlight regression")
}
if strings.Contains(docsNavClass("/docs/user/filters/cookbook", "/docs/user/filters/index", "pl-9"), "bg-brand/15") {
t.Error("cookbook URL wrongly highlights the Overview item")
}
}
+11 -2
View File
@@ -11,11 +11,20 @@ templ FilterBuilderRows(countNoun string) {
<div>
<template x-if="row.type === 'groupOpen'">
<div :class="row.depth > 0 ? 'fb-nested' : ''" class="flex items-center gap-1.5 flex-wrap py-1" :style="'margin-left:' + (row.depth * 14) + 'px'">
<span class="text-[11px] font-semibold uppercase tracking-wide" style="color: var(--text-secondary)" x-text="row.depth === 0 ? 'Match' : 'Group'"></span>
<!-- THE group mode control (2026-11-01): the header was a dead label
while the engine honored group modes all along — clicking it now
flips ALL ⇄ ANY for that group. Root (path=[]) is the whole-query
switch; the remove × hides there (nothing to remove). -->
<button type="button" class="fb-group-mode" :class="groupMode(row.path) === 'any' ? 'fb-group-any' : ''" :aria-pressed="groupMode(row.path) === 'any'" :title="groupMode(row.path) === 'any' ? 'ANY — at least one rule in this group matches. Click for ALL.' : 'ALL — every rule in this group must match. Click for ANY.'" @click="toggleGroupMode(row.path)">
<span x-text="row.path.length === 0 ? 'Match' : 'Group'"></span>
<span x-text="groupMode(row.path) === 'any' ? 'ANY' : 'ALL'"></span>
</button>
<span class="flex-1"></span>
<template x-if="row.path.length > 0">
<button type="button" class="fb-mini-btn fb-danger" @click="removeNode(row.path)" aria-label="Remove group">×</button>
</template>
<button type="button" class="fb-mini-btn" @click="addRule(row.path)">+ rule</button>
<button type="button" class="fb-mini-btn" @click="addGroup(row.path)">+ group</button>
<button type="button" class="fb-mini-btn fb-danger" @click="removeNode(row.path)" aria-label="Remove group">×</button>
</div>
</template>
<template x-if="row.type === 'rule'">
+2 -2
View File
@@ -35,14 +35,14 @@ func FilterBuilderRows(countNoun string) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<template x-for=\"row in builderRows\" :key=\"row.key\"><div><template x-if=\"row.type === 'groupOpen'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-1\" :style=\"'margin-left:' + (row.depth * 14) + 'px'\"><span class=\"text-[11px] font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\" x-text=\"row.depth === 0 ? 'Match' : 'Group'\"></span> <span class=\"flex-1\"></span> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addRule(row.path)\">+ rule</button> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addGroup(row.path)\">+ group</button> <button type=\"button\" class=\"fb-mini-btn fb-danger\" @click=\"removeNode(row.path)\" aria-label=\"Remove group\">×</button></div></template><template x-if=\"row.type === 'rule'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-0.5\" :style=\"'margin-left:' + ((row.depth + 1) * 14) + 'px'\"><button type=\"button\" class=\"fb-connective fb-connective-btn\" :class=\"row.chipClickable ? '' : 'fb-chip-static'\" x-show=\"!row.isFirst\" x-text=\"row.chip\" @click=\"toggleConnective(row)\" :title=\"row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip\"></button><!-- field picker: grouped menu --><div class=\"relative\"><button type=\"button\" class=\"fb-picker\" :class=\"{'fb-picker-active': row.rule.field !== ''}\" @click=\"toggleMenu(row.key + ':field')\" x-text=\"fieldLabelOf(row.rule.field)\"></button><div class=\"fb-menu fb-menu-fields\" x-show=\"openMenu === row.key + ':field'\" @click.outside=\"openMenu = ''\"><div class=\"fb-menu-filter-wrap\"><input type=\"text\" class=\"fb-menu-filter\" placeholder=\"Filter fields…\" x-model=\"fieldFilter\" @keydown.enter.prevent @click.stop></div><div class=\"fb-menu-scroll\"><template x-for=\"grp in visibleFieldGroups\" :key=\"grp.group\"><div><div class=\"fb-menu-group\" x-text=\"grp.group\"></div><template x-for=\"f in grp.fields\" :key=\"f.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"row.rule.field === f.key ? 'fb-menu-item-active' : ''\" @click=\"setRuleField(row.path, f.key)\" x-text=\"f.label\"></button></template></div></template><div class=\"fb-menu-empty\" x-show=\"visibleFieldGroups.length === 0\">No matching fields</div></div></div></div><!-- operator picker (negation lives here) --><div class=\"relative\"><button type=\"button\" class=\"fb-picker fb-picker-op\" :class=\"{'fb-negated-text': isNegated(row.rule)}\" @click=\"toggleMenu(row.key + ':op')\" x-text=\"opLabelOf(row.rule)\"></button><div class=\"fb-menu\" x-show=\"openMenu === row.key + ':op'\" @click.outside=\"openMenu = ''\"><template x-for=\"o in opsFor(row.rule.field)\" :key=\"o.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"[row.rule.op === o.key ? 'fb-menu-item-active' : '', o.negated ? 'fb-negated-text' : '']\" @click=\"setRuleOp(row.path, o.key)\" x-text=\"o.label\"></button></template></div></div><!-- value cell: shape follows the operator + field kind --><template x-if=\"hasValueCell(row.rule) && valueArity(row.rule) === 'two'\"><span class=\"flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[0] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" @click.outside=\"suggestionsOpen = ''\" placeholder=\"From\"> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">and</span> <input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[1] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 1, $el.value)\" placeholder=\"To\"></span></template><template x-if=\"hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')\"><span class=\"fb-value-cell relative flex items-center gap-1\" :class=\"isIdValueRow(row.path) ? 'fb-chipbox flex-wrap' : ''\"><template x-if=\"isIdValueRow(row.path)\"><span class=\"contents\"><!-- picked values as removable chips: the box\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\twraps and grows instead of a 9rem input\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tyou can't read past the first entry --><template x-for=\"chip in idChipsFor(row.path)\" :key=\"chip.id\"><span class=\"fb-chip\" :title=\"chip.label\"><span class=\"fb-chip-label\" x-text=\"chip.label\"></span> <button type=\"button\" class=\"fb-chip-x\" :aria-label=\"'Remove ' + chip.label\" @click=\"removeIdChip(row.path, chip.id)\">×</button></span></template><input type=\"text\" class=\"fb-value fb-chip-input\" :value=\"idDraftFor(row.path)\" @input.debounce.250ms=\"onValueCellInput(row.path, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" @keydown=\"chipInputKeydown(row.path, $event)\" :placeholder=\"valuePlaceholderFor(row.rule)\" :aria-label=\"fieldLabelOf(row.rule.field) + ' values'\"></span></template><template x-ifLine truncated
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<template x-for=\"row in builderRows\" :key=\"row.key\"><div><template x-if=\"row.type === 'groupOpen'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-1\" :style=\"'margin-left:' + (row.depth * 14) + 'px'\"><!-- THE group mode control (2026-11-01): the header was a dead label\n\t\t\t\t\t\t\t\t\t\t\t\t\twhile the engine honored group modes all along — clicking it now\n\t\t\t\t\t\t\t\t\t\t\t\t\tflips ALL ⇄ ANY for that group. Root (path=[]) is the whole-query\n\t\t\t\t\t\t\t\t\t\t\t\t\tswitch; the remove × hides there (nothing to remove). --><button type=\"button\" class=\"fb-group-mode\" :class=\"groupMode(row.path) === 'any' ? 'fb-group-any' : ''\" :aria-pressed=\"groupMode(row.path) === 'any'\" :title=\"groupMode(row.path) === 'any' ? 'ANY — at least one rule in this group matches. Click for ALL.' : 'ALL — every rule in this group must match. Click for ANY.'\" @click=\"toggleGroupMode(row.path)\"><span x-text=\"row.path.length === 0 ? 'Match' : 'Group'\"></span> <span x-text=\"groupMode(row.path) === 'any' ? 'ANY' : 'ALL'\"></span></button> <span class=\"flex-1\"></span><template x-if=\"row.path.length > 0\"><button type=\"button\" class=\"fb-mini-btn fb-danger\" @click=\"removeNode(row.path)\" aria-label=\"Remove group\">×</button></template><button type=\"button\" class=\"fb-mini-btn\" @click=\"addRule(row.path)\">+ rule</button> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addGroup(row.path)\">+ group</button></div></template><template x-if=\"row.type === 'rule'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-0.5\" :style=\"'margin-left:' + ((row.depth + 1) * 14) + 'px'\"><button type=\"button\" class=\"fb-connective fb-connective-btn\" :class=\"row.chipClickable ? '' : 'fb-chip-static'\" x-show=\"!row.isFirst\" x-text=\"row.chip\" @click=\"toggleConnective(row)\" :title=\"row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip\"></button><!-- field picker: grouped menu --><div class=\"relative\"><button type=\"button\" class=\"fb-picker\" :class=\"{'fb-picker-active': row.rule.field !== ''}\" @click=\"toggleMenu(row.key + ':field')\" x-text=\"fieldLabelOf(row.rule.field)\"></button><div class=\"fb-menu fb-menu-fields\" x-show=\"openMenu === row.key + ':field'\" @click.outside=\"openMenu = ''\"><div class=\"fb-menu-filter-wrap\"><input type=\"text\" class=\"fb-menu-filter\" placeholder=\"Filter fields…\" x-model=\"fieldFilter\" @keydown.enter.prevent @click.stop></div><div class=\"fb-menu-scroll\"><template x-for=\"grp in visibleFieldGroups\" :key=\"grp.group\"><div><div class=\"fb-menu-group\" x-text=\"grp.group\"></div><template x-for=\"f in grp.fields\" :key=\"f.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"row.rule.field === f.key ? 'fb-menu-item-active' : ''\" @click=\"setRuleField(row.path, f.key)\" x-text=\"f.label\"></button></template></div></template><div class=\"fb-menu-empty\" x-show=\"visibleFieldGroups.length === 0\">No matching fields</div></div></div></div><!-- operator picker (negation lives here) --><div class=\"relative\"><button type=\"button\" class=\"fb-picker fb-picker-op\" :class=\"{'fb-negated-text': isNegated(row.rule)}\" @click=\"toggleMenu(row.key + ':op')\" x-text=\"opLabelOf(row.rule)\"></button><div class=\"fb-menu\" x-show=\"openMenu === row.key + ':op'\" @click.outside=\"openMenu = ''\"><template x-for=\"o in opsFor(row.rule.field)\" :key=\"o.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"[row.rule.op === o.key ? 'fb-menu-item-active' : '', o.negated ? 'fb-negated-text' : '']\" @click=\"setRuleOp(row.path, o.key)\" x-text=\"o.label\"></button></template></div></div><!-- value cell: shape follows the operator + field kind --><template x-if=\"hasValueCell(row.rule) && valueArity(row.rule) === 'two'\"><span class=\"flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[0] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" @click.outside=\"suggestionsOpen = ''\" placeholder=\"From\"> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">and</span> <input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[1] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 1, $el.value)\" placeholder=\"To\"></span></template><template x-if=\"hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')\"><span class=\"fb-value-cell relative flex items-center gap-1\" :class=\"isIdValueRow(row.path) ? 'fb-chipbox flex-wrap' : ''\"><template x-if=\"isIdValueRow(row.path)\"><span class=\"contents\"><!-- picked values as removable chips: the box\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\twraps and grows instead of a 9rem input\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tyou caLine truncated
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("matchCountLoading ? 'counting…' : (matchCount === 1 ? '1 " + countNoun + " matches' : matchCount + ' " + countNoun + "s match')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `filter_builder.templ`, Line: 119, Col: 238}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `filter_builder.templ`, Line: 128, Col: 238}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
+11
View File
@@ -87,6 +87,17 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
}
</select>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Resume Rewind</label>
<select
name="audio_resume_rewind_seconds"
class="input"
>
<option value="0" selected?={ user.AudioResumeRewind == 0 }>Off (exact resume)</option>
<option value="5" selected?={ user.AudioResumeRewind == 5 }>5 seconds before</option>
<option value="10" selected?={ user.AudioResumeRewind == 10 }>10 seconds before</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Keep Screen Awake</label>
<select
+100 -70
View File
@@ -228,297 +228,327 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Keep Screen Awake</label> <select name=\"keep_screen_awake\" class=\"input\"><option value=\"true\"")
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)\">Resume Rewind</label> <select name=\"audio_resume_rewind_seconds\" class=\"input\"><option value=\"0\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.KeepScreenAwake {
if user.AudioResumeRewind == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, ">On</option> <option value=\"false\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, ">Off (exact resume)</option> <option value=\"5\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !user.KeepScreenAwake {
if user.AudioResumeRewind == 5 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, ">Off</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, 31, ">5 seconds before</option> <option value=\"10\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "UTC" {
if user.AudioResumeRewind == 10 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ">UTC (UTC+0)</option> <option value=\"Pacific/Honolulu\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ">10 seconds before</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Keep Screen Awake</label> <select name=\"keep_screen_awake\" class=\"input\"><option value=\"true\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Pacific/Honolulu" {
if user.KeepScreenAwake {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">Hawaii (UTC-10)</option> <option value=\"America/Anchorage\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">On</option> <option value=\"false\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "America/Anchorage" {
if !user.KeepScreenAwake {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">Off</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\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "America/Los_Angeles" {
if user.Timezone == "UTC" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, ">Pacific (UTC-8/-7)</option> <option value=\"America/Denver\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, ">UTC (UTC+0)</option> <option value=\"Pacific/Honolulu\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "America/Denver" {
if user.Timezone == "Pacific/Honolulu" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">Mountain (UTC-7/-6)</option> <option value=\"America/Phoenix\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">Hawaii (UTC-10)</option> <option value=\"America/Anchorage\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "America/Phoenix" {
if user.Timezone == "America/Anchorage" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, ">Mountain - no DST (UTC-7)</option> <option value=\"America/Chicago\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "America/Chicago" {
if user.Timezone == "America/Los_Angeles" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, ">Central (UTC-6/-5)</option> <option value=\"America/New_York\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, ">Pacific (UTC-8/-7)</option> <option value=\"America/Denver\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "America/New_York" {
if user.Timezone == "America/Denver" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ">Eastern (UTC-5/-4)</option> <option value=\"America/Sao_Paulo\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ">Mountain (UTC-7/-6)</option> <option value=\"America/Phoenix\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "America/Sao_Paulo" {
if user.Timezone == "America/Phoenix" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, ">Brasilia (UTC-3/-2)</option> <option value=\"Europe/London\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, ">Mountain - no DST (UTC-7)</option> <option value=\"America/Chicago\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Europe/London" {
if user.Timezone == "America/Chicago" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, ">British (UTC+0/+1)</option> <option value=\"Europe/Paris\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, ">Central (UTC-6/-5)</option> <option value=\"America/New_York\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Europe/Paris" {
if user.Timezone == "America/New_York" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, ">Central European (UTC+1/+2)</option> <option value=\"Europe/Helsinki\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, ">Eastern (UTC-5/-4)</option> <option value=\"America/Sao_Paulo\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Europe/Helsinki" {
if user.Timezone == "America/Sao_Paulo" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ">Eastern European (UTC+2/+3)</option> <option value=\"Europe/Moscow\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ">Brasilia (UTC-3/-2)</option> <option value=\"Europe/London\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Europe/Moscow" {
if user.Timezone == "Europe/London" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, ">Moscow (UTC+3)</option> <option value=\"Asia/Tehran\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, ">British (UTC+0/+1)</option> <option value=\"Europe/Paris\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Asia/Tehran" {
if user.Timezone == "Europe/Paris" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, ">Iran (UTC+3:30)</option> <option value=\"Asia/Dubai\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, ">Central European (UTC+1/+2)</option> <option value=\"Europe/Helsinki\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Asia/Dubai" {
if user.Timezone == "Europe/Helsinki" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, ">Gulf (UTC+4)</option> <option value=\"Asia/Karachi\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, ">Eastern European (UTC+2/+3)</option> <option value=\"Europe/Moscow\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Asia/Karachi" {
if user.Timezone == "Europe/Moscow" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, ">Pakistan (UTC+5)</option> <option value=\"Asia/Kolkata\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, ">Moscow (UTC+3)</option> <option value=\"Asia/Tehran\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Asia/Kolkata" {
if user.Timezone == "Asia/Tehran" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, ">India (UTC+5:30)</option> <option value=\"Asia/Dhaka\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, ">Iran (UTC+3:30)</option> <option value=\"Asia/Dubai\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Asia/Dhaka" {
if user.Timezone == "Asia/Dubai" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, ">Bangladesh (UTC+6)</option> <option value=\"Asia/Bangkok\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, ">Gulf (UTC+4)</option> <option value=\"Asia/Karachi\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Asia/Bangkok" {
if user.Timezone == "Asia/Karachi" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, ">Indochina (UTC+7)</option> <option value=\"Asia/Shanghai\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, ">Pakistan (UTC+5)</option> <option value=\"Asia/Kolkata\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Asia/Shanghai" {
if user.Timezone == "Asia/Kolkata" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, ">China (UTC+8)</option> <option value=\"Asia/Tokyo\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, ">India (UTC+5:30)</option> <option value=\"Asia/Dhaka\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Asia/Tokyo" {
if user.Timezone == "Asia/Dhaka" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, ">Japan/Korea (UTC+9)</option> <option value=\"Australia/Darwin\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, ">Bangladesh (UTC+6)</option> <option value=\"Asia/Bangkok\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Australia/Darwin" {
if user.Timezone == "Asia/Bangkok" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, ">Australian Central (UTC+9:30)</option> <option value=\"Australia/Sydney\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, ">Indochina (UTC+7)</option> <option value=\"Asia/Shanghai\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Australia/Sydney" {
if user.Timezone == "Asia/Shanghai" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, ">Australian Eastern (UTC+10/+11)</option> <option value=\"Pacific/Auckland\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, ">China (UTC+8)</option> <option value=\"Asia/Tokyo\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Pacific/Auckland" {
if user.Timezone == "Asia/Tokyo" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, ">New Zealand (UTC+12/+13)</option></select></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, ">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, 80, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, ">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, 82, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, ">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, 84, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, ">New Zealand (UTC+12/+13)</option></select></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if showRoleField {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<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, 86, "<div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Role</label> <select name=\"role\" class=\"input\"><option value=\"user\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Role == "user" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, ">User</option> <option value=\"admin\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, ">User</option> <option value=\"admin\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Role == "admin" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, " selected")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, ">Admin</option></select></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, ">Admin</option></select></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</div></div><div x-data=\"profilePasswordForm\"><div class=\"flex items-center gap-2 mb-4\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</div></div><div x-data=\"profilePasswordForm\"><div class=\"flex items-center gap-2 mb-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -526,12 +556,12 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Change Password</h3></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Change Password</h3></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if requireCurrentPassword {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<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\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<div class=\"space-y-4 max-w-md\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Current Password</label> <input type=\"password\" name=\"current_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" id=\"new-password\" name=\"new_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" id=\"confirm-password\" name=\"confirm_password\" class=\"input\"></div><div id=\"password-requirements\" class=\"text-xs\"></div><button type=\"button\" id=\"password-submit-btn\" hx-put=\"/api/auth/password\" hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -539,25 +569,25 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "Update Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "Update Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "<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, 95, "<p class=\"text-sm italic mb-4\" style=\"color: var(--text-secondary);\">As an admin, you can change this user's password without knowing their current password.</p><div class=\"space-y-4 max-w-md\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" id=\"new-password\" name=\"new_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" id=\"confirm-password\" name=\"confirm_password\" class=\"input\"></div><div id=\"password-requirements\" class=\"text-xs\"></div><button type=\"button\" id=\"password-submit-btn\" hx-put=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/password/" + user.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `profile_form.templ`, Line: 221, Col: 46}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `profile_form.templ`, Line: 232, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "\" hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"btn btn-primary\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "\" hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -565,22 +595,22 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "Reset Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "Reset Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</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, 98, "</div><div class=\"flex justify-end gap-3 pt-6 border-t\" style=\"border-color: var(--border);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if showCancelButton {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<button type=\"button\" @click=\"closeProfileModal()\" class=\"btn btn-secondary\">Cancel</button> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "<button type=\"button\" @click=\"closeProfileModal()\" class=\"btn btn-secondary\">Cancel</button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<button type=\"submit\" class=\"btn btn-primary\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "<button type=\"submit\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -588,7 +618,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "Save Changes</button></div><div id=\"profile-result\" class=\"mt-2\"></div></form>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "Save Changes</button></div><div id=\"profile-result\" class=\"mt-2\"></div></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+16
View File
@@ -1,6 +1,7 @@
package templates
import (
"fmt"
"time"
"github.com/a-h/templ"
@@ -20,6 +21,9 @@ type User struct {
// User-level default audiobook playback speed (plan §8); 0 means
// "unset" and renders as the 1× option.
DefaultAudioSpeed float64
// Resume rewind: seconds before the saved position the audio
// player starts (0 = exact resume; allow-list 0/5/10).
AudioResumeRewind int
// Whether the web reader/player should hold a Screen Wake Lock.
KeepScreenAwake bool
// Sidebar /podcasts entry gate (plan D13): the user can see >=1
@@ -32,6 +36,18 @@ type User struct {
// 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}
// ResumeRewindOptions mirrors the server's strict allow-list for
// audio_resume_rewind_seconds (0 = exact resume). Shared by the
// profile form and the audio player's settings popover.
var ResumeRewindOptions = []int{0, 5, 10}
func ResumeRewindLabel(seconds int) string {
if seconds == 0 {
return "Off"
}
return fmt.Sprintf("%d s before", seconds)
}
type PageData struct {
User User
CurrentPage string
+7 -2
View File
@@ -26,10 +26,15 @@ func activeClass(current, target string) string {
// docsNavClass returns classes for docs sidebar links (server-rendered active
// state — there is no client-side highlighter). indent sets the left padding,
// deeper for subcategory group items.
// deeper for subcategory group items. Folder-index pages compare normalized
// (their serving URL lacks the /index suffix their nav entry carries).
func docsNavClass(current, target, indent string) string {
base := "nav-item flex items-center " + indent + " pr-4 mr-2 py-1.5 rounded-lg text-sm transition-colors "
if strings.TrimRight(current, "/") == strings.TrimRight(target, "/") {
strip := func(u string) string {
u = strings.TrimRight(u, "/")
return strings.TrimSuffix(u, "/index")
}
if strip(current) == strip(target) {
return base + "bg-brand/15 text-brand font-medium"
}
return base + "text-content-muted hover:text-content hover:bg-surface-hover"
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { applyResumeRewind, normalizeResumeRewind } from "./audio-rewind";
describe("applyResumeRewind", () => {
it("steps back by the rewind amount", () => {
expect(applyResumeRewind(754, 5)).toBe(749);
expect(applyResumeRewind(754, 10)).toBe(744);
});
it("clamps at the start of the book", () => {
expect(applyResumeRewind(3, 5)).toBe(0);
expect(applyResumeRewind(0, 10)).toBe(0);
});
it("treats rewind 0 (setting off) as exact resume", () => {
expect(applyResumeRewind(754, 0)).toBe(754);
});
it("tolerates non-finite input", () => {
expect(applyResumeRewind(Number.NaN, 5)).toBe(0);
expect(applyResumeRewind(754, Number.NaN)).toBe(754);
expect(applyResumeRewind(Number.POSITIVE_INFINITY, 5)).toBe(0);
});
});
describe("normalizeResumeRewind", () => {
it("passes the allow-listed amounts", () => {
expect(normalizeResumeRewind(0)).toBe(0);
expect(normalizeResumeRewind(5)).toBe(5);
expect(normalizeResumeRewind(10)).toBe(10);
});
it("maps anything else to off", () => {
expect(normalizeResumeRewind(7)).toBe(0);
expect(normalizeResumeRewind(-5)).toBe(0);
expect(normalizeResumeRewind("12")).toBe(0);
expect(normalizeResumeRewind(undefined)).toBe(0);
expect(normalizeResumeRewind(Number.NaN)).toBe(0);
});
});
+21
View File
@@ -0,0 +1,21 @@
// Resume rewind (per-user server setting): how many seconds before the
// saved position the audiobook/podcast player starts. Applied on
// open-resume AND again on every genuine unpause — each event rewinds
// once from where playback currently sits, never stacked. Pure math;
// the Alpine component owns when to call it (the play-event suppress
// flag keeps restore-autoplay and track switches from double-firing).
// applyResumeRewind maps a position back by the rewind amount, clamped
// to the start of the book.
export function applyResumeRewind(positionSeconds: number, rewindSeconds: number): number {
if (!Number.isFinite(positionSeconds)) return 0;
if (!Number.isFinite(rewindSeconds) || rewindSeconds <= 0) return Math.max(0, positionSeconds);
return Math.max(0, positionSeconds - rewindSeconds);
}
// normalizeResumeRewind gates the server payload value to the setting's
// allow-list (0/5/10) — anything else means off.
export function normalizeResumeRewind(value: unknown): number {
const n = Number(value);
return n === 5 || n === 10 ? n : 0;
}
+54 -3
View File
@@ -16,6 +16,7 @@ import { Alpine } from "./alpine";
import { renderNoteHtml } from "./markdown";
import { showToast } from "./toast";
import { WakeLockManager } from "./wake-lock";
import { applyResumeRewind, normalizeResumeRewind } from "./audio-rewind";
import {
MOUSE_GUIDE,
resolveShortcut,
@@ -232,6 +233,14 @@ Alpine.data("audioReader", () => ({
// User-level default speed (plan §8): powers the dropdown's
// "(default)" marker; hydrated from the chapters payload.
defaultSpeed: 1,
// Resume rewind (per-user server setting, chapters payload): seconds
// before the saved position playback starts — on open AND again on
// every genuine unpause. 0 = exact resume.
resumeRewind: 0,
// Set around PROGRAMMATIC play() calls (restore-autoplay, track
// switches): their play event must not fire the unpause rewind — the
// open-rewind already positioned those. Cleared by the listener.
suppressUnpauseRewind: false,
// Exposed for the templ dropdown's x-for — templ cannot import the
// const directly; this property is the binding.
speeds: SPEEDS,
@@ -457,6 +466,16 @@ Alpine.data("audioReader", () => ({
audio.addEventListener("play", () => {
this.playing = true;
this.prevTick = performance.now();
// Resume rewind: a GENUINE unpause steps back resumeRewind
// seconds (per-user setting; 0 = off). Programmatic plays —
// restore-autoplay, track switches — set suppressUnpauseRewind:
// the open-rewind already positioned those, and the first play
// after open is the session start, not an unpause.
const programmatic = this.suppressUnpauseRewind;
this.suppressUnpauseRewind = false;
if (!programmatic && this.resumeRewind > 0 && audio.currentTime > 0) {
this.seekTo(applyResumeRewind(this.globalPosition(), this.resumeRewind));
}
// Manual play during a sleep fade aborts the expiry (the user
// is awake and asked for audio); stopSleep restores the volume.
if (this.sleep.mode === "fading") this.stopSleep();
@@ -551,7 +570,12 @@ Alpine.data("audioReader", () => ({
audio.playbackRate = this.speed;
audio.load();
}
if (resume) audio.play().catch(() => showToast("Playback failed to start", "error"));
if (resume) {
// Track-switch autoplay is programmatic: its play event must not
// fire the unpause rewind (the seek above IS the position).
this.suppressUnpauseRewind = true;
audio.play().catch(() => showToast("Playback failed to start", "error"));
}
},
async restorePosition() {
@@ -575,6 +599,7 @@ Alpine.data("audioReader", () => ({
}
const serverDefault = Number(data.default_playback_speed);
if (SPEEDS.includes(serverDefault)) this.defaultSpeed = serverDefault;
this.resumeRewind = normalizeResumeRewind(data.resume_rewind_seconds);
this.buildTracks(data);
this.recomputeTicks();
const audio = this.audio();
@@ -593,15 +618,17 @@ Alpine.data("audioReader", () => ({
const offset = Number(p.audio_offset_seconds ?? 0);
// Banner/deep-link jumps (?loc=audio:<seconds>) win over the
// stored position: the user explicitly asked for this spot.
// The saved-position branches start resumeRewind seconds early
// (the re-orientation gap; clamped at the book start).
const locParam = new URLSearchParams(window.location.search).get("loc") || "";
if (locParam.startsWith("audio:")) {
const at = Number(locParam.slice("audio:".length));
if (Number.isFinite(at)) this.seekTo(at);
} else if (Number.isFinite(p.percentage) && this.bookDuration > 0) {
const at = Number(p.percentage) * this.bookDuration;
const at = applyResumeRewind(Number(p.percentage) * this.bookDuration, this.resumeRewind);
this.seekTo(at);
} else if (chapter > 0 && this.chapters[chapter]?.start != null) {
this.seekTo(this.chapters[chapter].start + offset);
this.seekTo(applyResumeRewind(this.chapters[chapter].start + offset, this.resumeRewind));
}
this.currentChapterIdx = Math.max(0, chapter);
this.tickChapter();
@@ -762,6 +789,27 @@ Alpine.data("audioReader", () => ({
applyDefaultSpeedFromSelect(e: Event) {
this.setDefaultSpeed(Number((e.target as HTMLSelectElement).value));
},
// Player-side control of the account setting: applies LIVE (the
// next open/unpause uses it — no reload) and rides the profile
// endpoint like the profile-page select. String wire shape — the
// server binds audio_resume_rewind_seconds keep_screen_awake-style.
applyResumeRewindFromSelect(e: Event) {
const seconds = normalizeResumeRewind((e.target as HTMLSelectElement).value);
this.resumeRewind = seconds;
fetch("/api/auth/profile", {
method: "PUT",
headers: { ...getAuthHeaders(), "content-type": "application/json" },
body: JSON.stringify({ audio_resume_rewind_seconds: String(seconds) }),
})
.then((r) => {
if (!r.ok) throw new Error(String(r.status));
showToast(
seconds === 0 ? "Resume rewind off" : `Resume rewind: ${seconds}s before saved position`,
"success",
);
})
.catch(() => showToast("Resume rewind not saved", "error"));
},
isDefaultSpeed(rate: number): boolean {
return this.defaultSpeed === rate;
},
@@ -1460,6 +1508,9 @@ Alpine.data("audioReader", () => ({
if (!this.autoplayOn) return;
const audio = this.audio();
if (!audio || !audio.paused) return;
// Post-restore autoplay is programmatic: the open-rewind already
// positioned the element — the play event must not rewind again.
this.suppressUnpauseRewind = true;
audio.play().catch(() => {
/* autoplay blocked — stay silent */
});
+94
View File
@@ -8,6 +8,7 @@ import {
opsForField,
pruneIncomplete,
ruleToFlatParam,
suggestTree,
type FilterGroupState,
type FilterRuleState,
} from "./filter_builder";
@@ -636,3 +637,96 @@ describe("init: embedded labels + form hydration", () => {
expect(fetchSpy).not.toHaveBeenCalled();
});
});
// ── group ALL/ANY header toggle (2026-11-01): the header was a dead
// label while the engine honored group modes — now it's the control.
// The dropdown audit rides with it: suggestTree was already
// mode-generic; these pins lock that guarantee to the new shapes.
describe("group ALL/ANY header toggle", () => {
beforeEach(() => {
ingestFilterFields(builderFields);
});
it("flips a NESTED group's mode; the row chips re-derive", () => {
const c = filterBuilderData();
c.filterTree = {
mode: "all",
children: [
{ field: "author", op: "contains", values: ["a"] },
{
mode: "all",
children: [
{ field: "author", op: "contains", values: ["b"] },
{ field: "author", op: "contains", values: ["c"] },
],
},
],
};
expect(c.groupMode([1])).toBe("all");
c.toggleGroupMode([1]);
expect(c.groupMode([1])).toBe("any");
expect((c.filterTree.children[1] as FilterGroupState).mode).toBe("any");
// the projection mirrors the mode: the second rule inside the
// any-group now joins with OR
const orRow = c.builderRows.find((r) => r.chip === "OR");
expect(orRow).toBeDefined();
});
it("flips the ROOT (path=[]) — the whole-query switch rides the wire", () => {
const c = filterBuilderData();
c.filterTree = {
mode: "all",
children: [{ field: "author", op: "contains", values: ["a"] }],
};
expect(c.groupMode([])).toBe("all");
c.toggleGroupMode([]);
expect(c.filterTree.mode).toBe("any");
expect(c.treeJSON()).toContain('"mode":"any"');
c.toggleGroupMode([]);
expect(c.filterTree.mode).toBe("all");
});
});
describe("suggestTree under explicit groups (the group-toggle audit)", () => {
beforeEach(() => {
ingestFilterFields(builderFields);
});
it("root ANY: group siblings drop entirely (no constraint rides)", () => {
const tree: FilterGroupState = {
mode: "any",
children: [
{
mode: "all",
children: [{ field: "author", op: "contains", values: ["carroll"] }],
},
{ field: "library", op: "in", values: ["lib-1"] },
],
};
// editing the library rule: the author GROUP is an OR sibling — it
// must never narrow the menu
expect(suggestTree(tree, [1], "library")).toBeNull();
});
it("ALL root: a sibling explicit ANY-group survives with its mode intact", () => {
const tree: FilterGroupState = {
mode: "all",
children: [
{ field: "author", op: "contains", values: ["a"] },
{
mode: "any",
children: [
{ field: "author", op: "contains", values: ["carroll"] },
{ field: "author", op: "contains", values: ["orwell"] },
],
},
],
};
const t = suggestTree(tree, [0], "author");
expect(t).not.toBeNull();
expect(t!.mode).toBe("all");
const nested = t!.children[0] as FilterGroupState;
expect(nested.mode).toBe("any");
expect(nested.children).toHaveLength(2);
});
});
+36
View File
@@ -543,6 +543,21 @@ export function filterBuilderData() {
get builderRows(): BuilderRow[] {
const rows: BuilderRow[] = [];
// The ROOT projects as its own header row (2026-11-01): its mode
// was invisible before — no header, no control, permanently "all".
// path=[] distinguishes it from explicit groups (whose labels read
// "Group"); nodeAt([]) IS the root, so the header's toggle reaches
// it like any other group.
rows.push({
key: "root",
path: [],
type: "groupOpen",
depth: 0,
chip: "AND",
chipClickable: false,
isFirst: true,
group: this.filterTree as FilterGroupState,
});
projectRows(this.filterTree as FilterGroupState, [], 0, rows);
return rows;
},
@@ -617,6 +632,27 @@ export function filterBuilderData() {
}
return node;
},
// groupMode — a group's current all/any (root included: path=[] IS
// the root via nodeAt). Anything malformed reads as "all".
groupMode(path: number[]): "all" | "any" {
const node = this.nodeAt(path);
if (!node || isRule(node)) return "all";
return node.mode === "any" ? "any" : "all";
},
// toggleGroupMode — the group header's flip: ALL ⇄ ANY for that
// group only. The engine has always honored group modes
// (buildGroupClause parenthesizes nested groups), so this is pure
// UI exposure; the row-level chips and implicit OR-blocks are
// untouched (they mix AND/OR inside a group — a different, orthogonal
// feature).
toggleGroupMode(path: number[]): void {
const node = this.nodeAt(path);
if (!node || isRule(node)) return;
node.mode = node.mode === "any" ? "all" : "any";
this._touch();
},
addRule(path: number[]): void {
const parent = this.nodeAt(path);
if (!parent || !("children" in parent)) return;
+28
View File
@@ -2018,6 +2018,34 @@
padding: 0.2rem 0.25rem;
outline: none;
}
/* group header = the ALL/ANY switch (2026-11-01): the header was a
dead label while the engine honored group modes — now it's the
control. ANY is accented: it's the mode that changes semantics. */
.fb-group-mode {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
padding: 0.15rem 0.5rem;
border-radius: 0.4rem;
border: 1px solid var(--border);
background-color: var(--bg-primary);
color: var(--text-secondary);
cursor: pointer;
white-space: nowrap;
}
.fb-group-mode:hover {
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
color: var(--text-primary);
}
.fb-group-mode.fb-group-any {
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
background-color: var(--accent-muted);
color: var(--accent);
}
.fb-menu {
position: absolute;
z-index: 60;
+1 -1
View File
@@ -1,2 +1,2 @@
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated