Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2165829d0e | ||
|
|
04e855e691 | ||
|
|
3e8ac3b9b7 | ||
|
|
10a5ac88da | ||
|
|
3feb608d26 | ||
|
|
9b2d3da7e2 | ||
|
|
788e9463cf | ||
|
|
ef4de7896a | ||
|
|
12aab156f6 | ||
|
|
c1a7648076 | ||
|
|
9e2f216af5 | ||
|
|
ceed3ddeae | ||
|
|
0673550d58 | ||
|
|
71b171bf72 | ||
|
|
2df6f8e55c | ||
|
|
88adf30aa9 | ||
|
|
e3ca3426ba | ||
|
|
42b283480a | ||
|
|
913174cdf8 | ||
|
|
bf4735f3ce | ||
|
|
e07301f74a | ||
|
|
fbdd4ab9f9 | ||
|
|
ef827a3a05 | ||
|
|
7b7ce39826 | ||
|
|
9cf9e1d305 | ||
|
|
daffe499eb | ||
|
|
0d613ba0e3 | ||
|
|
a4dc03984c | ||
|
|
1ed1602e70 | ||
|
|
2344f5686a |
No files matched your search
@@ -1,6 +1,14 @@
|
||||
# Screenshot Automation Plan for Bookhoard Documentation
|
||||
|
||||
> **Status**: Ready to implement when frontend is complete
|
||||
> **Status**: REJECTED / ARCHIVED (2026-11-02) — not implementing.
|
||||
> **Superseded by**: [docs/developer/writing-ui-docs.md](docs/developer/writing-ui-docs.md) —
|
||||
> Bookhoard docs are written screenshot-free from now on. Why: screenshots go
|
||||
> stale silently (no gate can diff pixels against the UI), die on every
|
||||
> redesign, and are unverifiable; the UI is server-rendered so exact labels are
|
||||
> greppable and gate-checked instead (`TestUIControlLabels`). The prose
|
||||
> standard teaches patterns that survive re-layouts. Kept below unmodified for
|
||||
> history.
|
||||
>
|
||||
> **Last Updated**: 2026-02-03
|
||||
|
||||
This document outlines the complete plan for automatically generating screenshots for Bookhoard documentation using Playwright.
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
@@ -4,7 +4,7 @@ Welcome to the Bookhoard contributing documentation. This section contains guide
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
- **[Development Guide](Development.md)** - Development workflow and architecture
|
||||
- **[Development Guide](development.md)** - Development workflow and architecture
|
||||
- Architecture overview
|
||||
- Directory structure
|
||||
- Local development setup
|
||||
@@ -14,7 +14,7 @@ Welcome to the Bookhoard contributing documentation. This section contains guide
|
||||
|
||||
## 📋 Project Guidelines
|
||||
|
||||
- **[AGENTS.md](../../AGENTS.md)** - Agent/development rules
|
||||
- **[AGENTS.md](../contributing/contributing.md)** - Development rules and standards live in the Contributing portal
|
||||
- Critical prohibitions
|
||||
- Mandatory requirements
|
||||
- Error recovery protocol
|
||||
@@ -22,7 +22,7 @@ Welcome to the Bookhoard contributing documentation. This section contains guide
|
||||
|
||||
## 🤝 How to Contribute
|
||||
|
||||
We welcome contributions! Please see our [Development Guide](Development.md) for information on:
|
||||
We welcome contributions! Please see our [Development Guide](development.md) for information on:
|
||||
|
||||
- Setting up your development environment
|
||||
- Understanding the codebase
|
||||
|
||||
@@ -510,9 +510,9 @@ DEBUG=true
|
||||
|
||||
## 📚 Additional Resources
|
||||
|
||||
- [AGENTS.md](../../AGENTS.md) - Agent/development rules
|
||||
- [API Reference](../API_REFERENCE.md) - Complete API documentation
|
||||
- [Troubleshooting](../TROUBLESHOOTING.md) - Deployment issues
|
||||
- [Development rules and standards](contributing.md) - Contributing portal
|
||||
- [API Reference](../developer/api/api-reference.md) - Split endpoint docs, indexed from its overview
|
||||
- [Troubleshooting](../operations/troubleshooting.md) - Deployment issues
|
||||
- [Go Documentation](https://go.dev/doc/)
|
||||
- [Echo Framework](https://echo.labstack.com/docs)
|
||||
- [pgx Documentation](https://pgx.github.io/pgx/)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
<!-- Gate-checked by TestUIControlLabels (internal/docs/ui_labels_test.go):
|
||||
every `controls` entry must exist as a literal in templates/*.templ
|
||||
(or web/src for source: ts). Renames fail make test. Entry text is the
|
||||
canonical spelling cited across the docs. -->
|
||||
|
||||
# UI controls glossary
|
||||
|
||||
The canonical spellings for controls cited across the UI documentation. Docs
|
||||
quote the label exactly as this page lists it — one entry per control; two
|
||||
spellings of one control is a defect. The `controls` block is machine-read:
|
||||
when a doc first names a control, add its entry in the same commit; when a
|
||||
rename lands, update the entry in the same commit and the gate goes green
|
||||
again.
|
||||
|
||||
Entry fields:
|
||||
|
||||
- `label` — the exact rendered text users see
|
||||
- `where` — semantic path (page → section/card), never spatial; see the
|
||||
[writing standard](writing-ui-docs.md)
|
||||
- `does` — what interacting with it produces (the outcome)
|
||||
- `source` — optional; default `templ` (`templates/*.templ`); `ts` searches `web/src`
|
||||
- `also` — optional, pipe-separated extra literal fragments to search when
|
||||
the visible label is Alpine-composed rather than static (OR-checked with `label`)
|
||||
- `note` — optional clarifier
|
||||
|
||||
Form-field labels inside a single modal (Name, Description…) are cited
|
||||
inline in docs and not tracked here; this list carries the controls people
|
||||
navigate by.
|
||||
|
||||
## Dashboard, profile & podcasts
|
||||
|
||||
```controls
|
||||
label: Customize Dashboard
|
||||
where: Library bar on the dashboard (button); opens the Customize Dashboard panel
|
||||
does: Opens per-library shelf arrangement — drag order, show/hide switches, items per section.
|
||||
|
||||
label: Save Changes
|
||||
where: Customize Dashboard panel → submit
|
||||
does: Saves that library's shelf arrangement.
|
||||
|
||||
label: Refresh
|
||||
where: Library bar on the dashboard (button)
|
||||
does: Re-reads libraries without a full page reload.
|
||||
note: icon button with a tooltip title
|
||||
|
||||
label: Create Custom Section
|
||||
where: the custom-section builder page (/custom-section) → page title
|
||||
does: Page where a dashboard shelf is defined by filter rules or hand-picked books.
|
||||
|
||||
label: Update Password
|
||||
where: profile page → Change Password → submit
|
||||
does: Saves the new password after live requirement checks.
|
||||
|
||||
label: Remove My Account
|
||||
where: profile page → Danger Zone
|
||||
does: Deletes the account and all personal data after confirmation; blocked for the last admin.
|
||||
|
||||
label: Add Feed
|
||||
where: Podcasts page
|
||||
does: Opens the Add Podcast Feed panel.
|
||||
|
||||
label: Add & Subscribe
|
||||
where: Add Podcast Feed panel → submit
|
||||
does: Subscribes the feed and starts watching it for new episodes.
|
||||
also: Add & Subscribe
|
||||
note: templ source stores the ampersand entity; the rendered label reads "Add & Subscribe"
|
||||
|
||||
label: Download all episodes now
|
||||
where: Add Podcast Feed panel
|
||||
does: Grabs the feed's back catalog immediately on subscribe.
|
||||
|
||||
label: Player settings
|
||||
where: audio player → settings control
|
||||
does: Opens the per-player preferences — Autoplay on open, Default playback speed, Resume rewind.
|
||||
also: title="Settings"
|
||||
|
||||
label: Shortcuts & interactions
|
||||
where: audio player → the ? control
|
||||
does: Lists every keyboard shortcut the player supports.
|
||||
also: Shortcuts & interactions
|
||||
note: templ source stores the ampersand entity
|
||||
|
||||
label: Chapters
|
||||
where: audio player → chapter-list control
|
||||
does: Opens the chapter list and shows the current chapter position.
|
||||
note: title attribute on the control
|
||||
|
||||
label: Bookmarks
|
||||
where: audio player → bookmarks control
|
||||
does: Opens your markers for this title; tap one to jump back.
|
||||
note: title attribute on the control
|
||||
```
|
||||
|
||||
## Collections
|
||||
|
||||
```controls
|
||||
label: New Collection
|
||||
where: Collections page
|
||||
does: Opens the create-collection modal.
|
||||
note: the empty page offers the same action as "Create Your First Collection"
|
||||
|
||||
label: Create Your First Collection
|
||||
where: Collections page, when no collections exist
|
||||
does: Opens the create-collection modal.
|
||||
|
||||
label: Create Collection
|
||||
where: create-collection modal → submit
|
||||
does: Saves the new collection and returns to the Collections page.
|
||||
|
||||
label: Update Collection
|
||||
where: edit-collection modal → submit
|
||||
does: Saves your changes to the collection.
|
||||
|
||||
label: Static
|
||||
where: create-collection modal → Type
|
||||
does: Chooses a hand-picked collection — you add and remove items yourself.
|
||||
|
||||
label: ⚡ Smart
|
||||
where: create-collection modal → Type; also shown as a badge on smart collections page and detail
|
||||
does: Chooses a rules-matched collection — its membership stays current by itself.
|
||||
|
||||
label: Rules scope
|
||||
where: create-collection modal, when Smart is chosen
|
||||
does: Asks what the rules ask about — the two buttons below it.
|
||||
also: 📚 Books|🎧 Podcast episodes
|
||||
note: "Books" and "Podcast episodes" as the two scope buttons
|
||||
|
||||
label: Restore System
|
||||
where: Collections page
|
||||
does: Opens a pick-a-shelf modal to bring back a system collection you deleted.
|
||||
|
||||
label: Restore
|
||||
where: Restore System Collection modal → submit
|
||||
does: Brings the selected system collection back.
|
||||
|
||||
label: Edit Rules
|
||||
where: collection detail page
|
||||
does: Opens the smart-collection rules editor for this collection.
|
||||
|
||||
label: Filter tree
|
||||
where: smart-collection rules editor
|
||||
does: The area where the group and rules are built.
|
||||
note: read/write rules — the how is the filter guide
|
||||
|
||||
label: Apply to Collection
|
||||
where: smart-collection rules editor → save
|
||||
does: Saves the filter tree and makes the collection match those rules from now on.
|
||||
|
||||
label: Add Items
|
||||
where: collection detail page
|
||||
does: Opens the item picker window.
|
||||
|
||||
label: Add Selected
|
||||
where: item picker → footer
|
||||
does: Adds everything checked in the picker to the collection.
|
||||
|
||||
label: Remove Selected
|
||||
where: collection detail page
|
||||
does: Opens a confirm dialog to remove the checked items from the collection.
|
||||
|
||||
label: Search within collection...
|
||||
where: collection detail page → search box
|
||||
does: Filters the displayed items — nothing is removed.
|
||||
|
||||
label: Play as Sequence
|
||||
where: collection detail page (when it has a downloaded episode)
|
||||
does: Plays the collection's episodes one after another in the listening order you pick.
|
||||
|
||||
label: Hide finished books (reading-queue mode)
|
||||
where: edit-collection modal
|
||||
does: Finished books disappear from the collection's shelf.
|
||||
|
||||
label: Show Up Next strip
|
||||
where: edit-collection modal
|
||||
does: Adds this collection's Up Next strip to the podcast page.
|
||||
```
|
||||
|
||||
## Everywhere else
|
||||
|
||||
```controls
|
||||
label: Filters
|
||||
where: All Books / Podcasts page → toolbar
|
||||
does: Opens the filter drawer for building and applying rules.
|
||||
|
||||
label: Register Device
|
||||
where: Devices page
|
||||
does: Submits a device registration request for the pending-approval flow.
|
||||
|
||||
label: Download All
|
||||
where: Podcast page → episodes area
|
||||
does: Queues every episode of the feed for download.
|
||||
|
||||
label: Download order
|
||||
where: Add Podcast Feed panel; podcast feed settings
|
||||
does: Sets the ordering used when feed episodes are downloaded.
|
||||
|
||||
label: Match ALL / Group ANY header
|
||||
where: filter drawer → group header (also smart collections → Rules and dashboard sections)
|
||||
does: Sets how the group combines its rules; clicking the header flips ALL/ANY.
|
||||
also: ? 'Match' : 'Group'|? 'ANY' : 'ALL'
|
||||
note: label is Alpine-composed — 'Match' for the whole builder, 'Group' for nested; docs write it as MATCH ALL / MATCH ANY
|
||||
|
||||
label: Reset to Scanned
|
||||
where: book detail → edit modal → scan information area
|
||||
does: Restores scanner-extracted metadata over manual edits.
|
||||
|
||||
label: Auto-Scan
|
||||
where: Settings page → Scanning section
|
||||
does: Enables automatic periodic library scanning on the interval below it.
|
||||
|
||||
label: Scan Interval (seconds)
|
||||
where: Settings page → Scanning section
|
||||
does: Sets the seconds between automatic scans.
|
||||
|
||||
label: Resume rewind
|
||||
where: audio player → player settings; profile settings
|
||||
does: Rewinds playback a configured few seconds when you resume.
|
||||
also: Resume Rewind
|
||||
note: profile page spells it capitalized
|
||||
|
||||
label: Reading Direction
|
||||
where: book detail → edit modal
|
||||
does: Chooses the direction pages advance (publisher convention — e.g. manga).
|
||||
|
||||
label: Scan Info
|
||||
where: book detail page → info block
|
||||
does: Displays scanner-supplied notes attached to the item.
|
||||
also: Scan Information
|
||||
note: the editable form label in the modal reads "Scan Information"
|
||||
```
|
||||
@@ -0,0 +1,117 @@
|
||||
# Writing UI documentation without screenshots
|
||||
|
||||
Bookhoard's UI documentation uses no screenshots. This page is the standard
|
||||
for writing them. The flagship example of the style is the
|
||||
[filter system documentation](../user/filters/index.md): one system taught
|
||||
once, the places it appears listed as a *pattern*, samples for every field in
|
||||
a [generated reference](../user/filters/fields.md) that cannot drift from the
|
||||
UI.
|
||||
|
||||
## Why no screenshots
|
||||
|
||||
Screenshots fail in every way the rest of this repo's rot is already gated
|
||||
against:
|
||||
|
||||
- **They rot silently.** No gate can diff pixels against the running UI. A
|
||||
renamed button makes the screenshot wrong and nothing fails.
|
||||
- **They cannot be verified.** Shown text can: this app's UI is
|
||||
server-rendered from `templates/*.templ`, so every stable label and heading
|
||||
is a literal in the source — checkable, like routes and API examples are.
|
||||
- **They die with a redesign.** Text keyed to *function* survives a
|
||||
re-layout. Text keyed to a layout dies with its redesign.
|
||||
- **They exclude** (screen readers, text search) and bloat diffs.
|
||||
|
||||
The one thing screenshots do better — "which button is it?" — text does via
|
||||
**exact quoted labels** plus a semantic path, and the label gate then holds
|
||||
that text against the source.
|
||||
|
||||
## The layering — what is load-bearing
|
||||
|
||||
Write every UI page in this order. The top layers are why users don't get
|
||||
lost when the UI changes:
|
||||
|
||||
| # | Layer | Content | Lifespan |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | **The pattern** | What the thing *is*, what is invariant, every place the same pattern appears | Redesign-proof |
|
||||
| 2 | **Function inventory** | What the user *can do*, phrased without control names | Redesign-proof |
|
||||
| 3 | **How-to with examples** | Numbered flows: current label → the outcome it produces | Survives most changes |
|
||||
| 4 | **Current labels** | Exactly quoted, bold | Gate-checked today |
|
||||
| 5 | **Generated references** | Enumerables regenerated from source with a currency test | Cannot drift |
|
||||
|
||||
Labels are **tips, not the map**. Layer 3 may quote a label; layer 1 must
|
||||
make the page followable even if that label is renamed next week.
|
||||
|
||||
## The page template
|
||||
|
||||
1. **The pattern first.** The concept, its invariants, and everywhere it
|
||||
appears — "one system, three places". Never page cartography.
|
||||
2. **Function inventory.** Capabilities without control names: "you can pin
|
||||
any narrowing to a named filter you can reopen later".
|
||||
3. **How-to with examples.** Each step: the label of what you interact with,
|
||||
then the outcome ("the count at the bottom shows how many books will
|
||||
match"). An example beats an adjective.
|
||||
4. **Findability note.** One line telling the reader how to re-locate the
|
||||
feature *by function again* after any UI change — deliberately
|
||||
label-independent ("it's the drawer opened from any books or podcasts
|
||||
list — the only place whose header can read Match ALL").
|
||||
5. **Current labels.** Quoted exactly as rendered, one canonical spelling
|
||||
per control ([arbitrated by the glossary](ui-controls.md)).
|
||||
|
||||
## Writing rules
|
||||
|
||||
- **Semantic placement, never spatial.** No "top-right" or "at the bottom of
|
||||
the screen" talk. "Devices page → the **Register Device** button" survives
|
||||
any re-layout.
|
||||
- **One canonical label per control.** Two spellings of the same control is
|
||||
a defect, not a synonym.
|
||||
- **Status updates get a what-it-means table**, not a color description.
|
||||
- **Screenshots are never load-bearing.** Default is none.
|
||||
|
||||
## Tone by audience
|
||||
|
||||
- **User Guide (`docs/user/`) — plain language, deliberately.** Short
|
||||
sentences. Everyday words. No developer jargon; any term the reader
|
||||
genuinely needs (say, *rule*) is explained once, in place, then used
|
||||
consistently. The reader is assumed to know books, while the app's own
|
||||
words are the only technical vocabulary allowed.
|
||||
- **Developer and Administration sections — tone is a separate discussion,
|
||||
not yet decided.** Write them plainly and hand-off-rich as well; do not
|
||||
import opinions about their voice until that pass happens.
|
||||
|
||||
## Generate what has a registry
|
||||
|
||||
Anything enumerable gets generated from source with a currency test, not
|
||||
hand-typed. The precedent: every filter field is
|
||||
[generated](../user/filters/fields.md) by `TestFieldReferenceDocIsCurrent`
|
||||
from the same registry the menus render — the doc cannot disagree with the
|
||||
UI. Hand-typed lists drift fastest; when no registry exists yet, say so in
|
||||
the page and lean on the pattern layers.
|
||||
|
||||
## The gates
|
||||
|
||||
- `TestUIControlLabels` (`internal/docs/ui_labels_test.go`): every control in
|
||||
the [glossary](ui-controls.md) must exist as a literal in
|
||||
`templates/*.templ` (or `web/src` for TS-rendered surfaces, marked
|
||||
`source: ts`). Renames fail `make test` in the same commit as the rename.
|
||||
- `TestDocsRelativeLinks`: every relative link in the docs tree resolves to
|
||||
a real file.
|
||||
|
||||
Naming a new control in prose? Add its glossary entry in the same commit.
|
||||
Form labels inside one modal (Name, Description…) are cited inline and not
|
||||
glossary-tracked; the glossary carries the controls people navigate by —
|
||||
buttons, tabs, badges, page titles.
|
||||
|
||||
## The process rule
|
||||
|
||||
**A UI change is not finished until the docs audit runs.** Moved card,
|
||||
renamed button, restructured page — all of it triggers: labels still
|
||||
resolve, links still resolve, affected pages re-read against the new
|
||||
surface. The pattern layers keep users oriented between audits; the audit
|
||||
keeps layers 3–5 honest.
|
||||
|
||||
## What this does not replace
|
||||
|
||||
The filter documentation (and the older filtering overview) is hands-off
|
||||
exemplar territory — read it as the model, never restyle it here. New UI
|
||||
surfaces (scanning, devices, settings, podcasts, the reader, collections)
|
||||
are written this way from the start.
|
||||
@@ -623,6 +623,6 @@ This is a great example of how user feedback can improve implementation design!
|
||||
|
||||
## See Also
|
||||
|
||||
- [User Guide: Calibre Integration](../../user/calibre-integration.md)
|
||||
- [Implementation Plan](../../CALIBRE_OPF_IMPLEMENTATION.md)
|
||||
- [Scanner Source Code](../../internal/services/media_scanner.go)
|
||||
- [User Guide: Calibre Integration](../user/calibre-integration.md)
|
||||
- The original implementation plan file was removed from the repo when this page absorbed its content.
|
||||
- The scanner implementation lives in `internal/services/` (not hyperlinked — the docs site serves only docs).
|
||||
+44
-81
@@ -13,57 +13,60 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
||||
- **Device Setup**
|
||||
- [KOReader Setup Guide](user/devices/koreader-setup.md) - KOReader on Kindle/Kobo/PocketBook hardware
|
||||
- [Kobo Setup Guide](user/devices/kobo-setup.md) - Native Kobo sync (coming soon; use KOReader today)
|
||||
- [Android App](user/devices/android-app.md)
|
||||
|
||||
- **Sync Configuration**
|
||||
- [Universal Sync Guide](user/sync-guide.md) - Understanding sync, book matching, conflicts
|
||||
|
||||
- **Frontend & Settings** (Coming Soon)
|
||||
- Frontend Guide
|
||||
- User Areas Guide
|
||||
- Settings Guide
|
||||
- Admin Guide
|
||||
- **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
|
||||
|
||||
- **Guides**
|
||||
- [Reader Guide](user/reader-guide.md) - reading, highlights, notes
|
||||
- [Library Browsing](user/library-browsing.md) - browsing and searching your library
|
||||
- [Collections](user/collections.md) - manual and ⚡ smart shelves, books and episodes
|
||||
- [Podcasts](user/podcasts.md) - feeds, downloads, listening
|
||||
- [Dashboard](user/dashboard.md)
|
||||
- [Themes](user/themes.md)
|
||||
- [Calibre Integration](user/calibre-integration.md)
|
||||
- [Profile Guide](user/profile-guide.md)
|
||||
- [Admin Guide](user/admin-guide.md)
|
||||
|
||||
### 👨💻 For Developers
|
||||
|
||||
**[Developer Documentation Portal](developer/development.md)** - Technical documentation & API reference
|
||||
|
||||
- **API Documentation**
|
||||
- [Complete API Reference](developer/api-reference.md) - Monolithic REST API reference (1,300+ lines)
|
||||
- [Split Endpoint Docs](developer/api/api-reference.md) - Individual endpoint docs
|
||||
- [Collections API](developer/api/collections/list_collections.md) - Collections management API
|
||||
- [Admin API](developer/api/admin/) - User management (admin operations)
|
||||
- [Media Items API](developer/api/media-items/) - Media item operations including bulk operations and downloads
|
||||
- [Devices API](developer/api/devices/) - Device sync and shelf management
|
||||
- [Conflicts API](developer/api/conflicts/) - Sync conflict resolution endpoints
|
||||
- [Queue API](developer/api/queue/) - Sync queue management endpoints
|
||||
- [Scanner API](developer/api/scanner/) - Library scanning and automated watch mode (admin)
|
||||
- [KOReader API](developer/api/koreader/) - KOReader sync protocol endpoints
|
||||
- [Kobo API](developer/api/kobo/) - Kobo sync protocol endpoints (feature coming soon)
|
||||
- [WebSocket API](developer/api/websocket/) - Real-time sync events
|
||||
- **API Documentation** — every endpoint is documented individually; the links below are entry points
|
||||
- [API Reference overview](developer/api/api-reference.md) - index of all endpoint docs
|
||||
- [Collections API](developer/api/collections/list_collections.md) - collections management API
|
||||
- [Media Items API](developer/api/media-items/list_media_items.md) - media item operations including bulk operations and downloads
|
||||
- [Devices API](developer/api/devices/list_devices.md) - Device sync and shelf management
|
||||
- [Conflicts API](developer/api/conflicts/list_conflicts.md) - Sync conflict resolution endpoints
|
||||
- [Queue API](developer/api/queue/list_all_queue_items.md) - Sync queue management endpoints
|
||||
- [Scanner API](developer/api/scanner/overview.md) - Library scanning and automated watch mode (admin)
|
||||
- [KOReader API](developer/api/koreader/get_library.md) - KOReader sync protocol endpoints
|
||||
- [Kobo API](developer/api/kobo/sync_from_server.md) - Kobo sync protocol endpoints
|
||||
- [Admin API](developer/api/admin/list_users.md) - User management (admin operations); content-duplicate cleanup lives in [hash conflicts](developer/api/admin/hash_conflicts_api.md)
|
||||
|
||||
- **Protocol Specifications**
|
||||
- [KOReader Sync Protocol](developer/api/sync/koreader-protocol.md) - KOReader sync
|
||||
- [Kobo Sync Protocol](developer/api/sync/kobo-protocol.md) - Kobo device sync (coming soon)
|
||||
- [WebSocket API](developer/websocket-api.md) - Real-time events
|
||||
- [Kobo Sync Protocol](developer/api/sync/kobo-protocol.md) - Kobo device sync
|
||||
|
||||
- **Writing UI docs?** Read [Writing UI documentation without screenshots](developer/writing-ui-docs.md) first.
|
||||
|
||||
### 🔧 For Operations
|
||||
|
||||
**[Operations Documentation Portal](operations/operations.md)** - Deployment & maintenance
|
||||
|
||||
- **Deployment**
|
||||
- [Troubleshooting Guide](operations/troubleshooting.md) - Common deployment issues and solutions
|
||||
|
||||
- **Maintenance** (Coming Soon)
|
||||
- Deployment Guide
|
||||
- Maintenance Guide
|
||||
- Monitoring Guide
|
||||
- [Troubleshooting Guide](operations/troubleshooting.md) - Common deployment issues and solutions
|
||||
|
||||
### 🤝 For Contributors
|
||||
|
||||
**[Contributing Portal](contributing/contributing.md)** - Development workflow
|
||||
|
||||
- [Development Guide](contributing/development.md) - Architecture, setup, testing
|
||||
- [../../AGENTS.md](../../AGENTS.md) - Development rules and standards
|
||||
- [Development Guide](contributing/development.md) - Architecture, setup, testing, and the project rules
|
||||
|
||||
---
|
||||
|
||||
@@ -71,11 +74,11 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
||||
|
||||
| Want to... | Go to |
|
||||
| -------------------- | ---------------------------------------------------------------------- |
|
||||
| **Get started** | [README.md](../README.md) - Project overview and quick start |
|
||||
| **Get started** | [Development Guide](contributing/development.md) - Architecture and quick start |
|
||||
| **Set up a device** | [User Portal → Device Setup](user/user-guide.md) |
|
||||
| **Use the API** | [Developer Portal → API Docs](developer/development.md) |
|
||||
| **Deploy Bookhoard** | [Operations Portal → Troubleshooting](operations/troubleshooting.md) |
|
||||
| **Contribute code** | [Contributing Portal → Development Guide](contributing/development.md) |
|
||||
| **Use the API** | [API Reference overview](developer/api/api-reference.md) |
|
||||
| **Deploy Bookhoard** | [Operations → Troubleshooting](operations/troubleshooting.md) |
|
||||
| **Contribute code** | [Development Guide](contributing/development.md) |
|
||||
| **Understand sync** | [User Portal → Sync Guide](user/sync-guide.md) |
|
||||
| **Read in the browser** | [User Portal → Web Reader Guide](user/reader-guide.md) |
|
||||
|
||||
@@ -87,65 +90,25 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
||||
|
||||
| Question | Answer |
|
||||
| --------------------------- | ------------------------------------------------------ |
|
||||
| ...install Bookhoard? | [README.md](../README.md) - Quick Start |
|
||||
| ...set up KOReader? | [KOReader Setup Guide](user/devices/koreader-setup.md) |
|
||||
| ...use a Kobo? | [Kobo Setup Guide](user/devices/kobo-setup.md) - native sync coming soon; KOReader works today |
|
||||
| ...understand sync? | [Sync Guide](user/sync-guide.md) |
|
||||
| ...read in the browser? | [Web Reader Guide](user/reader-guide.md) - reading, highlights, notes |
|
||||
| ...resolve conflicts? | [Sync Guide](user/sync-guide.md) - Managing Conflicts |
|
||||
| ...troubleshoot deployment? | [Troubleshooting Guide](operations/troubleshooting.md) |
|
||||
| ...use the API? | [API Reference](developer/api-reference.md) |
|
||||
| ...use the API? | [API Reference overview](developer/api/api-reference.md) |
|
||||
| ...write UI docs? | [Writing UI documentation](developer/writing-ui-docs.md) |
|
||||
| ...contribute code? | [Development Guide](contributing/development.md) |
|
||||
|
||||
### "Where is..."
|
||||
|
||||
| Information | Location |
|
||||
| ---------------------- | ----------------------------------------------------------- |
|
||||
| ...features list? | [README.md](../README.md) |
|
||||
| ...database schema? | [database/schema/schema.sql](../database/schema/schema.sql) |
|
||||
| ...API endpoints? | [API Reference](developer/api-reference.md) |
|
||||
| ...secrets config? | [.env.example](../.env.example) |
|
||||
| ...operational config? | [docker-compose.yml](../docker-compose.yml) |
|
||||
| ...deployment issues? | [Troubleshooting Guide](operations/troubleshooting.md) |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Common Workflows
|
||||
|
||||
### Set up a new device
|
||||
|
||||
1. Choose your device: [KOReader](user/devices/koreader-setup.md) (works on Kindle, Kobo, and PocketBook hardware)
|
||||
2. Understand sync: [Sync Guide](user/sync-guide.md)
|
||||
3. Troubleshoot: Device-specific guides
|
||||
|
||||
### Integrate with Bookhoard API
|
||||
|
||||
1. Start with [API Reference](developer/api-reference.md)
|
||||
2. Explore [Collections API](developer/api/collections/list_collections.md)
|
||||
3. Review [WebSocket API](developer/websocket-api.md)
|
||||
4. Test with [bruno/](../bruno/) collections
|
||||
|
||||
### Deploy to production
|
||||
|
||||
1. Follow [README.md](../README.md) quick start
|
||||
2. Configure environment: [.env.example](../.env.example)
|
||||
3. Review [Troubleshooting Guide](operations/troubleshooting.md)
|
||||
4. Check [Development Guide](contributing/development.md) for performance tuning
|
||||
|
||||
---
|
||||
|
||||
## 📝 Contributing to Documentation
|
||||
|
||||
When adding new features:
|
||||
|
||||
1. **User-facing features** → Update relevant User docs
|
||||
2. **API endpoints** → Update [API Reference](developer/api-reference.md) & split docs
|
||||
3. **Backend changes** → Update [Development Guide](contributing/development.md)
|
||||
4. **Deployment changes** → Update [Operations Portal](operations/operations.md)
|
||||
|
||||
Keep [../../AGENTS.md](../../AGENTS.md) in mind for documentation standards.
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: August 2026
|
||||
**Bookhoard Version**: 1.0
|
||||
1. **User-facing features** → Update the relevant User docs
|
||||
2. **API endpoints** → Update the endpoint docs under `developer/api/` (start from the [API Reference overview](developer/api/api-reference.md))
|
||||
3. **Backend changes** → Update the [Development Guide](contributing/development.md)
|
||||
4. **Deployment changes** → Update the [Operations Portal](operations/operations.md)
|
||||
5. **New UI controls in docs** → Add them to the [UI controls glossary](developer/ui-controls.md)
|
||||
6. **UI appearances in docs** → Follow [Writing UI documentation without screenshots](developer/writing-ui-docs.md)
|
||||
@@ -15,19 +15,19 @@ Welcome to the Bookhoard operations documentation. This section contains guides
|
||||
|
||||
### Additional Deployment Guides
|
||||
|
||||
**[Deployment Guide](deployment.md)** - Comprehensive deployment guide
|
||||
**Deployment Guide** - Comprehensive deployment guide
|
||||
|
||||
- _Coming Soon_
|
||||
|
||||
## 🛠️ Maintenance
|
||||
|
||||
**[Maintenance Guide](maintenance.md)** - Ongoing operations and maintenance
|
||||
**Maintenance Guide** - Ongoing operations and maintenance
|
||||
|
||||
- _Coming Soon_
|
||||
|
||||
## 📊 Monitoring
|
||||
|
||||
**[Monitoring Guide](monitoring.md)** - Monitoring and alerting
|
||||
**Monitoring Guide** - Monitoring and alerting
|
||||
|
||||
- _Coming Soon_
|
||||
|
||||
|
||||
@@ -13,4 +13,4 @@ This guide will cover:
|
||||
|
||||
---
|
||||
|
||||
_In the meantime, check out our [Operations Portal](../operations/)_
|
||||
_In the meantime, check out our [Operations Portal](../operations/operations.md)_
|
||||
@@ -139,7 +139,7 @@ As long as a `metadata.opf` file exists in the folder, Bookhoard will import the
|
||||
|
||||
**Steps**:
|
||||
1. Add the Calibre library folder in Bookhoard (Administration → Libraries → expand the library → **Folders**)
|
||||
2. Trigger a scan via the **Scanner API**, or let watch mode pick up the changed files (the File Watcher status is shown on the admin dashboard)
|
||||
2. Trigger a scan from the admin interface's scan controls, or let watch mode pick up the changed files automatically
|
||||
3. Bookhoard imports all 500 books with:
|
||||
- Correct titles and authors
|
||||
- Series information (e.g., "Harry Potter #2")
|
||||
@@ -168,7 +168,7 @@ As long as a `metadata.opf` file exists in the folder, Bookhoard will import the
|
||||
**Steps**:
|
||||
1. Edit metadata in Calibre (it updates `metadata.opf`)
|
||||
2. In Bookhoard, trigger a rescan:
|
||||
- Via the **Scanner API** (`POST /api/scanner/scan`), or
|
||||
- Via the admin interface's scan controls, or
|
||||
- Let watch mode detect the changed files automatically (see File Watcher on the admin dashboard)
|
||||
3. Bookhoard detects updated `metadata.opf` and refreshes metadata
|
||||
|
||||
@@ -183,7 +183,7 @@ As long as a `metadata.opf` file exists in the folder, Bookhoard will import the
|
||||
**Solutions**:
|
||||
1. **Check file structure**: Ensure `metadata.opf` is in the same folder as the book file
|
||||
2. **Verify library type**: Ensure library type matches content (ebook vs. audiobook)
|
||||
3. **Force rescan**: Trigger a scan via the Scanner API to re-import all metadata (watch mode also picks up changed files automatically)
|
||||
3. **Force rescan**: Trigger a rescan from the admin interface to re-import all metadata (watch mode also picks up changed files automatically)
|
||||
4. **Check logs**: Review Bookhoard logs for parsing errors
|
||||
|
||||
### Incorrect Metadata
|
||||
@@ -332,11 +332,10 @@ file is never touched if anything fails (temp file + atomic rename).
|
||||
- [Calibre Website](https://calibre-ebook.com/)
|
||||
- [Calibre User Manual](https://manual.calibre-ebook.com/)
|
||||
- [Calibre Forum](https://www.mobileread.com/forums/forumdisplay.php?f=166)
|
||||
- [Bookhoard GitHub](https://github.com/yourusername/bookhoard)
|
||||
|
||||
## Need Help?
|
||||
|
||||
- **Documentation**: See [User Guide](user-guide.md) for general Bookhoard usage
|
||||
- **Troubleshooting**: See [Operations Guide](../operations/operations.md) for common issues
|
||||
- **Developer Docs**: See [Developer Portal](../developer/development.md) for technical details
|
||||
- **Issues**: Report bugs or request features on [GitHub Issues](https://github.com/yourusername/bookhoard/issues)
|
||||
- **Issues**: Report bugs or request features on your repository's issue tracker
|
||||
+101
-25
@@ -1,42 +1,118 @@
|
||||
# Collections
|
||||
|
||||
Collections allow you to organize books across multiple libraries.
|
||||
A collection is a personal shelf you name yourself. You put your books and
|
||||
podcast episodes on it, and it appears as a section on your dashboard.
|
||||
|
||||
## Viewing Collections
|
||||
That is the whole idea. Everything below is variations on it.
|
||||
|
||||
### From Collections Page
|
||||
## The one pattern, four flavors
|
||||
|
||||
Navigate to **Collections** (sidebar navigation) to see all your collections. Clicking on a collection shows ALL books in that collection across all libraries.
|
||||
Every collection behaves the same way on the outside: it has a name, an icon,
|
||||
a color, and a list of items. The flavors differ in how items *join*:
|
||||
|
||||
### From Dashboard
|
||||
| Flavor | How items join | Right for |
|
||||
| --- | --- | --- |
|
||||
| **Static** | You pick each item by hand | Curated lists — "books for the trip", "new house ideas" |
|
||||
| **⚡ Smart** (books) | Rules match books live, forever | "everything by Asimov", "still not started", "over 400 pages" |
|
||||
| **⚡ Smart** (podcast episodes) | Rules match episodes live — and they can play back-to-back | "catch up on my feeds", "episodes to finish" |
|
||||
| **System** (built-in) | Bookhoard maintains them | Continue Reading, Recently Added, Recently Read, Not Started, Continue Series |
|
||||
|
||||
When viewing a specific library's dashboard, collections only show books from that library. Clicking "View All" preserves this context and shows only books from that library in the collection.
|
||||
A collection holds **books and podcast episodes** — mix them if you like.
|
||||
|
||||
### Library Filtering
|
||||
The **⚡ Smart** badge means the same thing everywhere it appears: *membership
|
||||
is matched by rules right now — always current, nothing to refresh*.
|
||||
|
||||
- **No filter** (`/collections/favorites`): Shows all books across all libraries
|
||||
- **With filter** (`/collections/favorites?library_id=xxx`): Shows only books from the specified library
|
||||
## Creating a collection
|
||||
|
||||
## Creating Collections
|
||||
1. Open the **Collections** page (sidebar navigation)
|
||||
2. Click **New Collection**
|
||||
3. Choose the **Type**:
|
||||
- **Static** — pick items by hand
|
||||
- **⚡ Smart** — match by rules, always live
|
||||
4. If you picked **⚡ Smart**, choose the **Rules scope**: **📚 Books** or
|
||||
**🎧 Podcast episodes**
|
||||
5. Fill in the rest: **Name**, **Description**, an **Icon** you can search or
|
||||
pick, and a **Color**
|
||||
6. Click **Create Collection**
|
||||
|
||||
1. Go to **Collections** (sidebar navigation)
|
||||
2. Click **New Collection** (top-right) — or **Create Your First Collection** if the list is empty
|
||||
3. Fill in the details:
|
||||
- **Name** - Collection name
|
||||
- **Description** - Optional description
|
||||
- **Icon** - Pick from the icon grid
|
||||
- **Color** - Pick a color swatch
|
||||
4. Click **Create Collection**
|
||||
Starting from a completely empty account? The button **Create Your First
|
||||
Collection** does the same thing.
|
||||
|
||||
## Managing Collections
|
||||
## Adding items by hand (Static collections)
|
||||
|
||||
Each collection in the list has icon buttons on its card:
|
||||
1. Open the collection from the **Collections** page
|
||||
2. Click **Add Items**
|
||||
3. Pick the tab for what you want:
|
||||
- **📚 Books** — searchable by the **Search books...** box, and the
|
||||
**Author** and **Genre** boxes
|
||||
- **🎧 Episodes** — the episodes of the feeds you subscribe to
|
||||
4. Click one or more items — a checkmark marks what you picked; the bottom
|
||||
of the window counts them for you
|
||||
5. Click **Add Selected**
|
||||
|
||||
- **Edit** - Opens the edit form to change name, description, icon, or color. Click **Update Collection** to save.
|
||||
- **Delete** - Removes the collection after a confirmation prompt.
|
||||
To take items out, check them on the collection page and click
|
||||
**Remove Selected** (it asks first).
|
||||
|
||||
Deleted a system collection by mistake? The **Restore System** button on the Collections page brings back system collections.
|
||||
While checking what is in the collection, the **Search within collection...**
|
||||
box narrows the view — it never removes anything.
|
||||
|
||||
### Dashboard Sections
|
||||
## Rules (how ⚡ Smart collections work)
|
||||
|
||||
Collections appear as sections on your dashboard. Show, hide, and reorder them from the dashboard's **Customize Dashboard** settings (see [Dashboard](dashboard.md)).
|
||||
A smart collection keeps its own list of rules, the same rules that power the
|
||||
filter drawer. On the collection's page, click **Edit Rules** to open the
|
||||
rules editor: build your groups and rules in the **Filter tree**, then click
|
||||
**Apply to Collection**. From that point the collection matches those rules
|
||||
by itself — add a book that matches and it appears without you doing
|
||||
anything.
|
||||
|
||||
Rules live in one guide, so this page does not repeat them. The full how-to
|
||||
walks through groups, ALL/ANY, and value picking in
|
||||
[Filters — the complete guide](filters/index.md), ready-made examples are in
|
||||
the [cookbook](filters/cookbook.md), and
|
||||
[every filter field](filters/fields.md) lists what you can ask about.
|
||||
|
||||
*Finding it again later:* open any collection's page and look for the
|
||||
**Edit Rules** button; the **⚡ Smart** badge in the page title marks a
|
||||
collection that is already matching.
|
||||
|
||||
## Playing an episode collection
|
||||
|
||||
When a collection holds a downloaded episode, it grows a **Play as Sequence**
|
||||
button: the episodes play one after another. Next to it, the order picker
|
||||
decides the sequence — **Feed order**, **Feed order (reversed)**,
|
||||
**Oldest first (pub date)**, or **Newest first (pub date)**. Your choice is
|
||||
remembered on the collection.
|
||||
|
||||
## Changing a collection
|
||||
|
||||
The pencil button on a collection card, or the edit control on the
|
||||
collection page, opens the edit form:
|
||||
|
||||
- Change the **Name**, **Description**, **Icon**, **Color**
|
||||
- **Hide finished books (reading-queue mode)** — finished books drop off
|
||||
this shelf, which keeps a to-read list short
|
||||
- **Show Up Next strip** — show the collection's Up Next strip where it is
|
||||
used
|
||||
|
||||
Click **Update Collection** to save. Deleting a collection asks first, and
|
||||
deleting only removes the shelf — never the books or episodes inside it.
|
||||
|
||||
## System collections
|
||||
|
||||
The five built-in shelves — **Continue Reading**, **Recently Added**,
|
||||
**Recently Read**, **Not Started**, **Continue Series** — work like any
|
||||
collection on the outside, but Bookhoard decides their membership by what
|
||||
happens while you read. They appear on your dashboard automatically.
|
||||
|
||||
Deleted one and want it back? Click **Restore System** on the **Collections**
|
||||
page, pick the shelf from the list, and click **Restore**.
|
||||
|
||||
## Your dashboard shelves
|
||||
|
||||
Collections appear as sections on your dashboard, and you choose which ones
|
||||
show and in what order from the dashboard's own settings. That page's
|
||||
details live in [Dashboard](dashboard.md).
|
||||
|
||||
*Finding your way back:* **Collections** is the sidebar page whose top has
|
||||
the only **New Collection** button — from a dashboard shelf's **View All**,
|
||||
the collection detail page is the place with **Add Items**.
|
||||
+45
-35
@@ -1,52 +1,62 @@
|
||||
# Dashboard
|
||||
|
||||
The Bookhoard dashboard provides a Carousel-style horizontal carousel interface for browsing your book library.
|
||||
The dashboard is your home screen: horizontal shelves ("sections") of your
|
||||
books, one row after another. What each shelf contains depends on the library
|
||||
you are looking at — including the **All Libraries** view, which mixes
|
||||
everything you can see.
|
||||
|
||||
## Sections
|
||||
## The three kinds of section
|
||||
|
||||
### Smart Sections
|
||||
| Kind | What fills it |
|
||||
| --- | --- |
|
||||
| **System shelves** | Bookhoard maintains them from your reading: **Continue Reading**, **Recently Added**, **Recently Read**, **Not Started**, **Continue Series** |
|
||||
| **Your collections** | Every collection you create appears as its own shelf |
|
||||
| **Custom sections** | Shelves you define yourself — by filter rules or by hand-picking books |
|
||||
|
||||
Smart sections are automatically generated based on your reading activity:
|
||||
System shelves and collections are covered by their own guides (see
|
||||
[Sync Guide](sync-guide.md) for what "continue reading" means across devices,
|
||||
and [Collections](collections.md) for the shelves you build). This page is
|
||||
about arranging the rows.
|
||||
|
||||
- **Continue Reading**: Books you're currently reading (progress between 0-100%)
|
||||
- **In Progress**: Books you've started (progress > 0%)
|
||||
- **Recently Added**: Newest items added to this library
|
||||
- **Recently Read**: Books you've completed (100% progress)
|
||||
- **Not Started**: Books you haven't read yet
|
||||
### This Week
|
||||
|
||||
### User Collections
|
||||
The dashboard also shows a small **This Week** summary — your reading time
|
||||
and activity for the week so far. Click it to expand the detail.
|
||||
|
||||
Your collections appear as sections on the dashboard. To show or hide a collection's section:
|
||||
## Customizing your shelves
|
||||
|
||||
1. Select the library in the **Library** bar
|
||||
2. Click the **Customize Dashboard** icon button
|
||||
3. Toggle the collection on or off
|
||||
4. Click "Save Changes"
|
||||
1. Pick the library in the **Library** bar (a specific library, or
|
||||
**All Libraries**) — settings are saved per library
|
||||
2. Click the **Customize Dashboard** button in the Library bar
|
||||
3. In the **Customize Dashboard** panel: drag sections into the order you
|
||||
want, switch sections on or off, and set how many items each section
|
||||
shows (10–50)
|
||||
4. Click **Save Changes**
|
||||
|
||||
### Customizing Your Dashboard
|
||||
The **Refresh** button next to it re-reads your libraries without a full
|
||||
page reload.
|
||||
|
||||
1. In the **Library** bar below the top bar, select the library you want to customize (a specific library, not "All Libraries")
|
||||
2. Click the **Customize Dashboard** icon button at the right end of the Library bar (next to Refresh)
|
||||
3. **Drag sections** to reorder them
|
||||
4. **Toggle visibility** with the switches
|
||||
5. **Adjust items per section** (10-50 items)
|
||||
6. Click "Save Changes"
|
||||
## Custom sections
|
||||
|
||||
Settings are saved per library.
|
||||
A custom section is a shelf whose contents you define — either with the same
|
||||
filter rules the **Filters** drawer uses, or by picking the books yourself.
|
||||
You build one on the **Create Custom Section** page (at `/custom-section` on
|
||||
your server); once saved it behaves like any other shelf and can be ordered
|
||||
and hidden with everything else.
|
||||
|
||||
### Library Switching
|
||||
## Reading and listening from the dashboard
|
||||
|
||||
Use the **Library** dropdown in the bar below the top bar to switch between libraries (including "All Libraries"). Each library has its own dashboard settings.
|
||||
Point at a shelf and chevron buttons appear to scroll it. Every item carries
|
||||
a direct action: **Read** opens the web reader, **Listen** opens the audio
|
||||
player. If the same book has conflicting progress from two devices, the item
|
||||
grows a resolve action that takes you to conflict resolution — see the
|
||||
[Sync Guide](sync-guide.md).
|
||||
|
||||
### Keyboard Navigation
|
||||
## Keyboard and touch
|
||||
|
||||
- **Tab**: Navigate between sections and books
|
||||
- **Arrow Keys**: Scroll carousels horizontally
|
||||
- **Enter**: Open selected book
|
||||
- **Tab** moves between shelves and books; **Enter** opens the focused book
|
||||
- **Arrow keys** scroll the focused shelf sideways
|
||||
- On touch: swipe a shelf to scroll it, tap a cover to open it
|
||||
|
||||
Hovering a carousel shows chevron buttons on either side for scrolling.
|
||||
|
||||
### Touch Gestures (Mobile)
|
||||
|
||||
- **Swipe**: Drag carousel left/right to scroll
|
||||
- **Tap**: Open book details
|
||||
*Finding your way back:* the dashboard is the page with the **Library** bar —
|
||||
the only place with the **Customize Dashboard** button.
|
||||
@@ -1,40 +1,47 @@
|
||||
# Kobo Device Setup Guide
|
||||
|
||||
> ## 🚧 Coming Soon
|
||||
>
|
||||
> Native Kobo sync is not available yet. It is actively being developed and this guide will be filled in as the feature lands.
|
||||
There are two ways to use a Kobo with Bookhoard, and they can be chosen
|
||||
independently per device:
|
||||
|
||||
## Using a Kobo With Bookhoard Today
|
||||
| Way | Status | What you get |
|
||||
| --- | --- | --- |
|
||||
| **KOReader on the Kobo** | Works today, fully | Reading position, bookmarks, highlights, notes, OPDS wireless book delivery |
|
||||
| **Stock Kobo firmware (native sync)** | Server support is complete; device-side enablement is the remaining piece | Reading position and stats, bookmarks/highlights/notes, shelf mappings (your collections as Kobo shelves), automatic EPUB → KEPUB conversion, and the Kobo's own sync experience |
|
||||
|
||||
You don't have to wait: **KOReader runs on Kobo hardware** and syncs fully with Bookhoard today — reading position, bookmarks, highlights, and notes, plus OPDS wireless book delivery.
|
||||
## Using a Kobo today
|
||||
|
||||
See the **[KOReader Setup Guide](koreader-setup.md)** for complete instructions.
|
||||
**KOReader runs on Kobo hardware** and syncs fully with Bookhoard right
|
||||
now — the [KOReader Setup Guide](koreader-setup.md) walks the whole path
|
||||
(plugin install, server approval, OPDS delivery, troubleshooting).
|
||||
|
||||
## What's Planned for Native Kobo Sync
|
||||
## About native Kobo sync
|
||||
|
||||
When released, native Kobo sync will let stock Kobo firmware talk directly to Bookhoard:
|
||||
The server side of the native protocol is implemented and documented: the
|
||||
Kobo sync and initialization endpoints, bookmark and markup (highlight and
|
||||
note) sync, shelf mappings that expose your collections as Kobo shelves,
|
||||
and the KEPUB sidecar configuration used for conversion. What remains is
|
||||
the device-side rollout, so stock-firmware sync is not yet something to
|
||||
rely on for daily use — this guide will gain a full setup walkthrough when
|
||||
that lands.
|
||||
|
||||
- **Reading position sync** — percentages, pages, and reading statistics
|
||||
- **Bookmarks, highlights, and notes** — synced with the web and other devices
|
||||
- **OPDS wireless delivery** — browse and download books directly on the Kobo
|
||||
- **Automatic EPUB → KEPUB conversion** — for better Kobo rendering
|
||||
- **Shelf mappings** — Bookhoard collections appearing as Kobo shelves
|
||||
|
||||
The server-side protocol endpoints are already implemented and under test; the feature will be announced when it's ready for real devices.
|
||||
Nothing about native sync takes anything away from KOReader: the two can
|
||||
coexist, and a KOReader setup keeps working either way.
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: Should I buy a Kobo to use with Bookhoard today?**
|
||||
A: Kobo devices work great with Bookhoard via KOReader. Native (stock firmware) sync is coming soon.
|
||||
A: Yes, via KOReader — it is the supported path today and covers sync plus
|
||||
wireless book delivery.
|
||||
|
||||
**Q: What happens to my KOReader setup when native sync arrives?**
|
||||
A: Nothing — KOReader will keep working. Native sync simply adds another option for people who prefer stock Kobo firmware.
|
||||
**Q: Will I have to redo anything when native sync lands?**
|
||||
A: No — KOReader keeps working; native sync just adds the stock-firmware
|
||||
option.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [KOReader Setup Guide](koreader-setup.md) — works on Kobo today
|
||||
- [Bookhoard Universal Sync Guide](../sync-guide.md)
|
||||
- [Bookhoard API Reference](../../developer/api-reference.md)
|
||||
- Kobo sync protocol (for developers): [Kobo API docs](../../developer/api/kobo/sync_from_server.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
<!-- Ground truth: bookhoard.koplugin main.lua (menu tree, defaults, approval
|
||||
flow) + the server-side koreader endpoints. Re-verify against the plugin
|
||||
source when the plugin changes. -->
|
||||
|
||||
# KOReader Device Setup Guide
|
||||
|
||||
This guide will help you set up KOReader on your e-reader device to sync with Bookhoard.
|
||||
This guide will help you set up KOReader on your e-reader device to sync with
|
||||
Bookhoard — reading position, bookmarks, highlights, notes, and even your
|
||||
ratings, reviews, and reading time.
|
||||
|
||||
## What is KOReader?
|
||||
|
||||
KOReader is an open-source e-reader application that supports a wide range of e-reader devices including:
|
||||
KOReader is an open-source e-reader application that supports a wide range of
|
||||
e-reader devices including:
|
||||
|
||||
- Kindle devices (Paperwhite, Oasis, Voyage, etc.)
|
||||
- Kobo devices (Clara, Aura, Nia, etc.)
|
||||
- PocketBook devices
|
||||
|
||||
It also runs on Android tablets and phones, although Bookhoard's dedicated mobile apps (coming later) will be the better option there.
|
||||
It also runs on Android tablets and phones — though on Android the dedicated
|
||||
[Bookhoard app](android-app.md) is the better option.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -67,17 +75,27 @@ Before you begin, make sure you have:
|
||||
|
||||
## Connecting KOReader to Bookhoard
|
||||
|
||||
Setup is done **on the server**: you approve the device from the Bookhoard web interface — no usernames, passwords, or tokens to type on the device.
|
||||
Setup is done **on the server**: you approve the device from the Bookhoard
|
||||
web interface — no usernames, passwords, or tokens to type on the device.
|
||||
|
||||
### Step 1: Install the Bookhoard Plugin
|
||||
|
||||
1. Clone the [Bookhoard KOReader plugin](https://git.linuxhg.com/Bookhoard/bookhoard.koplugin)
|
||||
2. Copy it to your KOReader `plugins/` directory
|
||||
3. Restart KOReader
|
||||
Installation is a git clone straight into your KOReader `plugins/` folder —
|
||||
no download-and-copy step:
|
||||
|
||||
```bash
|
||||
cd koreader/plugins # .adds/koreader/plugins on Kobo
|
||||
git clone https://git.linuxhg.com/Bookhoard/bookhoard.koplugin
|
||||
```
|
||||
|
||||
Then restart KOReader.
|
||||
|
||||
**Updating later** is the same, smaller: in the `bookhoard.koplugin` folder,
|
||||
`git pull`, then restart KOReader.
|
||||
|
||||
### Step 2: Point the Plugin at Your Server
|
||||
|
||||
1. Open KOReader, tap the **wrench icon** at the top
|
||||
1. Open KOReader, tap the **wrench icon** at the top (the tools menu)
|
||||
2. Find and tap **Bookhoard sync**
|
||||
3. Tap **Server URL**, enter your server address, then tap **OK**:
|
||||
|
||||
@@ -87,34 +105,116 @@ Setup is done **on the server**: you approve the device from the Bookhoard web i
|
||||
|
||||
Use your server's LAN IP (or domain if you have one configured).
|
||||
|
||||
### Step 3: Approve the Device in Bookhoard
|
||||
### Step 3: Register the Device
|
||||
|
||||
1. On your computer or phone, open Bookhoard and go to the **Devices** page (sidebar navigation)
|
||||
2. Refresh the page — your device appears under **Pending Device Registrations**
|
||||
3. Click **Approve** to connect the device
|
||||
1. In the same **Bookhoard sync** menu, tap **Register device**
|
||||
2. On your computer or phone, open Bookhoard and go to the **Devices** page
|
||||
(sidebar navigation)
|
||||
3. Your device appears under **Pending Device Registrations** — click
|
||||
**Approve** to connect it
|
||||
|
||||
Once approved, the plugin picks up its credentials automatically — reading progress sync and OPDS catalog access are set up automatically. No further configuration is needed.
|
||||
The plugin waits on the device and finishes by itself the moment you
|
||||
approve. Once approved, its credentials are stored automatically, sync
|
||||
events are armed, and Bookhoard's OPDS catalog is added to your KOReader —
|
||||
no further configuration is needed.
|
||||
|
||||
> **Note:** Pending registrations expire after 5 minutes. If yours expires, just re-run the sync from the plugin menu and approve again.
|
||||
> **Note:** Pending registrations expire after 5 minutes. If yours expires
|
||||
> ("Registration expired"), just tap **Register device** again and approve.
|
||||
|
||||
### Auth Token (Advanced)
|
||||
|
||||
The Devices page shows each KOReader device's **Auth Token**. You normally never need it (the plugin receives it automatically during approval), but it can be re-entered manually in the plugin settings if you're moving a setup between devices or debugging.
|
||||
The Devices page shows each KOReader device's **Auth Token**. You normally
|
||||
never need it (the plugin receives it automatically during approval), but it
|
||||
can be re-entered manually in the plugin settings if you're moving a setup
|
||||
between devices or debugging.
|
||||
|
||||
## The Bookhoard sync menu
|
||||
|
||||
Everything the plugin can do lives in one place: **wrench icon → Bookhoard
|
||||
sync**. The menu changes shape depending on whether you are connected.
|
||||
|
||||
### Before you're connected
|
||||
|
||||
- **Server URL** — your server's address
|
||||
- **Register device** — starts the approval flow above
|
||||
|
||||
### Once connected — one-tap actions
|
||||
|
||||
| Menu item | What it does |
|
||||
| --- | --- |
|
||||
| **Push progress from this device** | Force-push this device's position to the server |
|
||||
| **Pull progress from server** | Force-pull the server's position onto this device |
|
||||
| **Sync rating, review & time now** | Push your rating, review, and accumulated reading time for the current book immediately |
|
||||
| **Setup OPDS catalog** | (Re-)adds Bookhoard's catalog to KOReader's OPDS list — normally done for you at approval |
|
||||
| **Device info** | Shows this device's ID and the server it talks to |
|
||||
| **Disconnect** | Forgets the device's credentials (after a confirmation) |
|
||||
|
||||
### Automatically push progress
|
||||
|
||||
This toggle is the on/off switch for background sync — **it starts off**, so
|
||||
after approval, turn it on to get automatic syncing. With it on, progress
|
||||
pushes as you read and on close/suspend; the plugin may prompt about turning
|
||||
Wi-Fi on at those moments depending on your device.
|
||||
|
||||
Two related settings sit under it:
|
||||
|
||||
- **Periodically sync every # pages** — how many page turns between updates
|
||||
(default 50; set 0 to disable periodic updates)
|
||||
- **Sync mode** — **Immediate** pushes right away, **Checkpoint** batches
|
||||
changes for slower connections or battery saving
|
||||
|
||||
### Sync behavior — who wins when states differ
|
||||
|
||||
These two settings decide what the device does when its progress and the
|
||||
server's disagree, without you opening the Conflicts page:
|
||||
|
||||
- **Sync to a newer state** — the server has *newer* progress than this
|
||||
device (default **Prompt**: the device asks before adopting it; alternatives
|
||||
are **Silently** or **Never**)
|
||||
- **Sync to an older state** — this device has *newer* progress than the
|
||||
server (default **Never**: the device never rolls itself back)
|
||||
|
||||
Set both to **Silently** for hands-off syncing, or leave the defaults if you
|
||||
want a say whenever progress would move.
|
||||
|
||||
### What to sync
|
||||
|
||||
Seven toggles, all on by default, each controlling one kind of data:
|
||||
|
||||
- **Reading progress**
|
||||
- **Bookmarks**
|
||||
- **Highlights**
|
||||
- **Notes**
|
||||
- **Ratings** — your book ratings made in KOReader
|
||||
- **Book review** — your review text
|
||||
- **Reading time** — time-spent statistics
|
||||
|
||||
Turn off anything you'd rather keep local; the rest keeps flowing both ways.
|
||||
|
||||
## What Syncs
|
||||
|
||||
Once connected, the following sync automatically in both directions between KOReader and Bookhoard (web and other devices):
|
||||
Once connected and enabled, the following sync in both directions between
|
||||
KOReader and Bookhoard (web, Android, and other devices), each governed by
|
||||
its toggle above:
|
||||
|
||||
- **Reading position** — percentage, chapter, and EPUB CFI where available
|
||||
- **Bookmarks**
|
||||
- **Highlights** — including highlight colors, mapped between the web and KOReader palettes
|
||||
- **Highlights** — including highlight colors, mapped between the web and
|
||||
KOReader palettes
|
||||
- **Notes** — standalone and attached to highlights
|
||||
- **Ratings, review, and reading time** — pushed to the server (push any
|
||||
time from the menu with **Sync rating, review & time now**)
|
||||
|
||||
Books are matched automatically using UUIDs, file hashes (SHA-256, format-aware so converted files still match), file aliases, and title/author fallback. If a book can't be matched, it shows up under the device's **Unlinked Books** in Bookhoard, where you can link it manually.
|
||||
Books are matched automatically using UUIDs, file hashes (SHA-256,
|
||||
format-aware so converted files still match), file aliases, and
|
||||
title/author fallback. If a book can't be matched, it shows up under the
|
||||
device's **Unlinked Books** in Bookhoard, where you can link it manually.
|
||||
|
||||
## OPDS Wireless Book Delivery
|
||||
|
||||
Once your device is approved, the plugin also registers Bookhoard's OPDS catalog, so you can browse and download books wirelessly — no USB cable needed.
|
||||
At approval the plugin adds Bookhoard's OPDS catalog to KOReader (and
|
||||
**Setup OPDS catalog** re-adds it any time), so you can browse and download
|
||||
books wirelessly — no USB cable needed.
|
||||
|
||||
### Browse and Download Books
|
||||
|
||||
@@ -129,7 +229,8 @@ Once your device is approved, the plugin also registers Bookhoard's OPDS catalog
|
||||
- **PDF**: Fixed-layout documents
|
||||
- **CBZ**: Comic book archives
|
||||
|
||||
Books downloaded via OPDS are automatically matched to your library, so their progress syncs from the first page.
|
||||
Books downloaded via OPDS are automatically matched to your library, so
|
||||
their progress syncs from the first page.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -142,7 +243,7 @@ Books downloaded via OPDS are automatically matched to your library, so their pr
|
||||
1. Verify the Server URL is correct (no trailing path — just the base address)
|
||||
2. Make sure KOReader is connected to Wi-Fi
|
||||
3. Check the Bookhoard server is reachable from the device's network
|
||||
4. Registrations expire after 5 minutes — re-run the sync and approve quickly
|
||||
4. Registrations expire after 5 minutes — tap **Register device** again and approve quickly
|
||||
|
||||
### Connection Refused
|
||||
|
||||
@@ -161,11 +262,18 @@ Books downloaded via OPDS are automatically matched to your library, so their pr
|
||||
|
||||
**Solutions**:
|
||||
|
||||
- Trigger a manual sync from the plugin menu
|
||||
- **Automatically push progress** defaults to off — turn it on in the menu, or push once with **Push progress from this device**
|
||||
- Check the device shows as enabled on the **Devices** page (open its settings from the icon next to the device)
|
||||
- Verify the book appears as an unlinked book for the device and link it if needed
|
||||
- Check Bookhoard server logs for errors
|
||||
|
||||
### Turning on auto-sync asks about Wi-Fi
|
||||
|
||||
If enabling **Automatically push progress** shows a message about
|
||||
**"Action when Wi-Fi is off"**, open KOReader's Network settings and set
|
||||
that action to **turn on** — auto-sync needs permission to bring Wi-Fi up by
|
||||
itself.
|
||||
|
||||
### Conflicts Detected
|
||||
|
||||
**Problem**: Sync conflicts when reading the same book on multiple devices
|
||||
@@ -174,7 +282,10 @@ Books downloaded via OPDS are automatically matched to your library, so their pr
|
||||
|
||||
1. Open the book's detail page and click **Sync Progress**, or open the Conflicts page (`/conflicts`)
|
||||
2. Review the progress reported by each device
|
||||
3. Choose which device's progress to keep
|
||||
3. Choose a resolution: keep one side's progress, keep the server's, or let the furthest progress win
|
||||
|
||||
For day-to-day control of the same question on the device itself, use the
|
||||
**Sync behavior** settings (newer state / older state).
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
@@ -189,8 +300,3 @@ Books downloaded via OPDS are automatically matched to your library, so their pr
|
||||
- [KOReader Forum](https://www.mobileread.com/forums/forumdisplay.php?f=271)
|
||||
- [Bookhoard Universal Sync Guide](../sync-guide.md)
|
||||
- [Bookhoard KOReader Plugin](https://git.linuxhg.com/Bookhoard/bookhoard.koplugin)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: August 2026
|
||||
**Bookhoard Version**: 1.0
|
||||
@@ -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).
|
||||
@@ -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.*
|
||||
@@ -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.
|
||||
@@ -1,16 +0,0 @@
|
||||
# Frontend Guide
|
||||
|
||||
**Coming Soon**
|
||||
|
||||
This guide will cover:
|
||||
|
||||
- Navigating the Bookhoard web interface
|
||||
- Browsing and searching your library
|
||||
- Managing collections
|
||||
- Viewing reading progress
|
||||
- Using notes and highlights
|
||||
- Personalizing your experience
|
||||
|
||||
---
|
||||
|
||||
_In the meantime, check out our [Device Setup Guides](devices/)_
|
||||
@@ -1,58 +1,43 @@
|
||||
# Saving Custom Filters
|
||||
# Browsing your library
|
||||
|
||||
The bookshelf (**All Books**) page lets you save custom filter presets for quick access.
|
||||
Everything starts from the **All Books** page: every book you can see in the
|
||||
current library, as a wall of covers. From there you narrow, sort, and open.
|
||||
|
||||
## Bookshelf Toolbar
|
||||
## The All Books toolbar
|
||||
|
||||
The All Books page has a toolbar with:
|
||||
- The **search box** finds titles, authors, and more as you type
|
||||
- The **Sort** dropdown orders the wall: **Title (A-Z)**, **Title (Z-A)**,
|
||||
**Author (A-Z)**, **Date Added**, or **Page Count**
|
||||
- The **Filters** button opens the filter drawer — authors, tags, series,
|
||||
progress, and every other field (the full how-to lives in
|
||||
[Filters — the complete guide](filters/index.md); this page sticks to
|
||||
browsing)
|
||||
- **Save**, **Load**, and **Clear** manage your saved filters: capture the
|
||||
current drawer state under a name, re-apply it any time, and delete ones
|
||||
you no longer want. Saved filters are private to your account.
|
||||
|
||||
- A **search input** for quick text searches
|
||||
- A **sort** dropdown (title, author, date added, page count)
|
||||
- A **Filters** button that opens the filter drawer (author, tags, series, and more)
|
||||
- **Save**, **Load**, and **Clear** buttons for filter presets
|
||||
Saved filters pair naturally with [smart collections](collections.md): a
|
||||
filter answers "what matches right now?", a smart collection keeps matching
|
||||
forever.
|
||||
|
||||
## How to Save a Filter
|
||||
## Opening a book
|
||||
|
||||
1. Navigate to the **All Books** page
|
||||
2. Click **Filters** to open the drawer, set your desired filters, and click **Apply Filters**
|
||||
3. Click the **Save** button in the toolbar
|
||||
4. Enter a name for your filter (e.g., "My Sci-Fi Books")
|
||||
5. Click **Save**
|
||||
Click any cover to open the book's detail page — description, files, your
|
||||
progress, actions to read or listen, and the places to edit metadata or fix
|
||||
what a scan guessed wrong. The detail page is the hub: series links,
|
||||
reviews, bookmarks, and the edit modal all live there.
|
||||
|
||||
## Loading Saved Filters
|
||||
## Series
|
||||
|
||||
1. Click the **Load** button to open the **Saved Filters** dropdown
|
||||
2. Click a filter's name to apply it
|
||||
3. The filter values are applied instantly, without a page reload
|
||||
A book's detail page links to its **series page** — every book in that
|
||||
series, in order, for the same library. Series are one of the fields the
|
||||
filter drawer can match on too.
|
||||
|
||||
## Managing Saved Filters
|
||||
## Podcasts are separate
|
||||
|
||||
**Delete a filter:**
|
||||
Podcast feeds and episodes live on the **Podcasts** page, not the book wall —
|
||||
see [Podcasts](podcasts.md).
|
||||
|
||||
1. Click the **Load** button to open the **Saved Filters** dropdown
|
||||
2. Click the trash icon next to the filter you want to remove
|
||||
3. Confirm deletion
|
||||
|
||||
**Filter Privacy:**
|
||||
|
||||
Saved filters are **private to your account**. Other users cannot see or modify your filters.
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
**Reading by Genre:**
|
||||
|
||||
1. Filter by tag: "Science Fiction"
|
||||
2. Save as "Sci-Fi Books"
|
||||
3. Quickly access all your sci-fi collection anytime
|
||||
|
||||
**Author Collections:**
|
||||
|
||||
1. Filter by author: "Isaac Asimov"
|
||||
2. Save as "Asimov Books"
|
||||
3. Switch between different author collections instantly
|
||||
|
||||
**Series Tracking:**
|
||||
|
||||
1. Filter by series: "Foundation"
|
||||
2. Save as "Foundation Series"
|
||||
3. Track your progress through a series
|
||||
*Finding your way back:* **All Books** is the wall-of-covers page with the
|
||||
**Filters** button; the same toolbar pattern is what you'll see on the
|
||||
Podcasts page.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Podcasts
|
||||
|
||||
Bookhoard is not just books: it also runs your podcast subscriptions. You
|
||||
subscribe to feeds, episodes download for offline listening, and you can
|
||||
play a run of episodes back-to-back — a feed, or a hand-picked sequence.
|
||||
|
||||
## The Podcasts page
|
||||
|
||||
Open **Podcasts** from the sidebar. The page is your subscriptions:
|
||||
|
||||
- **Continue** — the episode you were in the middle of, ready to resume
|
||||
- **All Feeds** — every feed you subscribe to
|
||||
- **Search** narrows your feeds and episodes
|
||||
- Each feed's page holds its episodes, with **Download All** for grabbing
|
||||
everything at once
|
||||
|
||||
Subscriptions live in your **Podcasts library** (feeds manage it themselves —
|
||||
you won't see it in the regular library switcher, and it never mixes into
|
||||
the book wall on **All Books**).
|
||||
|
||||
## Subscribing to a feed
|
||||
|
||||
1. Click **Add Feed** on the Podcasts page
|
||||
2. In the **Add Podcast Feed** panel, paste the show's **RSS feed URL**
|
||||
3. Choose the **Library** the podcast belongs to
|
||||
4. Optionally: check **Download all episodes now** to grab the back catalog
|
||||
immediately, and pick the **Download order** for new episodes — **Feed
|
||||
order**, **Feed order (reversed)**, **Oldest first (pub date)**, or
|
||||
**Newest first (pub date)**
|
||||
5. Click **Add & Subscribe**
|
||||
|
||||
From then on Bookhoard watches the feed: new episodes appear as the show
|
||||
publishes them, downloaded according to the order you picked.
|
||||
|
||||
## Playing episodes
|
||||
|
||||
Click an episode to open the audio player. The player keeps your place,
|
||||
supports bookmarks, a chapter list where the show provides chapters, and
|
||||
playback speed — the same speed and resume-rewind defaults you set on your
|
||||
[profile](profile-guide.md) apply here.
|
||||
|
||||
To play many episodes in a row, use a collection: episode collections have a
|
||||
**Play as Sequence** button and their own listening order — see
|
||||
[Collections](collections.md).
|
||||
|
||||
*Finding your way back:* **Podcasts** is the sidebar page whose header reads
|
||||
"Your subscriptions" — the only page with the **Add Feed** button.
|
||||
+37
-87
@@ -1,99 +1,49 @@
|
||||
# Profile Management Guide
|
||||
# Your profile
|
||||
|
||||
## Updating Your Profile
|
||||
Your profile page holds everything that is about *you* rather than your
|
||||
books: account details, playback preferences, your password, and — if you
|
||||
ever need it — account deletion.
|
||||
|
||||
Your profile contains your account information and preferences.
|
||||
Open it from the sidebar: click your **username** at the bottom, then
|
||||
**Profile**.
|
||||
|
||||
### How to Update
|
||||
## Account information
|
||||
|
||||
1. Click on your **username** at the bottom of the sidebar to expand the account menu
|
||||
2. Select **Profile**
|
||||
3. Edit any fields in the "Account Information" section
|
||||
4. Click **Save Changes**
|
||||
5. Changes take effect immediately
|
||||
| Field | What it does |
|
||||
| --- | --- |
|
||||
| **Username** | Your login name — must stay unique |
|
||||
| **Email Address** | Must stay unique |
|
||||
| **First Name / Last Name** | Optional display names |
|
||||
| **Theme** | A quick theme picker (seven favorites — see below for the full set) |
|
||||
| **Default Playback Speed** | The speed audiobook playback starts at |
|
||||
| **Resume Rewind** | Jumps back a few seconds when you resume playback (**Off (exact resume)**, 5, or 10 seconds) |
|
||||
| **Keep Screen Awake** | Stops the screen sleeping during playback |
|
||||
| **Timezone** | All times the server shows you (activity, stats, history) are rendered in this zone |
|
||||
|
||||
### Fields You Can Update
|
||||
Changes save from the form's save button and apply immediately.
|
||||
|
||||
- **Username** - Your login name (must be unique)
|
||||
- **Email** - Your email address (must be unique)
|
||||
- **First Name** - Optional display name
|
||||
- **Last Name** - Optional display name
|
||||
- **Theme** - Your preferred color scheme
|
||||
About the two theme doors: the profile's **Theme** select offers seven
|
||||
quick picks. The sidebar's **Appearance** panel offers the full collection —
|
||||
[Themes and Wood Paneling](themes.md) covers it, including the bookshelf
|
||||
wood textures, which are per-browser and not part of your account.
|
||||
|
||||
## Changing Your Password
|
||||
## Changing your password
|
||||
|
||||
Regular password changes are recommended for account security.
|
||||
On the same page, the **Change Password** section asks for your **Current
|
||||
Password**, a **New Password**, and **Confirm New Password**. The
|
||||
requirements are checked as you type; click **Update Password** to save.
|
||||
|
||||
### How to Change
|
||||
## Deleting your account
|
||||
|
||||
1. Go to **Profile** page
|
||||
2. Scroll to "Change Password" section
|
||||
3. Enter your **current password**
|
||||
4. Enter your **new password**
|
||||
5. **Confirm** your new password
|
||||
6. Click **Update Password**
|
||||
The **Danger Zone** at the bottom holds **Remove My Account**. Deletion
|
||||
removes your account and everything personal to it — profile, reading
|
||||
progress and history, device registrations, your collections — and keeps the
|
||||
libraries themselves, since books belong to the library, not to you. It
|
||||
cannot be undone.
|
||||
|
||||
### Password Requirements
|
||||
The last administrator cannot delete their own account — one admin must
|
||||
always remain.
|
||||
|
||||
- Minimum 8 characters
|
||||
- Must contain uppercase, lowercase, number, and special character
|
||||
- Must match confirmation field
|
||||
- Current password must be correct
|
||||
|
||||
## Deleting Your Account
|
||||
|
||||
**⚠️ WARNING:** This action cannot be undone.
|
||||
|
||||
### What Gets Deleted
|
||||
|
||||
When you delete your account:
|
||||
|
||||
- Your profile information
|
||||
- Reading progress and history
|
||||
- Device connections
|
||||
- Collection preferences
|
||||
- All personal data
|
||||
|
||||
### What Stays
|
||||
|
||||
- Library books (media belongs to the library, not you)
|
||||
- System settings
|
||||
- Other users' accounts
|
||||
|
||||
### How to Delete
|
||||
|
||||
1. Go to **Profile** page
|
||||
2. Scroll to "Danger Zone" (bottom of page)
|
||||
3. Click **Remove My Account**
|
||||
4. Confirm the deletion prompt
|
||||
|
||||
**Note:** If you're the last admin, you cannot delete your account for security reasons.
|
||||
|
||||
## Theme Options
|
||||
|
||||
Personalize your reading experience with different color themes.
|
||||
|
||||
### Quick Theme Switch
|
||||
|
||||
1. In the sidebar, open the **Appearance** panel (palette icon, near the bottom)
|
||||
2. Select a theme from the list; your active theme is marked with a checkmark
|
||||
3. Changes apply instantly
|
||||
|
||||
For the full theme list and bookshelf background (wood) options, see [Themes and Wood Paneling](themes.md).
|
||||
|
||||
### Available Themes
|
||||
|
||||
- **Tokyo Night** (default) - Blue/purple accents
|
||||
- **Dracula** - Purple/pink tones
|
||||
- **Nord** - Arctic, bluish-gray
|
||||
- **Solarized Dark** - Warm, precise contrast
|
||||
- **Monokai** - Classic vibrant colors
|
||||
- **One Dark Pro** - Atom editor inspired
|
||||
- **Material Dark** - Google Material Design
|
||||
- **Catppuccin Mocha / Macchiato / Frappé / Latte** - Soothing pastel palettes (Latte is light)
|
||||
|
||||
## For Admin Users
|
||||
|
||||
If you're an administrator, you can also manage other users' profiles from the admin panel.
|
||||
|
||||
See [Admin User Management](admin-guide.md) for details.
|
||||
Administrators can also edit other users' profiles (including resetting a
|
||||
password without the current one) from the admin area; that is covered with
|
||||
the rest of the admin docs.
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
Bookhoard's built-in web reader works in any modern browser — desktop or
|
||||
mobile — and keeps your position, highlights, bookmarks, and notes in sync
|
||||
with the Android app and KOReader.
|
||||
with the Android app and KOReader. Audiobooks get their own player, covered
|
||||
at the end of this guide.
|
||||
|
||||
## Opening a Book
|
||||
|
||||
@@ -79,3 +80,25 @@ to jump to it.
|
||||
Position, highlights, bookmarks, and notes sync automatically through the
|
||||
server (see the [Sync Guide](sync-guide.md)). Close the tab whenever you
|
||||
like — the reader resumes exactly where you left off on any device.
|
||||
|
||||
## Listening to audiobooks and podcast episodes
|
||||
|
||||
Books with audio files, and podcast episodes, open in the audio player
|
||||
instead of the page reader. It is one player for both:
|
||||
|
||||
- The **seek bar** is your position; drag it, or use the **previous /
|
||||
next episode** controls when you are playing a sequence
|
||||
- **Bookmarks** opens a panel of your markers for this title — tap one to
|
||||
jump back
|
||||
- **Chapters** opens the chapter list (shows "currently chapter N of M");
|
||||
audiobooks with embedded chapters and podcast shows with chapter marks
|
||||
both land here
|
||||
- **Player settings** holds the per-player preferences: **Autoplay on
|
||||
open**, **Default playback speed**, and **Resume rewind** (a few seconds
|
||||
back when you resume — the defaults come from your
|
||||
[profile](profile-guide.md) and this panel overrides them per player)
|
||||
- The **Shortcuts & interactions** panel (the **?** control) lists every
|
||||
keyboard shortcut
|
||||
|
||||
Progress, bookmarks, and your speed carry across devices through the same
|
||||
sync as everything else.
|
||||
@@ -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,15 +0,0 @@
|
||||
# Settings Guide
|
||||
|
||||
**Coming Soon**
|
||||
|
||||
This guide will cover:
|
||||
|
||||
- Account settings
|
||||
- Display preferences (themes, fonts)
|
||||
- Sync configuration
|
||||
- Device management
|
||||
- Privacy options
|
||||
|
||||
---
|
||||
|
||||
_In the meantime, check out our [Device Setup Guides](devices/)_
|
||||
+99
-555
@@ -1,591 +1,135 @@
|
||||
# Bookhoard Universal Sync - User Guide
|
||||
# Universal Sync — how your devices stay in step
|
||||
|
||||
## Table of Contents
|
||||
Sync is the promise that makes Bookhoard more than a web page: read on
|
||||
whatever is in your hands, and every other device catches up. One server
|
||||
holds the truth for each book — your position, bookmarks, highlights, and
|
||||
notes — and every registered device pushes and pulls against it.
|
||||
|
||||
1. [What is Universal Sync?](#what-is-universal-sync)
|
||||
2. [Supported Devices](#supported-devices)
|
||||
3. [Getting Started](#getting-started)
|
||||
4. [Device Setup](#device-setup)
|
||||
5. [Using Sync Features](#using-sync-features)
|
||||
6. [Book Matching](#book-matching)
|
||||
7. [Managing Conflicts](#managing-conflicts)
|
||||
8. [Best Practices](#best-practices)
|
||||
## Supported devices today
|
||||
|
||||
---
|
||||
| Platform | Status | How it syncs |
|
||||
| --- | --- | --- |
|
||||
| **Web browser** | ✅ Fully supported | Live, as you read — the reader saves position and annotations continuously |
|
||||
| **Android app** | ✅ Fully supported | The native client: progress, highlights, bookmarks, notes; reads offline and queues changes until you are back online |
|
||||
| **KOReader** (Kindle, Kobo, PocketBook) | ✅ Fully supported | The Bookhoard KOReader plugin, over Wi-Fi |
|
||||
| **Kobo, stock firmware** | 🚧 Server support complete | The native Kobo protocol is implemented and documented server-side; device-side enablement is the remaining piece — [Kobo Setup](devices/kobo-setup.md) has the details |
|
||||
|
||||
## What is Universal Sync?
|
||||
Kindle and reMarkable are under consideration; KOReader already covers
|
||||
several of those devices today.
|
||||
|
||||
**Universal Sync** is Bookhoard's cross-platform synchronization system that keeps your reading progress, highlights, and notes in sync across all your devices - automatically and in real-time.
|
||||
## Getting a device into the circle
|
||||
|
||||
### Key Features
|
||||
1. Connect the device to your server (each guide above walks its own path)
|
||||
2. Approve the pending registration on the **Devices** page — the Android
|
||||
app registers itself on first login; KOReader asks from its plugin
|
||||
3. Done. Sync starts as soon as a device is approved
|
||||
|
||||
🔄 **Automatic Sync** - Your reading progress syncs automatically when you turn pages
|
||||
The **Devices** page is mission control: see every registered device and
|
||||
when it was last seen, remove a device (revoking its access instantly), and
|
||||
work through **unlinked books** — device files that could not be matched to
|
||||
a library book yet.
|
||||
|
||||
📱 **Multi-Platform** - Works with web browsers and KOReader, with native Kobo sync and mobile apps on the roadmap
|
||||
## Book matching
|
||||
|
||||
📍 **Precise Location Tracking** - Supports EPUB CFI, page numbers, percentages, and character offsets
|
||||
When a device reports a book, Bookhoard tries to match it to your library
|
||||
by fingerprint, strongest first:
|
||||
|
||||
⚡ **Real-Time Updates** - See your progress update across all devices instantly
|
||||
1. **Bookhoard UUID** — 100% confidence
|
||||
2. **OPF UUID** — 95%
|
||||
3. **SHA-256 hash** — 90%
|
||||
4. **OPF identifier** — 85%
|
||||
5. **ISBN / ASIN** — 80%
|
||||
6. **Title + Author + file size** — 50%
|
||||
7. **Title only** — 30% (last resort, only tried when nothing else matched)
|
||||
|
||||
🔒 **Secure** - No passwords on devices, web-based authentication only
|
||||
Books that match automatically never bother you. The ones that can't
|
||||
(side-loaded files, converted formats like EPUB → KEPUB, mismatched
|
||||
metadata) land in the device's **unlinked books** list, where you can:
|
||||
|
||||
📴 **Offline Support** - Queue changes when offline, sync when reconnected
|
||||
- **Auto-link** everything above a confidence threshold
|
||||
- Review **suggestions** first, then link
|
||||
- **Link by hand** — pick the right library book yourself
|
||||
|
||||
---
|
||||
## What syncs
|
||||
|
||||
## Supported Devices
|
||||
**Reading progress** — page, percentage, chapter position, EPUB CFI, last
|
||||
read time. EPUBs sync by percentage and CFI, PDFs and comics by page, so
|
||||
the same book lines up on devices with different page counts.
|
||||
|
||||
### Currently Supported ✅
|
||||
**Highlights and notes** — the highlighted text, your note, its color, and
|
||||
where it sits. Locations are stored in several forms at once (page, EPUB
|
||||
CFI, percentage, character offset), which is why a highlight made on your
|
||||
phone lands in the right place on an e-reader with different pagination.
|
||||
|
||||
| Platform | Status | Sync Method | Notes |
|
||||
| ---------------- | ------------------ | --------------------------- | ---------------------------------- |
|
||||
| **Web Browser** | ✅ Fully Supported | Real-time WebSocket | Any modern browser |
|
||||
| **KOReader** | ✅ Fully Supported | Wi-Fi (Bookhoard plugin) | Kindle, Kobo, PocketBook hardware |
|
||||
**Bookmarks** — location, title, and when you made them.
|
||||
|
||||
### Coming Soon 🚧
|
||||
Everything above is per-user and private: your devices see your data
|
||||
because you approved them, other users see theirs, and revoking a device
|
||||
ends its access on the spot.
|
||||
|
||||
| Platform | Status |
|
||||
| --------------------- | ------------------------------------------------------------- |
|
||||
| **Kobo Devices** | Native sync coming soon — use KOReader on Kobo hardware today |
|
||||
| **Mobile Apps** | Android/iOS apps coming later |
|
||||
## Conflicts
|
||||
|
||||
### On the Roadmap 🔭
|
||||
If the same book is read on two devices close together, Bookhoard does not
|
||||
silently pick a winner — it records a **sync conflict** and shows it to
|
||||
you:
|
||||
|
||||
| Platform | Status |
|
||||
| --------------------- | ---------------------------------------- |
|
||||
| **Kindle Devices** | Under consideration (no date yet) |
|
||||
| **Remarkable Tablet** | Under consideration (no date yet) |
|
||||
- The book's detail page grows a **Sync Progress** button (with a
|
||||
"Progress conflict detected" notice)
|
||||
- The **Conflicts** page (`/conflicts`) lists everything unresolved
|
||||
|
||||
---
|
||||
Resolving means choosing a strategy: keep one side's progress, keep the
|
||||
server's current value, or let the **furthest progress win**. Until you
|
||||
resolve, the conflict stays listed — nothing is lost, it just waits for
|
||||
your call.
|
||||
|
||||
## Getting Started
|
||||
## Offline reading
|
||||
|
||||
### Prerequisites
|
||||
Devices keep working without the server. The Android app reads fully
|
||||
offline and queues your changes, draining the queue when you reconnect.
|
||||
E-readers push their progress on the next connection. The server holds a
|
||||
per-device queue for inbound work and retries failures automatically; if
|
||||
items pile up (a device offline for weeks), an admin can retry or clear a
|
||||
device's queue from the server side.
|
||||
|
||||
1. **Bookhoard Server** - Self-hosted instance running and accessible
|
||||
2. **Network Connection** - Devices must be able to reach your Bookhoard server
|
||||
3. **User Account** - Created and logged in to Bookhoard web interface
|
||||
## Reading history
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. **Set up your first device** - See [Device Setup](#device-setup) below
|
||||
2. **Start reading** - Open any book in the web interface or on your device
|
||||
3. **Turn pages** - Progress syncs automatically
|
||||
4. **Switch devices** - Pick up any other device - your progress is there!
|
||||
|
||||
---
|
||||
|
||||
## Device Setup
|
||||
|
||||
For detailed device configuration instructions, see the appropriate setup guide:
|
||||
|
||||
- **[KOReader Setup Guide](devices/koreader-setup.md)** - KOReader configuration (Kindle, Kobo, and PocketBook hardware)
|
||||
- **[Kobo Setup Guide](devices/kobo-setup.md)** - Native Kobo sync (coming soon; use KOReader today)
|
||||
|
||||
### Quick Overview
|
||||
|
||||
**Registration Process** (KOReader):
|
||||
|
||||
1. Install the Bookhoard plugin and enter your server URL in KOReader
|
||||
2. Approve the pending registration on the Bookhoard **Devices** page (sidebar navigation)
|
||||
3. That's it — sync starts automatically once approved
|
||||
|
||||
**Device Management**:
|
||||
|
||||
```
|
||||
Devices page (sidebar navigation)
|
||||
```
|
||||
|
||||
You can:
|
||||
|
||||
- View all your registered devices
|
||||
- See last sync time and status
|
||||
- Disable or remove devices
|
||||
- View unlinked books that need matching
|
||||
- Manage file aliases
|
||||
|
||||
---
|
||||
|
||||
## Book Matching
|
||||
|
||||
### What is Book Matching?
|
||||
|
||||
When devices sync books, Bookhoard tries to automatically match them using:
|
||||
|
||||
- **SHA-256 hash** (most reliable) - Content-based fingerprint
|
||||
- **ISBN** - Standard book identifier
|
||||
- **UUID** - Unique identifier from EPUB metadata
|
||||
- **Title + Author** (fallback) - Last resort matching
|
||||
|
||||
### Unlinked Books
|
||||
|
||||
Sometimes a book on your device can't be automatically matched to your library. This happens when:
|
||||
|
||||
- The book was side-loaded (not downloaded via Bookhoard)
|
||||
- The file format was converted (EPUB → KEPUB)
|
||||
- The metadata doesn't match exactly
|
||||
|
||||
### Viewing Unlinked Books
|
||||
|
||||
```
|
||||
Devices page → select device → unlinked books
|
||||
```
|
||||
|
||||
### Resolving Unlinked Books
|
||||
|
||||
1. **Auto-Link** - Automatically link books with 80%+ confidence
|
||||
2. **Get Suggestions** - Review potential matches before linking
|
||||
3. **Manual Link** - Choose the correct match yourself
|
||||
|
||||
### Book Matching Priority
|
||||
|
||||
1. **Bookhoard UUID** - 100% confidence
|
||||
2. **OPF UUID** - 95% confidence
|
||||
3. **SHA-256 hash** - 90% confidence
|
||||
4. **OPF identifier** - 85% confidence
|
||||
5. **ISBN/ASIN** - 80% confidence
|
||||
6. **Title + Author + File Size** - 50% confidence
|
||||
|
||||
---
|
||||
|
||||
## Managing Conflicts
|
||||
|
||||
### Reading Progress
|
||||
|
||||
**What Syncs**:
|
||||
|
||||
- Current page number
|
||||
- Reading percentage
|
||||
- Chapter progress
|
||||
- EPUB CFI (for EPUB files)
|
||||
- Last read timestamp
|
||||
- Reading position (viewport, zoom, scroll)
|
||||
|
||||
**How It Works**:
|
||||
|
||||
```
|
||||
You turn page → Device sends progress → Server updates database
|
||||
↓
|
||||
Broadcasts to all your devices
|
||||
↓
|
||||
Other devices update their display
|
||||
```
|
||||
|
||||
**Supported Progress Types**:
|
||||
|
||||
- **EPUB/MOBI**: Percentage + EPUB CFI + Chapter
|
||||
- **PDF/DJVU**: Page number + Viewport position
|
||||
- **CBZ/CBR**: Page number + Panel coordinates
|
||||
|
||||
### Highlights & Notes
|
||||
|
||||
**What Syncs**:
|
||||
|
||||
- Highlighted text
|
||||
- Notes and annotations
|
||||
- Bookmark locations
|
||||
- Colors and formatting
|
||||
- Chapter/paragraph references
|
||||
|
||||
**Universal Location References**:
|
||||
All highlights are stored with multiple location types:
|
||||
|
||||
- Page:offset (traditional)
|
||||
- EPUB CFI (EPUB files)
|
||||
- Percentage (0-100%)
|
||||
- Character offset (exact position)
|
||||
- Chapter references
|
||||
|
||||
This ensures your highlights work across all devices, even with different page counts!
|
||||
|
||||
### Bookmarks
|
||||
|
||||
**What Syncs**:
|
||||
|
||||
- Bookmark locations
|
||||
- Bookmark titles
|
||||
- Date created
|
||||
- Reading position context
|
||||
|
||||
---
|
||||
|
||||
## Sync Modes
|
||||
|
||||
### Immediate Mode (Default)
|
||||
|
||||
**Best For**: Normal reading, page turns
|
||||
|
||||
**Behavior**:
|
||||
|
||||
- Syncs every page turn
|
||||
- Real-time updates
|
||||
- Low latency
|
||||
- Higher bandwidth usage
|
||||
|
||||
**Recommended Settings**:
|
||||
|
||||
- Auto-sync: ON
|
||||
- Sync frequency: Every page turn
|
||||
|
||||
### Checkpoint Mode
|
||||
|
||||
**Best For**: Slow connections, battery saving
|
||||
|
||||
**Behavior**:
|
||||
|
||||
- Batches changes
|
||||
- Syncs every 5 minutes or when connection allows
|
||||
- Lower bandwidth
|
||||
- Better for offline reading
|
||||
|
||||
**Recommended Settings**:
|
||||
|
||||
- Auto-sync: ON
|
||||
- Sync frequency: Checkpoint mode
|
||||
|
||||
---
|
||||
|
||||
## Offline Support
|
||||
|
||||
### How Offline Sync Works
|
||||
|
||||
1. **Offline Reading**:
|
||||
- Read normally without connection
|
||||
- All progress tracked locally
|
||||
|
||||
2. **Queue Changes**:
|
||||
- Progress changes queued automatically
|
||||
- Annotations saved locally
|
||||
- Up to 100 items in offline queue
|
||||
|
||||
3. **Reconnection**:
|
||||
- Connect to Wi-Fi
|
||||
- Queue processes automatically
|
||||
- Priority items first (book completion, notes)
|
||||
- All devices updated
|
||||
|
||||
### Offline Indicators
|
||||
|
||||
**In Web Interface**:
|
||||
|
||||
- Yellow status icon: Device offline
|
||||
- Last seen timestamp
|
||||
- "Pending sync" badge on books
|
||||
|
||||
**On Devices**:
|
||||
|
||||
- Sync icon: Gray = offline
|
||||
- Sync icon: Blue = syncing
|
||||
- Sync icon: Green = synced
|
||||
|
||||
---
|
||||
Every reading session is logged — when, how long, on which device — and
|
||||
feeds your [dashboard](dashboard.md) and stats. Your history is visible on
|
||||
the **Progress** page and on each book's detail page, and it is yours
|
||||
alone.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Sync Not Working
|
||||
**Progress not updating**
|
||||
Check the device is still on the **Devices** page and was approved; check
|
||||
the server URL in the device's settings; confirm the device can reach the
|
||||
server (open the server URL in the device's browser as a test).
|
||||
|
||||
**Symptoms**: Progress not updating across devices
|
||||
**"Device not found"**
|
||||
The device was removed or its token revoked — register it again and
|
||||
approve it.
|
||||
|
||||
**Solutions**:
|
||||
**A conflict keeps coming back**
|
||||
Two devices are genuinely fighting over the same book. Decide which device
|
||||
is the reader for that book, resolve with a strategy above, and avoid
|
||||
simultaneous reading of the same title.
|
||||
|
||||
1. Check device is online: Devices page (sidebar navigation)
|
||||
2. Verify sync is enabled for the device
|
||||
3. Check sync URL is correct
|
||||
4. Ensure device has network connection
|
||||
5. Try manual sync: Open book → Menu → Sync Now
|
||||
**High battery or data use**
|
||||
That is the cost of continuous sync on a chatty device; e-reader plugins
|
||||
let you space out their sync checks from the plugin's own settings.
|
||||
|
||||
### "Device Not Found" Error
|
||||
## Device limits
|
||||
|
||||
**Cause**: Device not registered or authorization revoked
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Re-register the device
|
||||
2. Check device hasn't been removed
|
||||
3. Verify correct device type selected
|
||||
|
||||
### "Rate Limit Exceeded" Error
|
||||
|
||||
**Cause**: Too many sync requests
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Wait a few seconds
|
||||
2. Switch to checkpoint mode
|
||||
3. Contact admin to increase limits
|
||||
|
||||
### Conflicts Detected
|
||||
|
||||
**Symptoms**: "Sync conflict" notification
|
||||
|
||||
**Cause**: Same book being read on multiple devices simultaneously
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Open the book's detail page and click **Sync Progress**, or go to the Conflicts page (`/conflicts`)
|
||||
2. Review both device progress
|
||||
3. Choose which device's progress to keep
|
||||
4. Or choose "Merge" (keeps furthest progress)
|
||||
|
||||
### High Battery Usage
|
||||
|
||||
**Cause**: Immediate sync mode with frequent page turns
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Switch to checkpoint mode
|
||||
2. Increase sync interval
|
||||
3. Sync less frequently
|
||||
Each account has a device limit — **10 by default**, and administrators can
|
||||
raise or lower it per user. Removing a device you no longer use frees a
|
||||
slot.
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### For Optimal Performance
|
||||
|
||||
✅ **DO**:
|
||||
|
||||
- Use checkpoint mode when on slow connections
|
||||
- Keep device firmware updated
|
||||
- Use Wi-Fi when available
|
||||
- Approve only devices you own
|
||||
- Regularly check conflict resolution
|
||||
|
||||
❌ **DON'T**:
|
||||
|
||||
- Read same book on multiple devices simultaneously
|
||||
- Ignore conflict notifications
|
||||
- Register public/shared devices
|
||||
- Exceed device limits (max 10 per user)
|
||||
|
||||
### Organizing Your Library
|
||||
|
||||
**For Best Sync Experience**:
|
||||
|
||||
- Use consistent metadata (titles, authors)
|
||||
- Avoid duplicate books in library
|
||||
- Match files by ISBN when possible
|
||||
- Use clean file structure
|
||||
|
||||
### Managing Multiple Devices
|
||||
|
||||
**Recommended Setup**:
|
||||
|
||||
- **Primary Device**: KOReader on e-reader
|
||||
- **Secondary Device**: Web browser (work/home)
|
||||
- **On the go**: Web browser on a phone (dedicated mobile apps coming later)
|
||||
|
||||
**Sync Strategy**:
|
||||
|
||||
1. Read mainly on primary device
|
||||
2. Check progress on web/secondary devices
|
||||
3. Let auto-sync handle updates
|
||||
4. Resolve conflicts promptly
|
||||
|
||||
---
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Conflict Resolution
|
||||
|
||||
**Automatic Resolution**:
|
||||
|
||||
- Most recent progress wins
|
||||
- Timestamp-based comparison
|
||||
- 5-minute window for conflict detection
|
||||
|
||||
**Manual Resolution**:
|
||||
|
||||
```
|
||||
Book detail → Sync Progress → choose winner
|
||||
```
|
||||
|
||||
**Options**:
|
||||
|
||||
- **Keep Device A**: Use this device's progress
|
||||
- **Keep Device B**: Use other device's progress
|
||||
- **Merge**: Keep furthest progress (combination)
|
||||
- **Auto-Resolve Future**: Always prefer this device
|
||||
|
||||
### Sync Queue Management
|
||||
|
||||
**View Queue Status**:
|
||||
|
||||
```
|
||||
Devices page → sync queue section
|
||||
```
|
||||
|
||||
**Queue Stats**:
|
||||
|
||||
- Pending: Waiting to sync
|
||||
- Processing: Currently syncing
|
||||
- Failed: Retry scheduled
|
||||
- Completed: Successfully synced
|
||||
|
||||
**Manual Actions**:
|
||||
|
||||
- **Retry All**: Retry all failed items
|
||||
- **Clear Queue**: Remove all pending items
|
||||
- **Priority Sync**: Sync specific book immediately
|
||||
|
||||
### Reading History
|
||||
|
||||
**Automatic Tracking**:
|
||||
|
||||
- Every sync session logged
|
||||
- Time spent reading calculated
|
||||
- Pages read tracked
|
||||
- Device used recorded
|
||||
|
||||
**View History**:
|
||||
|
||||
```
|
||||
Progress page (sidebar navigation), or the book's detail page
|
||||
```
|
||||
|
||||
**Privacy**:
|
||||
|
||||
- Only you can see your history
|
||||
- History kept for 365 days
|
||||
- Exportable for backup
|
||||
|
||||
---
|
||||
|
||||
## Security & Privacy
|
||||
|
||||
### Device Authentication
|
||||
|
||||
**Secure by Design**:
|
||||
|
||||
- ✅ No passwords stored on devices
|
||||
- ✅ Web-based approval required
|
||||
- ✅ Unique tokens per device
|
||||
- ✅ Revocable at any time
|
||||
- ✅ Token encryption in transit
|
||||
|
||||
### Data Protection
|
||||
|
||||
**What We Store**:
|
||||
|
||||
- Reading progress (page, percentage)
|
||||
- Highlights and notes
|
||||
- Device identifiers
|
||||
- Sync timestamps
|
||||
|
||||
**What We DON'T Store**:
|
||||
|
||||
- Passwords on devices
|
||||
- Reading content (your books)
|
||||
- Unencrypted personal data
|
||||
- Location data (GPS)
|
||||
|
||||
### Access Control
|
||||
|
||||
**Your Data**:
|
||||
|
||||
- Only you can see your progress
|
||||
- Admins cannot read your annotations
|
||||
- Shared only with devices you approve
|
||||
|
||||
**Device Access**:
|
||||
|
||||
- Each device sees only your libraries
|
||||
- Devices cannot access other users
|
||||
- Revoking removes all access
|
||||
|
||||
---
|
||||
|
||||
## Performance Tips
|
||||
|
||||
### For Faster Sync
|
||||
|
||||
1. **Use 5GHz Wi-Fi** - Faster than 2.4GHz
|
||||
2. **Keep server nearby** - Low latency = better sync
|
||||
3. **Regular updates** - Keeps firmware current
|
||||
4. **Checkpoint mode** - For batch processing
|
||||
|
||||
### For Better Battery Life
|
||||
|
||||
1. **Checkpoint mode** - Fewer sync requests
|
||||
2. **Increase sync interval** - Fewer updates
|
||||
3. **Close when not reading** - Reduces background activity
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
### General Questions
|
||||
|
||||
**Q: Does sync work with all book formats?**
|
||||
A: Yes! EPUB, PDF, MOBI, CBZ, CBR and more.
|
||||
|
||||
**Q: Can multiple users share a device?**
|
||||
A: No, devices are tied to individual accounts for security.
|
||||
|
||||
**Q: What happens if I delete a book?**
|
||||
A: All sync data for that book is removed from the server.
|
||||
|
||||
**Q: Can I export my reading data?**
|
||||
A: Reading data isn't exportable from the UI yet — it's accessible via the API.
|
||||
|
||||
**Q: Does sync work over the internet?**
|
||||
A: Yes, if your server is publicly accessible with HTTPS.
|
||||
|
||||
**Q: How much data does sync use?**
|
||||
A: Approximately 1KB per page turn, 50KB per annotation.
|
||||
|
||||
### Technical Questions
|
||||
|
||||
**Q: How does sync handle different page counts?**
|
||||
A: Uses percentage and EPUB CFI for universal positioning.
|
||||
|
||||
**Q: Can I sync with Calibre anymore?**
|
||||
A: Bookhoard's KOReader sync uses a dedicated plugin (server-side approval, device tokens) — no Calibre involvement required.
|
||||
|
||||
**Q: What if I lose my device?**
|
||||
A: Revoke it in settings and register a new one.
|
||||
|
||||
**Q: Is sync end-to-end encrypted?**
|
||||
A: Yes, HTTPS/TLS 1.3 for all sync traffic.
|
||||
|
||||
---
|
||||
|
||||
## Getting Help
|
||||
|
||||
### Documentation
|
||||
|
||||
- **API Docs**: `/docs/api.md`
|
||||
- **Device Guides**: `/docs/devices/`
|
||||
- **Self-Hosting**: `/docs/install.md`
|
||||
|
||||
### Support
|
||||
|
||||
- **Issues**: Report bugs at GitHub Issues
|
||||
- **Discussions**: Join GitHub Discussions
|
||||
- **Email**: support@bookhoard.example.com
|
||||
|
||||
### Community
|
||||
|
||||
- **Forum**: community.bookhoard.example.com
|
||||
- **Matrix**: #bookhoard:matrix.org
|
||||
- **Discord**: discord.gg/bookhoard
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
### Version 1.0.x (2026)
|
||||
|
||||
- ✅ Web sync support
|
||||
- ✅ KOReader sync (progress, bookmarks, highlights, notes)
|
||||
- ✅ Conflict resolution
|
||||
- ✅ Offline queue
|
||||
- ✅ Real-time WebSocket sync
|
||||
- 🚧 Native Kobo sync (coming soon)
|
||||
- 🚧 Mobile apps (coming later)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: August 2026
|
||||
**Version**: 1.0
|
||||
**License**: AGPL-3.0
|
||||
More detail on any device lives in its guide:
|
||||
[Android](devices/android-app.md) ·
|
||||
[KOReader](devices/koreader-setup.md) ·
|
||||
[Kobo](devices/kobo-setup.md) ·
|
||||
[Calibre import](calibre-integration.md) ·
|
||||
and the [filter rules](filters/index.md) that power matching-adjacent
|
||||
shelves like [smart collections](collections.md).
|
||||
+8
-2
@@ -34,7 +34,7 @@ full set of [Omarchy](https://omarchy.org) defaults alongside the classics:
|
||||
|
||||
**Light**
|
||||
|
||||
- **Lupine** - Clean light with violet-blue accents (default light theme)
|
||||
- **Lupine** - Clean light with violet-blue accents
|
||||
- **Tokyo Night Day** - Daylight counterpart of the default
|
||||
- **Solarized Light** - Precision contrast on warm paper
|
||||
- **One Light** - Atom's light theme
|
||||
@@ -52,11 +52,17 @@ full set of [Omarchy](https://omarchy.org) defaults alongside the classics:
|
||||
|
||||
1. In the sidebar, open the **Appearance** panel (palette icon, near the bottom)
|
||||
2. Use the **Dark / Light** switch at the top to show the half you want —
|
||||
it opens on the half your current theme lives in
|
||||
it opens on the half your current theme lives in, and each side shows
|
||||
how many themes it holds. The switch only filters the list; it never
|
||||
changes your theme by itself
|
||||
3. Pick a theme from the list — each option shows its color swatch, and your
|
||||
active theme is marked with a checkmark
|
||||
4. Your choice is saved automatically and synced across devices
|
||||
|
||||
There is exactly one default: **Tokyo Night** on a fresh install. Nothing
|
||||
else is pre-selected for you — pick whichever theme you like from either
|
||||
half.
|
||||
|
||||
On small screens, open the sidebar with the menu button in the top bar first.
|
||||
|
||||
## Wood Paneling
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# User Areas Guide
|
||||
|
||||
**Coming Soon**
|
||||
|
||||
This guide will cover:
|
||||
|
||||
- Managing your personal library
|
||||
- Uploading and organizing books
|
||||
- Creating and managing collections
|
||||
- Syncing across devices
|
||||
- Personal settings
|
||||
|
||||
---
|
||||
|
||||
_In the meantime, check out our [Sync Guide](sync-guide.md)_
|
||||
+38
-70
@@ -1,79 +1,47 @@
|
||||
# User Documentation
|
||||
# Bookhoard — start here
|
||||
|
||||
Welcome to the Bookhoard user documentation. This section contains guides for using Bookhoard features, setting up devices, and configuring sync.
|
||||
Bookhoard is a self-hosted home for the things you read and hear: your book
|
||||
libraries live on your own server, you read them in the browser or on your
|
||||
devices, and everything keeps track of where you are.
|
||||
|
||||
## 📱 Device Setup
|
||||
The moving parts, in one breath:
|
||||
|
||||
Learn how to configure your e-reader devices to sync with Bookhoard:
|
||||
- **Libraries** hold your books (and your podcasts run as feeds of their own)
|
||||
- The **dashboard** is your home screen — shelves that update themselves
|
||||
- **Collections** are shelves you build, by hand or by rules
|
||||
- **Filters** answer "which books match?" — the same rules power saved
|
||||
filters, smart collections, and dashboard shelves
|
||||
- **Devices** — the Android app, KOReader on e-readers, and stock Kobo
|
||||
firmware — stay in step with the server through **sync**
|
||||
- Your **profile** holds your preferences: theme, playback defaults, password
|
||||
|
||||
- **[Android App Guide](devices/android-app.md)** - The native Android client
|
||||
- Installing and upgrading the APK
|
||||
- Connecting to a self-hosted server over LAN
|
||||
- The Android 16+ local-network permission
|
||||
- Troubleshooting login failures
|
||||
## Where to go
|
||||
|
||||
- **[KOReader Setup Guide](devices/koreader-setup.md)** - Complete guide for KOReader
|
||||
- Installation on Kindle/Kobo/PocketBook
|
||||
- Plugin setup with server-side device approval
|
||||
- Progress, bookmark, highlight, and note sync
|
||||
- OPDS catalog access
|
||||
- Troubleshooting
|
||||
| You want to... | Read |
|
||||
| --- | --- |
|
||||
| Learn the layout: dashboard, shelves, customizing | [Dashboard](dashboard.md) |
|
||||
| Browse, search, and sort your books | [Browsing your library](library-browsing.md) |
|
||||
| Build shelves — by hand or with rules | [Collections](collections.md) |
|
||||
| Narrow with rules, save them, reuse them | [Filters — the complete guide](filters/index.md) |
|
||||
| Subscribe to shows, download, listen | [Podcasts](podcasts.md) |
|
||||
| Read and annotate in the browser | [Web Reader Guide](reader-guide.md) |
|
||||
| Change themes and bookshelf textures | [Themes and Wood Paneling](themes.md) |
|
||||
| Edit your account, password, playback defaults | [Your profile](profile-guide.md) |
|
||||
| Get the Android app talking to your server | [Android App](devices/android-app.md) |
|
||||
| Use KOReader on an e-reader | [KOReader Setup](devices/koreader-setup.md) |
|
||||
| Use a Kobo | [Kobo Setup](devices/kobo-setup.md) |
|
||||
| Understand how it all stays in sync | [Universal Sync Guide](sync-guide.md) |
|
||||
| Import from Calibre | [Calibre Integration](calibre-integration.md) |
|
||||
|
||||
- **[Kobo Setup Guide](devices/kobo-setup.md)** - Native Kobo sync (coming soon)
|
||||
- In the meantime, KOReader works great on Kobo hardware
|
||||
New here? The usual path is: browse [your library](library-browsing.md),
|
||||
open a book, and let the [dashboard](dashboard.md) keep track of you. Sync
|
||||
comes when you add a [device](devices/android-app.md).
|
||||
|
||||
## 🔄 Sync Configuration
|
||||
## A note on admins
|
||||
|
||||
- **[Universal Sync Guide](sync-guide.md)** - Understanding and using sync features
|
||||
- What is Universal Sync?
|
||||
- Supported devices
|
||||
- Book matching and auto-linking
|
||||
- Conflict resolution
|
||||
- Best practices
|
||||
Some screens (user management, server settings, library folders) are admin
|
||||
territory; [Admin Guide](admin-guide.md) is the door, and the admin docs are
|
||||
being expanded separately.
|
||||
|
||||
## 📚 Calibre Integration
|
||||
|
||||
- **[Calibre Integration Guide](calibre-integration.md)** - Integrating with Calibre libraries
|
||||
- What is Calibre?
|
||||
- Automatic metadata import
|
||||
- Setting up your Calibre library
|
||||
- Supported metadata and file types
|
||||
- Workflow examples
|
||||
- Troubleshooting
|
||||
- Best practices
|
||||
|
||||
## 📖 Reading
|
||||
|
||||
- **[Web Reader Guide](reader-guide.md)** - Reading and annotating in the browser
|
||||
- Touch navigation (swipe to page, tap for menu)
|
||||
- Selecting text: long-press, drag, handles
|
||||
- Highlights, notes, copy, and editing
|
||||
- PDFs and comics
|
||||
|
||||
## 🎨 Frontend Guide
|
||||
|
||||
**[Frontend Guide](frontend-guide.md)** - Learn how to use the Bookhoard web interface
|
||||
|
||||
- _Coming Soon_
|
||||
|
||||
## 👤 User Areas
|
||||
|
||||
**[User Areas Guide](user-areas.md)** - Managing your personal library and settings
|
||||
|
||||
- _Coming Soon_
|
||||
|
||||
## ⚙️ Settings
|
||||
|
||||
**[Settings Guide](settings-guide.md)** - Configuring your Bookhoard preferences
|
||||
|
||||
- _Coming Soon_
|
||||
|
||||
## 🔐 Admin Features
|
||||
|
||||
**[Admin Guide](admin-guide.md)** - Administrative functions and management
|
||||
|
||||
- _Coming Soon_
|
||||
|
||||
---
|
||||
|
||||
**Looking for developer documentation?** See the [Developer Portal](../developer/development.md)
|
||||
**Looking for developer documentation?** See the
|
||||
[Developer Portal](../developer/development.md).
|
||||
+15
-14
@@ -640,18 +640,19 @@ type UserDashboardPreferencesGlobal struct {
|
||||
}
|
||||
|
||||
type Users struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
Email string `db:"email" json:"email"`
|
||||
Username string `db:"username" json:"username"`
|
||||
PasswordHash string `db:"password_hash" json:"password_hash"`
|
||||
FirstName pgtype.Text `db:"first_name" json:"first_name"`
|
||||
LastName pgtype.Text `db:"last_name" json:"last_name"`
|
||||
Role string `db:"role" json:"role"`
|
||||
Theme pgtype.Text `db:"theme" json:"theme"`
|
||||
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
|
||||
KeepScreenAwake bool `db:"keep_screen_awake" json:"keep_screen_awake"`
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
Email string `db:"email" json:"email"`
|
||||
Username string `db:"username" json:"username"`
|
||||
PasswordHash string `db:"password_hash" json:"password_hash"`
|
||||
FirstName pgtype.Text `db:"first_name" json:"first_name"`
|
||||
LastName pgtype.Text `db:"last_name" json:"last_name"`
|
||||
Role string `db:"role" json:"role"`
|
||||
Theme pgtype.Text `db:"theme" json:"theme"`
|
||||
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
|
||||
KeepScreenAwake bool `db:"keep_screen_awake" json:"keep_screen_awake"`
|
||||
AudioResumeRewindSeconds int32 `db:"audio_resume_rewind_seconds" json:"audio_resume_rewind_seconds"`
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -11407,26 +11407,28 @@ 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
|
||||
`
|
||||
|
||||
type GetUserRow struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
Email string `db:"email" json:"email"`
|
||||
Username string `db:"username" json:"username"`
|
||||
Theme pgtype.Text `db:"theme" json:"theme"`
|
||||
FirstName pgtype.Text `db:"first_name" json:"first_name"`
|
||||
LastName pgtype.Text `db:"last_name" json:"last_name"`
|
||||
Role string `db:"role" json:"role"`
|
||||
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
||||
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
|
||||
KeepScreenAwake bool `db:"keep_screen_awake" json:"keep_screen_awake"`
|
||||
DeviceCount int64 `db:"device_count" json:"device_count"`
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
Email string `db:"email" json:"email"`
|
||||
Username string `db:"username" json:"username"`
|
||||
Theme pgtype.Text `db:"theme" json:"theme"`
|
||||
FirstName pgtype.Text `db:"first_name" json:"first_name"`
|
||||
LastName pgtype.Text `db:"last_name" json:"last_name"`
|
||||
Role string `db:"role" json:"role"`
|
||||
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
||||
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
|
||||
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"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error) {
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,10 +91,23 @@ 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 {
|
||||
return nil, fmt.Errorf("failed to read document: %w", err)
|
||||
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
|
||||
|
||||
+29
-14
@@ -8,40 +8,55 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDocsRelativeLinks keeps the developer docs tree internally navigable:
|
||||
// every relative markdown link must point at a file that exists. The
|
||||
// api-reference overview once shipped 20+ directory-style links
|
||||
// (authentication/ etc.) that 404'd — this gate makes that class of rot
|
||||
// fail make test. (docs/user is excluded: end-user prose has its own pass.)
|
||||
// TestDocsRelativeLinks keeps every doc tree internally navigable: every
|
||||
// relative markdown link must point at a file that exists. The api-reference
|
||||
// overview once shipped 20+ directory-style links (authentication/ etc.)
|
||||
// that 404'd — this gate makes that class of rot fail make test.
|
||||
//
|
||||
// Rules:
|
||||
// - http(s):, mailto:, and pure #anchors are skipped
|
||||
// - every other target must end in .md and resolve (relative to the
|
||||
// linking file) to an existing file inside the tree
|
||||
// linking file) to an existing file inside the docs tree
|
||||
// - directory-style targets ("area/") are the exact bug this test exists
|
||||
// for and always fail
|
||||
//
|
||||
// Hands-off sources (project owner instruction, 2026-11-02: DO NOT EDIT THE
|
||||
// FILTER DOCS): docs/user/filters/** and docs/user/searching-and-filtering.md
|
||||
// are not scanned as link sources, so no future gate failure can force an
|
||||
// edit there. Files linking INTO them are still checked — resolution needs
|
||||
// file existence only, never edits to their content.
|
||||
|
||||
var docsLinkRe = regexp.MustCompile(`\[[^\]]*\]\(([^)\s]+)\)`)
|
||||
|
||||
var handsOffFilterDocs = []string{
|
||||
"user/filters/",
|
||||
"user/searching-and-filtering.md",
|
||||
}
|
||||
|
||||
func TestDocsRelativeLinks(t *testing.T) {
|
||||
// Walk developer docs; escape-check against the whole docs tree —
|
||||
// cross-area links into docs/user are legitimate (same site).
|
||||
devRoot, err := filepath.Abs("../../docs/developer")
|
||||
if err != nil {
|
||||
t.Fatalf("abs developer root: %v", err)
|
||||
}
|
||||
treeRoot, err := filepath.Abs("../../docs")
|
||||
if err != nil {
|
||||
t.Fatalf("abs docs root: %v", err)
|
||||
}
|
||||
err = filepath.WalkDir(devRoot, func(path string, d os.DirEntry, err error) error {
|
||||
err = filepath.WalkDir(treeRoot, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".md") {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(treeRoot, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relSlash := filepath.ToSlash(rel)
|
||||
for _, off := range handsOffFilterDocs {
|
||||
if strings.HasPrefix(relSlash, off) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(docsRoot, path)
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestUIControlLabels holds the UI documentation to the source of the UI.
|
||||
// The glossary (docs/developer/ui-controls.md) is the canonical spelling of
|
||||
// every control the docs cite; this test greps that spelling against the
|
||||
// server-rendered templates, so a renamed button fails make test in the
|
||||
// same commit as the rename — the class of rot screenshots could never be
|
||||
// gated. Dynamic (Alpine-composed) labels are OR-checked with their `also`
|
||||
// fragments instead of requiring the composed string to exist.
|
||||
//
|
||||
// Scope is substring-level (not word-boundary): the point is catching
|
||||
// renames and deletions, not stopping coincidental substrings. New controls
|
||||
// cited in prose get a glossary entry in the same commit (writing-ui-docs.md
|
||||
// is the standard; the filter docs are the hands-off exemplar).
|
||||
|
||||
const (
|
||||
labelGlossaryRel = "../../docs/developer/ui-controls.md"
|
||||
labelTemplRootRel = "../../templates"
|
||||
labelTSRootRel = "../../web/src"
|
||||
)
|
||||
|
||||
type docGlossaryEntry struct {
|
||||
label string
|
||||
where string
|
||||
does string
|
||||
source string
|
||||
reason string
|
||||
also []string
|
||||
skip bool
|
||||
}
|
||||
|
||||
// docCorpus concatenates every file under root ending in suffix.
|
||||
func docCorpus(t *testing.T, root string, suffix string) string {
|
||||
t.Helper()
|
||||
abs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
t.Fatalf("abs %s: %v", root, err)
|
||||
}
|
||||
var b strings.Builder
|
||||
err = filepath.WalkDir(abs, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() || !strings.HasSuffix(path, suffix) {
|
||||
return nil
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.Write(raw)
|
||||
b.WriteString("\n")
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk %s: %v", root, err)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func corpusName(source string) string {
|
||||
if source == "ts" {
|
||||
return "web/src"
|
||||
}
|
||||
return "templ"
|
||||
}
|
||||
|
||||
func parseUIControlsBlock(t *testing.T, raw string) []docGlossaryEntry {
|
||||
t.Helper()
|
||||
|
||||
var block strings.Builder
|
||||
inBlock, foundAny := false, false
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(trimmed, "```controls"):
|
||||
inBlock, foundAny = true, true
|
||||
continue
|
||||
case trimmed == "```" && inBlock:
|
||||
inBlock = false
|
||||
continue
|
||||
}
|
||||
if inBlock {
|
||||
block.WriteString(line)
|
||||
block.WriteString("\n")
|
||||
}
|
||||
}
|
||||
if !foundAny {
|
||||
t.Fatalf("no ```controls block found in %s", labelGlossaryRel)
|
||||
}
|
||||
|
||||
var entries []docGlossaryEntry
|
||||
haveCur := false
|
||||
cur := docGlossaryEntry{}
|
||||
flush := func() {
|
||||
if haveCur {
|
||||
entries = append(entries, cur)
|
||||
haveCur = false
|
||||
}
|
||||
}
|
||||
for _, line := range strings.Split(block.String(), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key, val, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
t.Fatalf("malformed controls line %q (want `key: value`)", line)
|
||||
}
|
||||
key, val = strings.TrimSpace(key), strings.TrimSpace(val)
|
||||
switch key {
|
||||
case "label":
|
||||
flush()
|
||||
haveCur = true
|
||||
cur = docGlossaryEntry{label: val}
|
||||
case "where":
|
||||
cur.where = val
|
||||
case "does":
|
||||
cur.does = val
|
||||
case "source":
|
||||
cur.source = val
|
||||
case "also":
|
||||
for _, frag := range strings.Split(val, "|") {
|
||||
if frag = strings.TrimSpace(frag); frag != "" {
|
||||
cur.also = append(cur.also, frag)
|
||||
}
|
||||
}
|
||||
case "skip":
|
||||
cur.skip = val == "true"
|
||||
case "reason":
|
||||
cur.reason = val
|
||||
case "note":
|
||||
// free text for humans
|
||||
default:
|
||||
t.Fatalf("unknown field in controls block: %q (allowed: label|where|does|source|also|skip|reason|note)", key)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return entries
|
||||
}
|
||||
|
||||
func TestUIControlLabels(t *testing.T) {
|
||||
raw, err := os.ReadFile(labelGlossaryRel)
|
||||
if err != nil {
|
||||
t.Fatalf("read glossary: %v", err)
|
||||
}
|
||||
entries := parseUIControlsBlock(t, string(raw))
|
||||
if len(entries) == 0 {
|
||||
t.Fatalf("glossary has no entries — add at least one before citing controls")
|
||||
}
|
||||
|
||||
tmplRequired, tsRequired := false, false
|
||||
for _, e := range entries {
|
||||
if e.source == "ts" {
|
||||
tsRequired = true
|
||||
} else {
|
||||
tmplRequired = true
|
||||
}
|
||||
}
|
||||
corpora := map[string]string{}
|
||||
if tmplRequired {
|
||||
corpora[corpusName("")] = docCorpus(t, labelTemplRootRel, ".templ")
|
||||
}
|
||||
if tsRequired {
|
||||
corpora[corpusName("ts")] = docCorpus(t, labelTSRootRel, ".ts")
|
||||
}
|
||||
|
||||
seen := map[string]int{}
|
||||
for _, e := range entries {
|
||||
if e.label == "" {
|
||||
t.Errorf("entry with empty label (where: %q, does: %q)", e.where, e.does)
|
||||
continue
|
||||
}
|
||||
seen[e.label]++
|
||||
if e.where == "" || e.does == "" {
|
||||
t.Errorf("control %q: `where` and `does` are required", e.label)
|
||||
}
|
||||
if e.skip && e.reason == "" {
|
||||
t.Errorf("control %q: skip=true requires a reason", e.label)
|
||||
}
|
||||
switch e.source {
|
||||
case "", "templ", "ts":
|
||||
default:
|
||||
t.Errorf("control %q: unknown source %q (templ|ts)", e.label, e.source)
|
||||
}
|
||||
if e.skip {
|
||||
continue
|
||||
}
|
||||
|
||||
fragments := append([]string{e.label}, e.also...)
|
||||
found := false
|
||||
for _, frag := range fragments {
|
||||
if strings.Contains(corpora[corpusName(e.source)], frag) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("control %q (%s): none of the fragments [%s] found in %s sources — the label was renamed or the glossary is stale. Update ui-controls.md in the same commit as the rename.",
|
||||
e.label, e.where, strings.Join(fragments, " | "), corpusName(e.source))
|
||||
}
|
||||
}
|
||||
for label, n := range seen {
|
||||
if n > 1 {
|
||||
t.Errorf("control %q listed %d times — one entry per control", label, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -123,9 +124,15 @@ type UserProfile struct {
|
||||
Role string `json:"role"`
|
||||
Theme string `json:"theme,omitempty"`
|
||||
// User-level default audiobook playback speed (plan §8); 0 omits.
|
||||
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty"`
|
||||
// Whether the web reader/player should hold a Screen Wake Lock.
|
||||
// Response-only struct: omit nothing — default_audio_speed is NOT NULL
|
||||
// DEFAULT 1.0 (writes validate 0.25–4), so a real profile always emits
|
||||
// it; omitempty here would drop a contract field from the docs gate.
|
||||
DefaultAudioSpeed float64 `json:"default_audio_speed"` // 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 +149,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 +563,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 +728,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.
|
||||
|
||||
@@ -3101,13 +3101,18 @@ 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 {
|
||||
defaultSpeed = float64(userRow.DefaultAudioSpeed)
|
||||
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},
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -41,10 +41,13 @@ func SecurityHeadersMiddleware() echo.MiddlewareFunc {
|
||||
// the component and asserts the hash matches.
|
||||
//
|
||||
// style-src keeps 'unsafe-inline' for htmx-injected styles and
|
||||
// Alpine :style attribute writes.
|
||||
// Alpine :style attribute writes, plus blob: for the
|
||||
// reader-generated stylesheets foliate injects at open time
|
||||
// (blocked since the CSP migration — every /readers load
|
||||
// logged a violation; selection/highlight styling rode on it).
|
||||
// font/img blob: — the reader renders EPUB-embedded fonts and
|
||||
// images from blob: URLs inside the foliate content frame.
|
||||
c.Response().Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' "+ScriptHashThemeFlashGuard+"; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data: blob:; frame-src 'self' blob:")
|
||||
c.Response().Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' "+ScriptHashThemeFlashGuard+"; style-src 'self' 'unsafe-inline' blob:; img-src 'self' data: https: blob:; font-src 'self' data: blob:; frame-src 'self' blob:")
|
||||
c.Response().Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
c.Response().Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -66,7 +66,7 @@ templ AdminArchived(user User, items []ArchivedItem, retentionDays int) {
|
||||
if item.Author != "" {
|
||||
<p style="color: var(--text-secondary)">{ item.Author }</p>
|
||||
}
|
||||
<p class="font-mono text-xs break-all" style="color: var(--text-secondary)">{ item.FilePath }</p>
|
||||
<p class="font-mono text-xs break-all line-clamp-3" style="color: var(--text-secondary)">{ item.FilePath }</p>
|
||||
</td>
|
||||
<td class="px-4 py-3" style="color: var(--text-primary)">{ item.LibraryName }</td>
|
||||
<td class="px-4 py-3" style="color: var(--text-secondary)">{ item.Status }</td>
|
||||
|
||||
@@ -164,14 +164,14 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<p class=\"font-mono text-xs break-all\" style=\"color: var(--text-secondary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<p class=\"font-mono text-xs break-all line-clamp-3\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.FilePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 69, Col: 103}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 69, Col: 116}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -49,7 +49,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
|
||||
unless purged; items return automatically if their files come back.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<a href="/admin/archived" class="btn btn-secondary">View Archived</a>
|
||||
<button type="button" @click="purgeArchivedItems()" class="btn btn-secondary">
|
||||
@Icon("trash", "h-4 w-4")
|
||||
@@ -200,8 +200,13 @@ templ LibraryList(user User, libraries []LibraryData, users []User) {
|
||||
<div class="space-y-4">
|
||||
for _, library := range libraries {
|
||||
<div class="card overflow-hidden">
|
||||
<div class="flex items-center justify-between gap-4 p-5">
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center justify-between gap-x-6 gap-y-3 p-5">
|
||||
<!-- No flex-1 here: a 0 basis item never triggers the row's
|
||||
flex-wrap, so its shrink-0 chips overprinted the Scan/
|
||||
Manage buttons on phones. Content-sized block + min-w-0
|
||||
truncates via the h3; justify-between still splits the
|
||||
columns on desktop. -->
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("library", "h-5 w-5")
|
||||
</span>
|
||||
@@ -211,12 +216,12 @@ templ LibraryList(user User, libraries []LibraryData, users []User) {
|
||||
<p class="text-sm truncate" style="color: var(--text-secondary)">{ library.Description }</p>
|
||||
}
|
||||
</div>
|
||||
<span class="chip shrink-0">
|
||||
<span class="chip shrink-0 hidden sm:inline-flex">
|
||||
@Icon("tag", "h-3 w-3")
|
||||
{ library.TypeName }
|
||||
</span>
|
||||
if library.FolderCount > 0 {
|
||||
<span class="chip shrink-0">
|
||||
<span class="chip shrink-0 hidden sm:inline-flex">
|
||||
@Icon("folder", "h-3 w-3")
|
||||
{ library.FolderCount } folders
|
||||
</span>
|
||||
|
||||
@@ -95,7 +95,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</a></p><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Files missing from disk for two consecutive scans. Reading history is kept unless purged; items return automatically if their files come back.</p></div><div class=\"flex items-center gap-2\"><a href=\"/admin/archived\" class=\"btn btn-secondary\">View Archived</a> <button type=\"button\" @click=\"purgeArchivedItems()\" class=\"btn btn-secondary\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</a></p><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Files missing from disk for two consecutive scans. Reading history is kept unless purged; items return automatically if their files come back.</p></div><div class=\"flex flex-wrap items-center gap-2\"><a href=\"/admin/archived\" class=\"btn btn-secondary\">View Archived</a> <button type=\"button\" @click=\"purgeArchivedItems()\" class=\"btn btn-secondary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -288,7 +288,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, library := range libraries {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<div class=\"card overflow-hidden\"><div class=\"flex items-center justify-between gap-4 p-5\"><div class=\"flex items-center gap-3 min-w-0 flex-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<div class=\"card overflow-hidden\"><div class=\"flex flex-wrap items-center justify-between gap-x-6 gap-y-3 p-5\"><!-- No flex-1 here: a 0 basis item never triggers the row's\n\t\t\t\t\t\t flex-wrap, so its shrink-0 chips overprinted the Scan/\n\t\t\t\t\t\t Manage buttons on phones. Content-sized block + min-w-0\n\t\t\t\t\t\t truncates via the h3; justify-between still splits the\n\t\t\t\t\t\t columns on desktop. --><div class=\"flex items-center gap-3 min-w-0\"><span class=\"grid place-items-center h-10 w-10 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -303,7 +303,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(library.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 209, Col: 92}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 214, Col: 92}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -321,7 +321,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(library.Description)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 211, Col: 95}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 216, Col: 95}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -332,7 +332,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div><span class=\"chip shrink-0\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div><span class=\"chip shrink-0 hidden sm:inline-flex\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -343,7 +343,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(library.TypeName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 216, Col: 26}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 221, Col: 26}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -354,7 +354,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if library.FolderCount > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<span class=\"chip shrink-0\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<span class=\"chip shrink-0 hidden sm:inline-flex\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -365,7 +365,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(library.FolderCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 221, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 226, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -383,7 +383,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + library.ID + "/scan")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 227, Col: 58}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 232, Col: 58}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -404,7 +404,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 237, Col: 32}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 242, Col: 32}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -425,7 +425,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("library-panel-" + library.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 246, Col: 44}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 251, Col: 44}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -500,7 +500,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(folder.FolderPath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 268, Col: 99}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 273, Col: 99}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -513,7 +513,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + libraryID + "/scan")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 271, Col: 57}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 276, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -526,7 +526,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(hxValsFolderScan(folder.FolderPath))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 272, Col: 53}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 277, Col: 53}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -547,7 +547,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 283, Col: 62}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 288, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -560,7 +560,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(hxValsFolderDelete(folder.FolderPath))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 284, Col: 55}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 289, Col: 55}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -573,7 +573,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 285, Col: 49}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 290, Col: 49}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -604,7 +604,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 295, Col: 80}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 300, Col: 80}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -617,7 +617,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 295, Col: 124}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 300, Col: 124}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -630,7 +630,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 299, Col: 37}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 304, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -643,7 +643,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var26 string
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 306, Col: 32}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 311, Col: 32}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -656,7 +656,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var27 string
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 307, Col: 52}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 312, Col: 52}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -713,7 +713,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var28 string
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/visibility")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 336, Col: 63}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 341, Col: 63}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -726,7 +726,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(`{"user_id": "` + u.ID + `"}`)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 337, Col: 47}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 342, Col: 47}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -739,7 +739,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 339, Col: 49}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 344, Col: 49}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -752,7 +752,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var31 string
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 342, Col: 76}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 347, Col: 76}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -765,7 +765,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var32 string
|
||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(u.Email)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 343, Col: 75}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 348, Col: 75}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -793,7 +793,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var33 templ.SafeURL
|
||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs("/admin/libraries/" + libraryID + "/issues")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 352, Col: 57}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 357, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -820,7 +820,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var34 string
|
||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(issueCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 357, Col: 24}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 362, Col: 24}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -847,7 +847,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var35 string
|
||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 366, Col: 28}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 371, Col: 28}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -860,7 +860,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var36 string
|
||||
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 367, Col: 33}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 372, Col: 33}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -873,7 +873,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var37 string
|
||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Description)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 368, Col: 40}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 373, Col: 40}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -894,7 +894,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var38 string
|
||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 376, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 381, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -907,7 +907,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var39 string
|
||||
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 377, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 382, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -965,7 +965,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
||||
var templ_7745c5c3_Var41 string
|
||||
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(currentPath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 392, Col: 89}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 397, Col: 89}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -983,7 +983,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
||||
var templ_7745c5c3_Var42 string
|
||||
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + parentPath + "&target_input=" + targetInput + "&library_id=" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 398, Col: 117}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 403, Col: 117}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1014,7 +1014,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
||||
var templ_7745c5c3_Var43 string
|
||||
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + entry.Path + "&target_input=" + targetInput + "&library_id=" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 411, Col: 118}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 416, Col: 118}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1035,7 +1035,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
||||
var templ_7745c5c3_Var44 string
|
||||
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 416, Col: 40}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 421, Col: 40}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1053,7 +1053,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
||||
var templ_7745c5c3_Var45 string
|
||||
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(targetInput)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 423, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 428, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1066,7 +1066,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
||||
var templ_7745c5c3_Var46 string
|
||||
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 424, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 429, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -48,7 +48,7 @@ templ AdminPodcasts(user User, feeds []AdminPodcastFeedData, totalFeeds int, tot
|
||||
if len(feeds) == 0 {
|
||||
<div class="card p-6 text-center text-sm" style="color: var(--text-secondary)">No podcast feeds exist yet.</div>
|
||||
} else {
|
||||
<div class="oc-table card">
|
||||
<div class="oc-table card overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-xs uppercase tracking-wide" style="color: var(--text-secondary); border-bottom: 1px solid var(--border);">
|
||||
|
||||
@@ -155,7 +155,7 @@ func AdminPodcasts(user User, feeds []AdminPodcastFeedData, totalFeeds int, tota
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"oc-table card\"><table class=\"w-full text-sm\"><thead><tr class=\"text-left text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary); border-bottom: 1px solid var(--border);\"><th class=\"px-4 py-3\">Feed</th><th class=\"px-4 py-3\">Library</th><th class=\"px-4 py-3\">Subscribers</th><th class=\"px-4 py-3 text-right\">Downloaded</th><th class=\"px-4 py-3 text-right\">Disk</th><th class=\"px-4 py-3\">Last fetch</th><th class=\"px-4 py-3 text-right\">Actions</th></tr></thead> <tbody>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"oc-table card overflow-x-auto\"><table class=\"w-full text-sm\"><thead><tr class=\"text-left text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary); border-bottom: 1px solid var(--border);\"><th class=\"px-4 py-3\">Feed</th><th class=\"px-4 py-3\">Library</th><th class=\"px-4 py-3\">Subscribers</th><th class=\"px-4 py-3 text-right\">Downloaded</th><th class=\"px-4 py-3 text-right\">Disk</th><th class=\"px-4 py-3\">Last fetch</th><th class=\"px-4 py-3 text-right\">Actions</th></tr></thead> <tbody>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -495,7 +514,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
||||
</div>
|
||||
|
||||
<!-- Settings strip: Skip ▾ · Speed ▾ · Bookmark · Volume ▾ -->
|
||||
<div class="w-full max-w-xl flex items-center justify-center gap-2">
|
||||
<div class="w-full max-w-xl flex flex-wrap items-center justify-center gap-2">
|
||||
<div class="relative" @click.outside="skipOpen = false">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -394,67 +394,116 @@ 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\">")
|
||||
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(ResumeRewindLabel(secs))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, 36, "</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
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(metadata.CoverImagePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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, 39, "\" alt=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var24 string
|
||||
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: 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, 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, 36, "<span class=\"text-5xl flex items-center justify-center h-full\" style=\"color: var(--text-secondary);\">🎧</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 = 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, 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
|
||||
}
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
|
||||
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: 278, Col: 117}
|
||||
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_Var22))
|
||||
_, 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, 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
|
||||
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, 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);\">")
|
||||
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
|
||||
}
|
||||
@@ -462,12 +511,12 @@ 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, 40, "</button> ")
|
||||
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, 41, "<span class=\"rounded-full p-2.5 opacity-30 cursor-not-allowed\" aria-disabled=\"true\" title=\"No previous episode\">")
|
||||
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
|
||||
}
|
||||
@@ -475,13 +524,13 @@ 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, 42, "</span> ")
|
||||
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, 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, 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
|
||||
}
|
||||
@@ -489,44 +538,44 @@ 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, 44, "</button> ")
|
||||
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, 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). -->")
|
||||
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, 46, "<button type=\"button\" @click=\"goToNextEpisode(false)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"")
|
||||
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_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("Next episode: " + metadata.PodcastSequence.NextTitle)
|
||||
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: 471, Col: 74}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 490, Col: 74}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||
_, 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, 47, "\" title=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\" title=\"")
|
||||
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)
|
||||
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: 472, Col: 61}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 491, Col: 61}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
||||
_, 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, 48, "\" style=\"color: var(--text-primary);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" style=\"color: var(--text-primary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -534,12 +583,12 @@ 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, 49, "</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, 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, 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
|
||||
}
|
||||
@@ -547,13 +596,13 @@ 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, 51, "</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</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, 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
|
||||
}
|
||||
@@ -561,12 +610,12 @@ 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, 58, "</button>")
|
||||
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
|
||||
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 flex-wrap 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></butLine truncated
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -659,8 +659,8 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-6 border-t flex-shrink-0" style="border-color: var(--border);">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-wrap items-center justify-between gap-x-6 gap-y-3 p-6 border-t flex-shrink-0" style="border-color: var(--border);">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
@click="rescanBook()"
|
||||
:disabled="rescanning"
|
||||
|
||||
@@ -1477,7 +1477,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "\" readonly class=\"input opacity-60 font-mono text-xs\"></div></div></div></div></div><div class=\"flex items-center justify-between p-6 border-t flex-shrink-0\" style=\"border-color: var(--border);\"><div class=\"flex items-center gap-2\"><button @click=\"rescanBook()\" :disabled=\"rescanning\" class=\"btn btn-secondary\"><span x-show=\"!rescanning\" class=\"inline-flex items-center gap-2\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "\" readonly class=\"input opacity-60 font-mono text-xs\"></div></div></div></div></div><div class=\"flex flex-wrap items-center justify-between gap-x-6 gap-y-3 p-6 border-t flex-shrink-0\" style=\"border-color: var(--border);\"><div class=\"flex flex-wrap items-center gap-2\"><button @click=\"rescanBook()\" :disabled=\"rescanning\" class=\"btn btn-secondary\"><span x-show=\"!rescanning\" class=\"inline-flex items-center gap-2\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -283,7 +284,7 @@ templ BookShelf(
|
||||
<div id="filter-save-error" style="display: none; color: var(--status-danger); padding: 0.75rem; border-radius: 0.5rem; margin-top: 0.75rem;"></div>
|
||||
<input type="hidden" name="resource_type" value="media-items"/>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<button type="button" @click="showSaveModal = false" class="btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-secondary">Save Filter</button>
|
||||
<!-- Item 3.5: the bridge — the current tree becomes a live
|
||||
|
||||
@@ -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 {
|
||||
@@ -562,7 +562,7 @@ func BookShelf(
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</button></div><form id=\"save-filter-form\" hx-post=\"/api/saved-filters\" hx-target=\"#saved-filters-list\" hx-swap=\"beforeend\" hx-include=\"#filter-form\" @htmx:afterRequest=\"if(event.detail.xhr.status < 400) { afterFilterSave() }\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Filter Name</label> <input type=\"text\" id=\"save-filter-name\" name=\"filter_name\" placeholder=\"My Custom Filter\" class=\"input\" required><div id=\"filter-save-error\" style=\"display: none; color: var(--status-danger); padding: 0.75rem; border-radius: 0.5rem; margin-top: 0.75rem;\"></div><input type=\"hidden\" name=\"resource_type\" value=\"media-items\"></div><div class=\"flex justify-end gap-2\"><button type=\"button\" @click=\"showSaveModal = false\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-secondary\">Save Filter</button><!-- Item 3.5: the bridge — the current tree becomes a live\n\t\t\t\t\t\t\t smart collection (the qf engine drives it everywhere:\n\t\t\t\t\t\t\t shelf, collections, play chains). --><button type=\"button\" @click=\"saveFilterAsCollection()\" class=\"btn btn-primary\">Save as Collection</button></div><input type=\"hidden\" name=\"limit\" value=\"50\"> <input type=\"hidden\" name=\"offset\" value=\"0\"></form></div></div></body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</button></div><form id=\"save-filter-form\" hx-post=\"/api/saved-filters\" hx-target=\"#saved-filters-list\" hx-swap=\"beforeend\" hx-include=\"#filter-form\" @htmx:afterRequest=\"if(event.detail.xhr.status < 400) { afterFilterSave() }\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Filter Name</label> <input type=\"text\" id=\"save-filter-name\" name=\"filter_name\" placeholder=\"My Custom Filter\" class=\"input\" required><div id=\"filter-save-error\" style=\"display: none; color: var(--status-danger); padding: 0.75rem; border-radius: 0.5rem; margin-top: 0.75rem;\"></div><input type=\"hidden\" name=\"resource_type\" value=\"media-items\"></div><div class=\"flex flex-wrap justify-end gap-2\"><button type=\"button\" @click=\"showSaveModal = false\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-secondary\">Save Filter</button><!-- Item 3.5: the bridge — the current tree becomes a live\n\t\t\t\t\t\t\t smart collection (the qf engine drives it everywhere:\n\t\t\t\t\t\t\t shelf, collections, play chains). --><button type=\"button\" @click=\"saveFilterAsCollection()\" class=\"btn btn-primary\">Save as Collection</button></div><input type=\"hidden\" name=\"limit\" value=\"50\"> <input type=\"hidden\" name=\"offset\" value=\"0\"></form></div></div></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package templates
|
||||
|
||||
templ CollectionModal(collection CollectionData) {
|
||||
<div x-data="collections" class="fixed inset-0 z-[65] flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
|
||||
<div class="card p-6 w-full max-w-md" style="box-shadow: var(--shadow-pop);">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div x-data="collections" class="fixed inset-0 z-[65] flex items-stretch justify-end sm:items-center sm:justify-center sm:p-4" style="background-color: var(--surface-overlay);">
|
||||
<div class="app-modal-panel card p-5 sm:p-6 sm:max-w-md">
|
||||
<div class="sticky top-0 z-10 flex justify-between items-center -mx-5 px-5 pb-3 mb-5 border-b sm:-mx-6 sm:px-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("folder", "h-5 w-5")
|
||||
@@ -85,7 +85,7 @@ templ CollectionModal(collection CollectionData) {
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<div class="sticky bottom-0 z-10 flex justify-end space-x-3 -mx-5 px-5 py-3 border-t sm:-mx-6 sm:px-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<button type="button" @click="closeCollectionModal($el)" class="btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Update Collection</button>
|
||||
</div>
|
||||
@@ -185,7 +185,7 @@ templ CollectionModal(collection CollectionData) {
|
||||
</div>
|
||||
<input type="hidden" name="color" id="collection-color" value="blue"/>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<div class="sticky bottom-0 z-10 flex justify-end space-x-3 -mx-5 px-5 py-3 border-t sm:-mx-6 sm:px-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<button type="button" @click="closeCollectionModal($el)" class="btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Create Collection</button>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div x-data=\"collections\" class=\"fixed inset-0 z-[65] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-10 w-10 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div x-data=\"collections\" class=\"fixed inset-0 z-[65] flex items-stretch justify-end sm:items-center sm:justify-center sm:p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"app-modal-panel card p-5 sm:p-6 sm:max-w-md\"><div class=\"sticky top-0 z-10 flex justify-between items-center -mx-5 px-5 pb-3 mb-5 border-b sm:-mx-6 sm:px-6\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-10 w-10 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -163,7 +163,7 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " class=\"h-4 w-4 accent-current\"> <span>Show Up Next strip</span></label></div></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal($el)\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Update Collection</button></div></form>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " class=\"h-4 w-4 accent-current\"> <span>Show Up Next strip</span></label></div></div><div class=\"sticky bottom-0 z-10 flex justify-end space-x-3 -mx-5 px-5 py-3 border-t sm:-mx-6 sm:px-6\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><button type=\"button\" @click=\"closeCollectionModal($el)\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Update Collection</button></div></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -181,7 +181,7 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\"><div id=\"icon-grid\" class=\"grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"></div></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Color</label><div class=\"flex gap-2\"><button type=\"button\" @click=\"selectColor('blue')\" data-color=\"blue\" class=\"w-8 h-8 rounded-full color-option bg-blue-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-blue-400\" aria-label=\"Blue\"></button> <button type=\"button\" @click=\"selectColor('red')\" data-color=\"red\" class=\"w-8 h-8 rounded-full color-option bg-red-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-red-400\" aria-label=\"Red\"></button> <button type=\"button\" @click=\"selectColor('yellow')\" data-color=\"yellow\" class=\"w-8 h-8 rounded-full color-option bg-yellow-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-yellow-400\" aria-label=\"Yellow\"></button> <button type=\"button\" @click=\"selectColor('green')\" data-color=\"green\" class=\"w-8 h-8 rounded-full color-option bg-green-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-green-400\" aria-label=\"Green\"></button> <button type=\"button\" @click=\"selectColor('purple')\" data-color=\"purple\" class=\"w-8 h-8 rounded-full color-option bg-purple-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-purple-400\" aria-label=\"Purple\"></button></div><input type=\"hidden\" name=\"color\" id=\"collection-color\" value=\"blue\"></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal($el)\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Create Collection</button></div></form>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\"><div id=\"icon-grid\" class=\"grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"></div></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Color</label><div class=\"flex gap-2\"><button type=\"button\" @click=\"selectColor('blue')\" data-color=\"blue\" class=\"w-8 h-8 rounded-full color-option bg-blue-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-blue-400\" aria-label=\"Blue\"></button> <button type=\"button\" @click=\"selectColor('red')\" data-color=\"red\" class=\"w-8 h-8 rounded-full color-option bg-red-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-red-400\" aria-label=\"Red\"></button> <button type=\"button\" @click=\"selectColor('yellow')\" data-color=\"yellow\" class=\"w-8 h-8 rounded-full color-option bg-yellow-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-yellow-400\" aria-label=\"Yellow\"></button> <button type=\"button\" @click=\"selectColor('green')\" data-color=\"green\" class=\"w-8 h-8 rounded-full color-option bg-green-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-green-400\" aria-label=\"Green\"></button> <button type=\"button\" @click=\"selectColor('purple')\" data-color=\"purple\" class=\"w-8 h-8 rounded-full color-option bg-purple-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-purple-400\" aria-label=\"Purple\"></button></div><input type=\"hidden\" name=\"color\" id=\"collection-color\" value=\"blue\"></div><div class=\"sticky bottom-0 z-10 flex justify-end space-x-3 -mx-5 px-5 py-3 border-t sm:-mx-6 sm:px-6\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><button type=\"button\" @click=\"closeCollectionModal($el)\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Create Collection</button></div></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ templ CollectionRules(user User, collection CollectionData, fieldsJSON string, t
|
||||
<span class="grid place-items-center h-12 w-12 rounded-2xl shrink-0 text-2xl" style={ "background-color: var(--accent-muted); color: " + collection.Color }>{ collection.Icon }</span>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ collection.Name }</h1>
|
||||
<p class="flex items-center gap-2" style="color: var(--text-secondary)">
|
||||
<p class="flex flex-wrap items-center gap-2" style="color: var(--text-secondary)">
|
||||
<span class="badge text-xs" style="background-color: var(--accent-muted); color: var(--accent);">⚡ Smart</span>
|
||||
if scope == "episodes" {
|
||||
Episodes scope — membership recomputes live from the tree below.
|
||||
@@ -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);">
|
||||
|
||||
@@ -152,7 +152,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, 11, "</h1><p class=\"flex items-center gap-2\" style=\"color: var(--text-secondary)\"><span class=\"badge text-xs\" style=\"background-color: var(--accent-muted); color: var(--accent);\">⚡ Smart</span> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</h1><p class=\"flex flex-wrap items-center gap-2\" style=\"color: var(--text-secondary)\"><span class=\"badge text-xs\" style=\"background-color: var(--accent-muted); color: var(--accent);\">⚡ Smart</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -60,15 +60,15 @@ templ ConflictsListContent(user User, conflicts []handlers.ConflictDetailRespons
|
||||
templ ConflictStatsBar(total int, unresolved int) {
|
||||
<div class="stat-card text-center py-3">
|
||||
<div class="text-2xl font-bold" style="color: var(--text-primary);">{ total }</div>
|
||||
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Total</div>
|
||||
<div class="text-xs uppercase tracking-normal" style="color: var(--text-secondary);">Total</div>
|
||||
</div>
|
||||
<div class="stat-card text-center py-3">
|
||||
<div class="text-2xl font-bold" style="color: var(--status-warning);">{ unresolved }</div>
|
||||
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Unresolved</div>
|
||||
<div class="text-xs uppercase tracking-normal" style="color: var(--text-secondary);">Unresolved</div>
|
||||
</div>
|
||||
<div class="stat-card text-center py-3">
|
||||
<div class="text-2xl font-bold" style="color: var(--status-success);">{ total - unresolved }</div>
|
||||
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Resolved</div>
|
||||
<div class="text-xs uppercase tracking-normal" style="color: var(--text-secondary);">Resolved</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -219,7 +219,7 @@ func ConflictStatsBar(total int, unresolved int) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Total</div></div><div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" style=\"color: var(--status-warning);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div><div class=\"text-xs uppercase tracking-normal\" style=\"color: var(--text-secondary);\">Total</div></div><div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" style=\"color: var(--status-warning);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -232,7 +232,7 @@ func ConflictStatsBar(total int, unresolved int) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Unresolved</div></div><div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" style=\"color: var(--status-success);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div><div class=\"text-xs uppercase tracking-normal\" style=\"color: var(--text-secondary);\">Unresolved</div></div><div class=\"stat-card text-center py-3\"><div class=\"text-2xl font-bold\" style=\"color: var(--status-success);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -245,7 +245,7 @@ func ConflictStatsBar(total int, unresolved int) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div><div class=\"text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary);\">Resolved</div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div><div class=\"text-xs uppercase tracking-normal\" style=\"color: var(--text-secondary);\">Resolved</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ templ CustomSectionBuilder(user User, libraries []LibraryData, errorMessage stri
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="search-results"
|
||||
id="cs-search-results"
|
||||
class="hidden mb-4 card p-3 max-h-60 overflow-y-auto space-y-2"
|
||||
style="background-color: var(--bg-primary);"
|
||||
></div>
|
||||
|
||||
@@ -143,7 +143,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Search</button></div></div><div id=\"search-results\" class=\"hidden mb-4 card p-3 max-h-60 overflow-y-auto space-y-2\" style=\"background-color: var(--bg-primary);\"></div><div class=\"mb-4\"><label class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Selected Books</label><div id=\"selected-books\" class=\"min-h-[60px] p-3 rounded-xl border-2 border-dashed\" style=\"border-color: var(--border-strong); background-color: var(--bg-primary);\"><p class=\"text-sm text-center\" style=\"color: var(--text-secondary);\">No books selected</p></div></div></div><div class=\"card p-6\"><div class=\"flex items-center justify-between mb-4\"><h2 class=\"text-lg font-semibold flex items-center gap-2\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Search</button></div></div><div id=\"cs-search-results\" class=\"hidden mb-4 card p-3 max-h-60 overflow-y-auto space-y-2\" style=\"background-color: var(--bg-primary);\"></div><div class=\"mb-4\"><label class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Selected Books</label><div id=\"selected-books\" class=\"min-h-[60px] p-3 rounded-xl border-2 border-dashed\" style=\"border-color: var(--border-strong); background-color: var(--bg-primary);\"><p class=\"text-sm text-center\" style=\"color: var(--text-secondary);\">No books selected</p></div></div></div><div class=\"card p-6\"><div class=\"flex items-center justify-between mb-4\"><h2 class=\"text-lg font-semibold flex items-center gap-2\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+12
-12
@@ -70,7 +70,7 @@ templ CollectionCarousel(section handlers.SectionData) {
|
||||
if section.ViewAllURL != "" {
|
||||
<a
|
||||
href={ section.ViewAllURL }
|
||||
class="inline-flex items-center gap-1 text-sm font-medium hover:underline transition-colors"
|
||||
class="inline-flex shrink-0 items-center gap-1 whitespace-nowrap text-sm font-medium hover:underline transition-colors"
|
||||
style="color: var(--accent);"
|
||||
>
|
||||
View All
|
||||
@@ -220,11 +220,11 @@ templ BookCard(item handlers.BookInfo) {
|
||||
templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections []string, itemsPerSection int) {
|
||||
<div
|
||||
id="dashboard-settings-modal"
|
||||
class="hidden fixed inset-0 z-[70] flex items-center justify-center p-4"
|
||||
class="hidden fixed inset-0 z-[70] flex items-stretch justify-end sm:items-center sm:justify-center sm:p-4"
|
||||
style="background-color: var(--surface-overlay);"
|
||||
>
|
||||
<div class="card p-6 w-full max-w-2xl shadow-2xl" style="box-shadow: var(--shadow-pop);">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div class="app-modal-panel card p-5 sm:p-6 sm:max-w-2xl">
|
||||
<div class="sticky top-0 z-10 flex justify-between items-start mb-6 pb-2" style="background-color: var(--bg-secondary);">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Customize Dashboard</h2>
|
||||
<p class="text-sm mt-1" style="color: var(--text-secondary)">Drag to reorder, toggle to show or hide.</p>
|
||||
@@ -237,22 +237,22 @@ templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections
|
||||
<div id="collection-list" class="space-y-2 mb-6">
|
||||
for _, section := range sections {
|
||||
<div
|
||||
class="collection-item flex items-center justify-between p-3 rounded-xl cursor-move select-none card"
|
||||
class="collection-item flex flex-wrap items-center justify-between gap-x-3 gap-y-2 p-3 rounded-xl cursor-move select-none card"
|
||||
data-collection-id={ section.ID }
|
||||
data-is-system={ fmt.Sprintf("%v", section.IsSystem) }
|
||||
draggable="true"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
@Icon("grip", "h-5 w-5 shrink-0")
|
||||
<span class="text-xl">{ section.Icon }</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium" style="color: var(--text-primary)">{ section.Title }</span>
|
||||
<span class="text-xl shrink-0">{ section.Icon }</span>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="font-medium truncate" style="color: var(--text-primary)">{ section.Title }</span>
|
||||
if section.IsSystem {
|
||||
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">System</span>
|
||||
<span class="badge shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">System</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-3 shrink-0 ml-auto">
|
||||
if section.IsSystem {
|
||||
<button
|
||||
data-action="restore-system-collection"
|
||||
@@ -296,7 +296,7 @@ templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections
|
||||
target="items-count-display"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3">
|
||||
<div class="sticky bottom-0 z-10 flex justify-end gap-3 -mx-5 px-5 py-3 border-t sm:-mx-6 sm:px-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||
<button data-action="close-dashboard-settings" class="btn btn-secondary">Cancel</button>
|
||||
<button data-action="save-dashboard-settings" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
|
||||
@@ -256,7 +256,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" class=\"inline-flex items-center gap-1 text-sm font-medium hover:underline transition-colors\" style=\"color: var(--accent);\">View All")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" class=\"inline-flex shrink-0 items-center gap-1 whitespace-nowrap text-sm font-medium hover:underline transition-colors\" style=\"color: var(--accent);\">View All")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -747,7 +747,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
||||
templ_7745c5c3_Var34 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<div id=\"dashboard-settings-modal\" class=\"hidden fixed inset-0 z-[70] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-2xl shadow-2xl\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center mb-6\"><div><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Customize Dashboard</h2><p class=\"text-sm mt-1\" style=\"color: var(--text-secondary)\">Drag to reorder, toggle to show or hide.</p><p class=\"text-xs mt-1 font-medium\" id=\"dashboard-scope-label\" style=\"color: var(--accent)\"></p></div><button data-action=\"close-dashboard-settings\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<div id=\"dashboard-settings-modal\" class=\"hidden fixed inset-0 z-[70] flex items-stretch justify-end sm:items-center sm:justify-center sm:p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"app-modal-panel card p-5 sm:p-6 sm:max-w-2xl\"><div class=\"sticky top-0 z-10 flex justify-between items-start mb-6 pb-2\" style=\"background-color: var(--bg-secondary);\"><div><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Customize Dashboard</h2><p class=\"text-sm mt-1\" style=\"color: var(--text-secondary)\">Drag to reorder, toggle to show or hide.</p><p class=\"text-xs mt-1 font-medium\" id=\"dashboard-scope-label\" style=\"color: var(--accent)\"></p></div><button data-action=\"close-dashboard-settings\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -760,7 +760,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, section := range sections {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<div class=\"collection-item flex items-center justify-between p-3 rounded-xl cursor-move select-none card\" data-collection-id=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<div class=\"collection-item flex flex-wrap items-center justify-between gap-x-3 gap-y-2 p-3 rounded-xl cursor-move select-none card\" data-collection-id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -786,7 +786,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\" draggable=\"true\"><div class=\"flex items-center gap-3\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\" draggable=\"true\"><div class=\"flex items-center gap-3 min-w-0\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -794,27 +794,27 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<span class=\"text-xl\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<span class=\"text-xl shrink-0\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var37 string
|
||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 247, Col: 43}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 247, Col: 52}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</span><div class=\"flex items-center gap-2\"><span class=\"font-medium\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</span><div class=\"flex items-center gap-2 min-w-0\"><span class=\"font-medium truncate\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var38 string
|
||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 249, Col: 84}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 249, Col: 93}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -825,12 +825,12 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if section.IsSystem {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">System</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<span class=\"badge shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">System</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</div></div><div class=\"flex items-center gap-3\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</div></div><div class=\"flex items-center gap-3 shrink-0 ml-auto\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -907,7 +907,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\" class=\"w-full h-2 rounded-lg appearance-none cursor-pointer\" style=\"background-color: var(--border-strong);\" data-input-action=\"update-items-count\" target=\"items-count-display\"></div><div class=\"flex justify-end gap-3\"><button data-action=\"close-dashboard-settings\" class=\"btn btn-secondary\">Cancel</button> <button data-action=\"save-dashboard-settings\" class=\"btn btn-primary\">Save Changes</button></div></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\" class=\"w-full h-2 rounded-lg appearance-none cursor-pointer\" style=\"background-color: var(--border-strong);\" data-input-action=\"update-items-count\" target=\"items-count-display\"></div><div class=\"sticky bottom-0 z-10 flex justify-end gap-3 -mx-5 px-5 py-3 border-t sm:-mx-6 sm:px-6\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><button data-action=\"close-dashboard-settings\" class=\"btn btn-secondary\">Cancel</button> <button data-action=\"save-dashboard-settings\" class=\"btn btn-primary\">Save Changes</button></div></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -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,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'">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -120,21 +120,25 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
|
||||
<!-- Order override (D5): per-user; refreshes the episode list -->
|
||||
<label class="text-sm ml-2" for="podcast-order-select" style="color: var(--text-secondary)">Order</label>
|
||||
<select
|
||||
id="podcast-order-select"
|
||||
name="order"
|
||||
class="input w-auto py-1.5 cursor-pointer"
|
||||
hx-put={ "/api/podcasts/feeds/" + data.Feed.ID + "/order" }
|
||||
hx-target="#episode-list"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<option value="native" selected?={ data.Order == "native" }>Feed order</option>
|
||||
<option value="reverse" selected?={ data.Order == "reverse" }>Feed order (reversed)</option>
|
||||
<option value="oldest" selected?={ data.Order == "oldest" }>Oldest first (pub date)</option>
|
||||
<option value="newest" selected?={ data.Order == "newest" }>Newest first (pub date)</option>
|
||||
</select>
|
||||
<!-- Order override (D5): per-user; refreshes the episode list.
|
||||
Label+select travel as a pair so flex-wrap can never
|
||||
separate them on narrow screens. -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<label class="text-sm" for="podcast-order-select" style="color: var(--text-secondary)">Order</label>
|
||||
<select
|
||||
id="podcast-order-select"
|
||||
name="order"
|
||||
class="input w-auto py-1.5 cursor-pointer"
|
||||
hx-put={ "/api/podcasts/feeds/" + data.Feed.ID + "/order" }
|
||||
hx-target="#episode-list"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<option value="native" selected?={ data.Order == "native" }>Feed order</option>
|
||||
<option value="reverse" selected?={ data.Order == "reverse" }>Feed order (reversed)</option>
|
||||
<option value="oldest" selected?={ data.Order == "oldest" }>Oldest first (pub date)</option>
|
||||
<option value="newest" selected?={ data.Order == "newest" }>Newest first (pub date)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Serial suggestion (D5, §0 item 3 rename): one-click,
|
||||
never silent. "reverse" sorts by FEED POSITION DESC —
|
||||
@@ -152,21 +156,23 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Auto-download policy (D6) -->
|
||||
<label class="text-sm ml-2" for="podcast-autodl-select" style="color: var(--text-secondary)">Auto-download</label>
|
||||
@PodcastAutoDownloadSelect(data.Feed)
|
||||
if data.Feed.AutoDownload == "newest" {
|
||||
<input
|
||||
type="number"
|
||||
id="podcast-autodl-count"
|
||||
name="auto_download_count"
|
||||
min="1"
|
||||
max="100"
|
||||
value={ podcastInt(data.Feed.AutoDownloadCount) }
|
||||
class="input w-20 py-1.5"
|
||||
aria-label="Newest N episodes"
|
||||
/>
|
||||
}
|
||||
<!-- Auto-download policy (D6) — label+controls as one pair -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<label class="text-sm" for="podcast-autodl-select" style="color: var(--text-secondary)">Auto-download</label>
|
||||
@PodcastAutoDownloadSelect(data.Feed)
|
||||
if data.Feed.AutoDownload == "newest" {
|
||||
<input
|
||||
type="number"
|
||||
id="podcast-autodl-count"
|
||||
name="auto_download_count"
|
||||
min="1"
|
||||
max="100"
|
||||
value={ podcastInt(data.Feed.AutoDownloadCount) }
|
||||
class="input w-20 py-1.5"
|
||||
aria-label="Newest N episodes"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Unsubscribe (issues #4/#7): the sole subscriber who is
|
||||
an admin gets the three-choice modal (unsubscribe /
|
||||
|
||||
@@ -359,14 +359,14 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<span>Refresh</span></button><!-- Order override (D5): per-user; refreshes the episode list --><label class=\"text-sm ml-2\" for=\"podcast-order-select\" style=\"color: var(--text-secondary)\">Order</label> <select id=\"podcast-order-select\" name=\"order\" class=\"input w-auto py-1.5 cursor-pointer\" hx-put=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<span>Refresh</span></button><!-- Order override (D5): per-user; refreshes the episode list.\n\t\t\t\t\t Label+select travel as a pair so flex-wrap can never\n\t\t\t\t\t separate them on narrow screens. --><div class=\"flex items-center gap-1.5\"><label class=\"text-sm\" for=\"podcast-order-select\" style=\"color: var(--text-secondary)\">Order</label> <select id=\"podcast-order-select\" name=\"order\" class=\"input w-auto py-1.5 cursor-pointer\" hx-put=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID + "/order")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 129, Col: 63}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 132, Col: 64}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -412,7 +412,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">Newest first (pub date)</option></select><!-- Serial suggestion (D5, §0 item 3 rename): one-click,\n\t\t\t\t\t never silent. \"reverse\" sorts by FEED POSITION DESC —\n\t\t\t\t\t no pub dates involved, so messy dates can't break it. -->")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">Newest first (pub date)</option></select></div><!-- Serial suggestion (D5, §0 item 3 rename): one-click,\n\t\t\t\t\t never silent. \"reverse\" sorts by FEED POSITION DESC —\n\t\t\t\t\t no pub dates involved, so messy dates can't break it. -->")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -424,7 +424,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID + "/order")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 145, Col: 64}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 149, Col: 64}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -435,7 +435,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<!-- Auto-download policy (D6) --><label class=\"text-sm ml-2\" for=\"podcast-autodl-select\" style=\"color: var(--text-secondary)\">Auto-download</label>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<!-- Auto-download policy (D6) — label+controls as one pair --><div class=\"flex items-center gap-1.5\"><label class=\"text-sm\" for=\"podcast-autodl-select\" style=\"color: var(--text-secondary)\">Auto-download</label>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -451,7 +451,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(podcastInt(data.Feed.AutoDownloadCount))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 165, Col: 54}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 170, Col: 55}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -462,7 +462,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<!-- Unsubscribe (issues #4/#7): the sole subscriber who is\n\t\t\t\t\t an admin gets the three-choice modal (unsubscribe /\n\t\t\t\t\t remove feed / cancel); everyone else gets a plain\n\t\t\t\t\t confirm — file retention is admin knowledge only. -->")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</div><!-- Unsubscribe (issues #4/#7): the sole subscriber who is\n\t\t\t\t\t an admin gets the three-choice modal (unsubscribe /\n\t\t\t\t\t remove feed / cancel); everyone else gets a plain\n\t\t\t\t\t confirm — file retention is admin knowledge only. -->")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -479,7 +479,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID + "/subscription")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 182, Col: 74}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 188, Col: 74}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -497,7 +497,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID + "/subscription")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 191, Col: 74}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 197, Col: 74}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -516,7 +516,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 203, Col: 56}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 209, Col: 56}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -555,7 +555,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(data.GapCount) + " episode(s) not downloaded in the middle of this feed")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 218, Col: 160}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 224, Col: 160}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -578,7 +578,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID + "/episodes/delete-files")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 258, Col: 82}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 264, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -651,7 +651,7 @@ func PodcastEpisodeList(data PodcastDetailData) templ.Component {
|
||||
var templ_7745c5c3_Var27 string
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue("/podcasts/" + data.Feed.ID + "/episodes")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 294, Col: 52}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 300, Col: 52}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -721,7 +721,7 @@ func PodcastEpisodeListPolling(data PodcastDetailData) templ.Component {
|
||||
var templ_7745c5c3_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue("/podcasts/" + data.Feed.ID + "/episodes")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 319, Col: 52}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 325, Col: 52}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -773,7 +773,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var31 string
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(boolAttr(ep.Downloaded))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 336, Col: 43}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 342, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -786,7 +786,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var32 string
|
||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(podcastPct(ep.ListenPct))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 337, Col: 44}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 343, Col: 44}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -799,7 +799,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var33 string
|
||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(strings.ToLower(ep.Title + " " + ep.Description))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 338, Col: 69}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 344, Col: 69}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -818,7 +818,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var34 string
|
||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(ep.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 351, Col: 18}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 357, Col: 18}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -831,7 +831,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var35 string
|
||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue("Select " + ep.Title + " for file deletion")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 353, Col: 61}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 359, Col: 61}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -855,7 +855,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var36 string
|
||||
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(ep.Position))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 361, Col: 28}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 367, Col: 28}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -868,7 +868,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var37 templ.SafeURL
|
||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinURLErrs(ep.PlayURL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 365, Col: 24}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 371, Col: 24}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -881,7 +881,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var38 string
|
||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(ep.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 365, Col: 129}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 371, Col: 129}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -899,7 +899,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var39 string
|
||||
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(ep.StateBadge())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 367, Col: 165}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 373, Col: 165}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -917,7 +917,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var40 string
|
||||
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(ep.StateBadge())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 369, Col: 134}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 375, Col: 134}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -935,7 +935,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var41 string
|
||||
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(ep.StateBadge())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 371, Col: 133}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 377, Col: 133}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -958,7 +958,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var42 string
|
||||
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(ep.DownloadError)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 375, Col: 97}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 381, Col: 97}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -982,7 +982,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var43 string
|
||||
templ_7745c5c3_Var43, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(progressWidth(ep.ProgressPct) + " background-color: var(--accent);")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 381, Col: 130}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 387, Col: 130}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -995,7 +995,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var44 string
|
||||
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(FormatEpisodeSize(ep.ProgressReceived) + " / " + FormatEpisodeSize(ep.ProgressTotal))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 384, Col: 93}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 390, Col: 93}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1013,7 +1013,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var45 string
|
||||
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(FormatEpisodeSize(ep.ProgressReceived))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 390, Col: 122}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 396, Col: 122}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1036,7 +1036,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var46 string
|
||||
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(ep.Date)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 395, Col: 13}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 401, Col: 13}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1050,7 +1050,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var47 string
|
||||
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + ep.Duration)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 397, Col: 27}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 403, Col: 27}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1065,7 +1065,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var48 string
|
||||
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + ep.Size)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 400, Col: 23}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 406, Col: 23}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1084,7 +1084,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var49 templ.SafeURL
|
||||
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinURLErrs(ep.PlayURL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 405, Col: 23}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 411, Col: 23}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1145,7 +1145,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var52 string
|
||||
templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + ep.FeedID + "/episodes/" + ep.ID + "/download")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 417, Col: 85}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 423, Col: 85}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var52)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1158,7 +1158,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var53 string
|
||||
templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.ResolveAttributeValue(downloadButtonTitle(ep.DownloadState))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 419, Col: 49}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 425, Col: 49}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var53)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1179,7 +1179,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) templ.Component {
|
||||
var templ_7745c5c3_Var54 string
|
||||
templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(downloadButtonLabel(ep.DownloadState))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 422, Col: 49}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 428, Col: 49}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1258,7 +1258,7 @@ func PodcastAutoDownloadSelect(feed PodcastFeedCard) templ.Component {
|
||||
var templ_7745c5c3_Var56 string
|
||||
templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + feed.ID + "/subscription")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 466, Col: 61}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 472, Col: 61}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var56)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1334,7 +1334,7 @@ func PodcastOrderSelectOOB(feedID string, order string) templ.Component {
|
||||
var templ_7745c5c3_Var58 string
|
||||
templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + feedID + "/order")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 486, Col: 53}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 492, Col: 53}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var58)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1418,7 +1418,7 @@ func PodcastAutoDownloadSelectOOB(feed PodcastFeedCard) templ.Component {
|
||||
var templ_7745c5c3_Var60 string
|
||||
templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + feed.ID + "/subscription")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 505, Col: 61}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 511, Col: 61}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var60)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1494,7 +1494,7 @@ func PodcastUnsubscribeModal(data PodcastDetailData) templ.Component {
|
||||
var templ_7745c5c3_Var62 string
|
||||
templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(data.Feed.DisplayTitle())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 530, Col: 112}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 536, Col: 112}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1507,7 +1507,7 @@ func PodcastUnsubscribeModal(data PodcastDetailData) templ.Component {
|
||||
var templ_7745c5c3_Var63 string
|
||||
templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID + "/subscription")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 536, Col: 72}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 542, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var63)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1520,7 +1520,7 @@ func PodcastUnsubscribeModal(data PodcastDetailData) templ.Component {
|
||||
var templ_7745c5c3_Var64 string
|
||||
templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + data.Feed.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 545, Col: 54}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `podcast_detail.templ`, Line: 551, Col: 54}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -104,9 +104,9 @@ templ Progress(user User, progressData []handlers.ProgressWithMedia, errorMessag
|
||||
</div>
|
||||
}
|
||||
if item.EpubCFI != "" {
|
||||
<div class="mt-2 flex items-center gap-2 text-xs" style="color: var(--text-secondary);">
|
||||
<div class="mt-2 flex items-center gap-2 text-xs min-w-0" style="color: var(--text-secondary);">
|
||||
@Icon("bookmark", "h-3.5 w-3.5")
|
||||
<span>CFI: { item.EpubCFI }</span>
|
||||
<span class="min-w-0 break-all">CFI: { item.EpubCFI }</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -308,7 +308,7 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
|
||||
}
|
||||
}
|
||||
if item.EpubCFI != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"mt-2 flex items-center gap-2 text-xs\" style=\"color: var(--text-secondary);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"mt-2 flex items-center gap-2 text-xs min-w-0\" style=\"color: var(--text-secondary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -316,14 +316,14 @@ func Progress(user User, progressData []handlers.ProgressWithMedia, errorMessage
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<span>CFI: ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<span class=\"min-w-0 break-all\">CFI: ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(item.EpubCFI)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 109, Col: 36}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `progress.templ`, Line: 109, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -29,7 +29,7 @@ templ Stats(user User) {
|
||||
<!-- READING tab — behavior stats from sync pushes (all libraries) -->
|
||||
<div id="stats-reading-section">
|
||||
<div class="card p-4 mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex gap-2" id="stats-range-selector">
|
||||
<div class="flex flex-wrap gap-2" id="stats-range-selector">
|
||||
<button data-range="7" @click="selectRange('7')" class="stats-range-btn btn btn-secondary text-sm">Last 7 days</button>
|
||||
<button data-range="30" @click="selectRange('30')" class="stats-range-btn btn btn-secondary text-sm">30 days</button>
|
||||
<button data-range="90" @click="selectRange('90')" class="stats-range-btn btn btn-secondary text-sm">90 days</button>
|
||||
|
||||
@@ -80,7 +80,7 @@ func Stats(user User) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, ") · totals derive from your devices' sync pushes, not a stopwatch.</p><div class=\"flex gap-2 mt-4\" id=\"stats-tab-row\"><button data-tab=\"reading\" @click=\"selectTab('reading')\" class=\"stats-tab-btn btn btn-secondary text-sm\">Reading</button> <button data-tab=\"library\" @click=\"selectTab('library')\" class=\"stats-tab-btn btn btn-secondary text-sm\">Library</button></div></div><!-- READING tab — behavior stats from sync pushes (all libraries) --><div id=\"stats-reading-section\"><div class=\"card p-4 mb-6 flex flex-wrap items-center justify-between gap-3\"><div class=\"flex gap-2\" id=\"stats-range-selector\"><button data-range=\"7\" @click=\"selectRange('7')\" class=\"stats-range-btn btn btn-secondary text-sm\">Last 7 days</button> <button data-range=\"30\" @click=\"selectRange('30')\" class=\"stats-range-btn btn btn-secondary text-sm\">30 days</button> <button data-range=\"90\" @click=\"selectRange('90')\" class=\"stats-range-btn btn btn-secondary text-sm\">90 days</button> <button data-range=\"year\" @click=\"selectRange('year')\" class=\"stats-range-btn btn btn-secondary text-sm\">This year</button></div><p class=\"text-xs\" style=\"color: var(--text-secondary)\" id=\"stats-window-label\"></p></div><div class=\"grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8\"><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Reading Time</h3><p id=\"stats-total-minutes\" class=\"text-3xl font-bold\" style=\"color: var(--accent)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\" id=\"stats-pages-card-label\">Pages</h3><p id=\"stats-total-pages\" class=\"text-3xl font-bold\" style=\"color: var(--accent)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Streaks</h3><p id=\"stats-streaks\" class=\"text-lg font-bold leading-tight mt-1\" style=\"color: var(--text-primary)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Books Finished</h3><p id=\"stats-books-finished\" class=\"text-3xl font-bold\" style=\"color: var(--accent)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Most Active</h3><p id=\"stats-most-active\" class=\"text-sm font-bold leading-snug mt-2\" style=\"color: var(--text-primary)\">–</p></div></div><div class=\"grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8\"><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">Daily Reading Minutes</h3><div class=\"h-64\"><canvas id=\"stats-daily-minutes-chart\"></canvas></div></div><div class=\"card p-6\" id=\"stats-pages-chart-panel\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\" id=\"stats-pages-chart-label\">Pages per Day</h3><div class=\"h-64\"><canvas id=\"stats-daily-pages-chart\"></canvas></div></div><div class=\"card p-6 hidden\" id=\"stats-listening-chart-panel\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">Listening per Day (hrs)</h3><div class=\"h-64\"><canvas id=\"stats-daily-listening-chart\"></canvas></div></div></div><div class=\"grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8\"><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">By Library</h3><div id=\"stats-library-breakdown\" class=\"space-y-3\"><div class=\"text-center py-6 text-sm\" style=\"color: var(--text-secondary)\">Loading…</div></div></div><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">By Device</h3><p class=\"text-xs mb-3\" style=\"color: var(--text-secondary)\">Where the sync pushes came from — registered device names, plus a Web bucket for pushes that carry no device credential (the web reader).</p><div id=\"stats-device-breakdown\" class=\"space-y-3\"><div class=\"text-center py-6 text-sm\" style=\"color: var(--text-secondary)\">Loading…</div></div></div><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">Most Read</h3><div id=\"stats-top-books\" class=\"space-y-3\"><div class=\"text-center py-6 text-sm\" style=\"color: var(--text-secondary)\">Loading…</div></div></div></div><div id=\"stats-empty\" class=\"card p-8 text-center hidden\"><p class=\"text-sm mb-2\" style=\"color: var(--text-primary)\">No reading sessions recorded in this range yet.</p><p class=\"text-xs\" style=\"color: var(--text-secondary)\">Sessions are captured every time a deviLine truncated
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, ") · totals derive from your devices' sync pushes, not a stopwatch.</p><div class=\"flex gap-2 mt-4\" id=\"stats-tab-row\"><button data-tab=\"reading\" @click=\"selectTab('reading')\" class=\"stats-tab-btn btn btn-secondary text-sm\">Reading</button> <button data-tab=\"library\" @click=\"selectTab('library')\" class=\"stats-tab-btn btn btn-secondary text-sm\">Library</button></div></div><!-- READING tab — behavior stats from sync pushes (all libraries) --><div id=\"stats-reading-section\"><div class=\"card p-4 mb-6 flex flex-wrap items-center justify-between gap-3\"><div class=\"flex flex-wrap gap-2\" id=\"stats-range-selector\"><button data-range=\"7\" @click=\"selectRange('7')\" class=\"stats-range-btn btn btn-secondary text-sm\">Last 7 days</button> <button data-range=\"30\" @click=\"selectRange('30')\" class=\"stats-range-btn btn btn-secondary text-sm\">30 days</button> <button data-range=\"90\" @click=\"selectRange('90')\" class=\"stats-range-btn btn btn-secondary text-sm\">90 days</button> <button data-range=\"year\" @click=\"selectRange('year')\" class=\"stats-range-btn btn btn-secondary text-sm\">This year</button></div><p class=\"text-xs\" style=\"color: var(--text-secondary)\" id=\"stats-window-label\"></p></div><div class=\"grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8\"><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Reading Time</h3><p id=\"stats-total-minutes\" class=\"text-3xl font-bold\" style=\"color: var(--accent)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\" id=\"stats-pages-card-label\">Pages</h3><p id=\"stats-total-pages\" class=\"text-3xl font-bold\" style=\"color: var(--accent)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Streaks</h3><p id=\"stats-streaks\" class=\"text-lg font-bold leading-tight mt-1\" style=\"color: var(--text-primary)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Books Finished</h3><p id=\"stats-books-finished\" class=\"text-3xl font-bold\" style=\"color: var(--accent)\">–</p></div><div class=\"stat-card\"><h3 class=\"text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Most Active</h3><p id=\"stats-most-active\" class=\"text-sm font-bold leading-snug mt-2\" style=\"color: var(--text-primary)\">–</p></div></div><div class=\"grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8\"><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">Daily Reading Minutes</h3><div class=\"h-64\"><canvas id=\"stats-daily-minutes-chart\"></canvas></div></div><div class=\"card p-6\" id=\"stats-pages-chart-panel\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\" id=\"stats-pages-chart-label\">Pages per Day</h3><div class=\"h-64\"><canvas id=\"stats-daily-pages-chart\"></canvas></div></div><div class=\"card p-6 hidden\" id=\"stats-listening-chart-panel\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">Listening per Day (hrs)</h3><div class=\"h-64\"><canvas id=\"stats-daily-listening-chart\"></canvas></div></div></div><div class=\"grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8\"><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">By Library</h3><div id=\"stats-library-breakdown\" class=\"space-y-3\"><div class=\"text-center py-6 text-sm\" style=\"color: var(--text-secondary)\">Loading…</div></div></div><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">By Device</h3><p class=\"text-xs mb-3\" style=\"color: var(--text-secondary)\">Where the sync pushes came from — registered device names, plus a Web bucket for pushes that carry no device credential (the web reader).</p><div id=\"stats-device-breakdown\" class=\"space-y-3\"><div class=\"text-center py-6 text-sm\" style=\"color: var(--text-secondary)\">Loading…</div></div></div><div class=\"card p-6\"><h3 class=\"text-sm font-semibold uppercase tracking-wide mb-4\" style=\"color: var(--text-secondary)\">Most Read</h3><div id=\"stats-top-books\" class=\"space-y-3\"><div class=\"text-center py-6 text-sm\" style=\"color: var(--text-secondary)\">Loading…</div></div></div></div><div id=\"stats-empty\" class=\"card p-8 text-center hidden\"><p class=\"text-sm mb-2\" style=\"color: var(--text-primary)\">No reading sessions recorded in this range yet.</p><p class=\"text-xs\" style=\"color: var(--text-secondary)\">Sessions are captured every tLine truncated
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -40,6 +40,15 @@ Gate: every line below executes for BOTH personas unless marked
|
||||
- Row 23 chains by page navigation — assert the URL becomes the next
|
||||
episode's reader and progress carries over.
|
||||
|
||||
## 2026-11-02 — promise-API confirm probe
|
||||
|
||||
`promise_confirm_probe.spec.ts` (no env gating): the confirmDialog()
|
||||
promise path (purge archived, remove account, …) must wire the global
|
||||
modal's buttons even when no hx-confirm element fired first — the
|
||||
Purge All Archived dialog opened with dead Cancel/Confirm otherwise
|
||||
(user report; confirm.ts now wires at init + on every confirmDialog).
|
||||
The probe intercepts the purge POST, so nothing is deleted.
|
||||
|
||||
## 2026-10-30 — round-3 regression probes (live-server, env-gated)
|
||||
|
||||
Two specs added against a RUNNING dev server (skip unless
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Regression probe (2026-11-02): the CONFIRM PROMISE API path must wire
|
||||
// the global modal's buttons too. purgeArchivedItems() (admin archived
|
||||
// page) opens confirmDialog() directly — no hx-confirm element fires —
|
||||
// and both buttons were dead whenever no hx-confirm had been clicked
|
||||
// first: the modal opened and Cancel/Confirm did nothing (user report).
|
||||
// The purge request itself is INTERCEPTED here — nothing is deleted.
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
const BASE = process.env.BH_BASE ?? "http://localhost:8765";
|
||||
|
||||
test("confirmDialog promise path wires Cancel/Confirm", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
const t = new URLSearchParams(window.location.search).get("token");
|
||||
if (t) localStorage.setItem("token", t);
|
||||
});
|
||||
// testuser login via endpoint, then seed cookie+localStorage like the
|
||||
// other probes (rls_sweep pattern, kept inline to stay one file).
|
||||
const res = await page.request.post(`${BASE}/api/auth/login`, {
|
||||
data: { login: "testuser", password: "Test@Pass123!" },
|
||||
});
|
||||
const { access_token: token } = (await res.json()) as { access_token?: string };
|
||||
expect(token).toBeTruthy();
|
||||
await page.context().addCookies([{ name: "token", value: token!, url: BASE }]);
|
||||
await page.addInitScript((t) => localStorage.setItem("token", t), token!);
|
||||
|
||||
// The purge POST is intercepted: Cancel/Confirm behavior is asserted
|
||||
// without ever deleting the dev data.
|
||||
let purgeRequested = false;
|
||||
await page.route("**/api/media-items/purge-archived", (route) => {
|
||||
purgeRequested = true;
|
||||
route.fulfill({ status: 200, contentType: "application/json", body: '{"purged":0}' });
|
||||
});
|
||||
|
||||
await page.goto(`${BASE}/admin/archived`);
|
||||
await expect(page.locator("#confirm-modal")).toBeAttached();
|
||||
|
||||
// Fresh page: no hx-confirm has fired, so pre-fix the buttons were
|
||||
// unwired (data-wired absent) and Cancel did nothing.
|
||||
await page.getByRole("button", { name: "Purge All Archived" }).click();
|
||||
const modal = page.locator("#confirm-modal");
|
||||
await expect(modal).toBeVisible();
|
||||
await expect(page.locator("#confirm-modal-cancel")).toHaveAttribute("data-wired", "1");
|
||||
|
||||
await page.locator("#confirm-modal-cancel").click();
|
||||
await expect(modal).toBeHidden();
|
||||
|
||||
// Confirm path: modal closes AND the guarded request issues.
|
||||
await page.getByRole("button", { name: "Purge All Archived" }).click();
|
||||
await expect(modal).toBeVisible();
|
||||
await page.locator("#confirm-modal-accept").click();
|
||||
await expect(modal).toBeHidden();
|
||||
expect(purgeRequested).toBe(true);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 */
|
||||
});
|
||||
|
||||
+12
-1
@@ -25,6 +25,7 @@ function hide(ok: boolean): void {
|
||||
* Safe to call from any module (import this instead of window.confirm).
|
||||
*/
|
||||
export function confirmDialog(message: string): Promise<boolean> {
|
||||
wireButtons();
|
||||
const { overlay, message: messageEl } = modalElements();
|
||||
if (!overlay || !messageEl) {
|
||||
// Header missing (bare layouts): degrade to the native dialog
|
||||
@@ -38,7 +39,13 @@ export function confirmDialog(message: string): Promise<boolean> {
|
||||
});
|
||||
}
|
||||
|
||||
// Wire the static buttons once.
|
||||
// Wire the static buttons once. Called at module init AND from both
|
||||
// entry paths below (idempotent via data-wired): the htmx:confirm
|
||||
// interceptor and the confirmDialog promise API. Wiring only on the
|
||||
// htmx path left the promise path (purge archived, remove account, ...)
|
||||
// with dead Cancel/Confirm buttons whenever no hx-confirm element had
|
||||
// been clicked first — the modal opened and nothing could close it
|
||||
// (user report: Purge Archived, 2026-11-02).
|
||||
function wireButtons(): void {
|
||||
const { overlay } = modalElements();
|
||||
const accept = document.getElementById("confirm-modal-accept");
|
||||
@@ -60,6 +67,10 @@ function wireButtons(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// The header (and its confirm modal) render before deferred modules run,
|
||||
// so the elements are present at import time.
|
||||
wireButtons();
|
||||
|
||||
// htmx:confirm interceptor: every element with hx-confirm gets the
|
||||
// modal instead of the browser dialog; confirming issues the request.
|
||||
// (Canonical htmx pattern — see htmx docs, "confirming with a modal".)
|
||||
|
||||
@@ -257,6 +257,27 @@ function initCustomSectionBuilder(): void {
|
||||
if (form) {
|
||||
form.addEventListener("submit", saveCustomSection);
|
||||
}
|
||||
|
||||
// Delegated clicks for the generated picker/selection markup: the CSP
|
||||
// build refuses inline onclick= handlers, so the rows carry data
|
||||
// attributes and one listener per container survives innerHTML swaps.
|
||||
const resultsContainer = document.getElementById("cs-search-results");
|
||||
resultsContainer?.addEventListener("click", (e) => {
|
||||
const row = (e.target as HTMLElement).closest<HTMLElement>("[data-book-id]");
|
||||
if (!row) return;
|
||||
addBookToSelection(
|
||||
row.dataset.bookId ?? "",
|
||||
row.dataset.bookTitle ?? "",
|
||||
row.dataset.bookAuthor ?? "",
|
||||
);
|
||||
});
|
||||
|
||||
const selectedContainer = document.getElementById("selected-books");
|
||||
selectedContainer?.addEventListener("click", (e) => {
|
||||
const btn = (e.target as HTMLElement).closest<HTMLElement>("[data-remove-book]");
|
||||
if (!btn) return;
|
||||
removeBookFromSelection(btn.dataset.removeBook ?? "");
|
||||
});
|
||||
}
|
||||
|
||||
function addFilterRule(): void {
|
||||
@@ -363,7 +384,11 @@ function onBookSearchInput(): void {
|
||||
async function searchBooks(): Promise<void> {
|
||||
const searchInput = document.getElementById("book-search") as HTMLInputElement;
|
||||
const librarySelect = document.getElementById("section-library") as HTMLSelectElement;
|
||||
const resultsContainer = document.getElementById("search-results") as HTMLElement;
|
||||
// NOTE: cs-search-results, NOT search-results — the header search
|
||||
// dropdown owns the latter on every page, and getElementById returns
|
||||
// the first match in tree order (the picker's results were landing in
|
||||
// the topbar dropdown, which stayed invisible).
|
||||
const resultsContainer = document.getElementById("cs-search-results") as HTMLElement;
|
||||
|
||||
const query = searchInput?.value.trim();
|
||||
const libraryId = librarySelect?.value;
|
||||
@@ -374,22 +399,48 @@ async function searchBooks(): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
// The unified JSON search (same endpoint the bookshelf uses). The
|
||||
// builder was wired to /api/books/search, a route that has never
|
||||
// existed — every picker search 404'd. Rows come back with id/title/
|
||||
// author/cover_image_path; map to the BookInfo shape the picker
|
||||
// renders (conflict/badge metadata is attached server-side later).
|
||||
const response = await fetch(
|
||||
`/api/books/search?q=${encodeURIComponent(query)}&library_id=${libraryId}`,
|
||||
`/api/media-items/search?q=${encodeURIComponent(query)}&library_id=${libraryId}`,
|
||||
{
|
||||
headers: {
|
||||
...authHeaders(),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status === 404) {
|
||||
// Unified search signals "no results" as a 404 whose body still
|
||||
// carries results: [].
|
||||
displaySearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to search books");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
displaySearchResults(data.books || []);
|
||||
const rows: Array<{
|
||||
id?: string;
|
||||
title?: string;
|
||||
author?: string | null;
|
||||
cover_image_path?: string | null;
|
||||
}> = Array.isArray(data) ? data : data.results || [];
|
||||
const books: BookInfo[] = rows.map((r) => ({
|
||||
media_item_id: r.id ?? "",
|
||||
title: r.title ?? "",
|
||||
author: r.author ?? "",
|
||||
cover_image_path: r.cover_image_path ?? "",
|
||||
has_conflict: false,
|
||||
format_group: "",
|
||||
badge: "",
|
||||
}));
|
||||
displaySearchResults(books);
|
||||
} catch (error) {
|
||||
console.error("Search books error:", error);
|
||||
showToast("Failed to search books", "error");
|
||||
@@ -397,7 +448,7 @@ async function searchBooks(): Promise<void> {
|
||||
}
|
||||
|
||||
function displaySearchResults(books: BookInfo[]): void {
|
||||
const resultsContainer = document.getElementById("search-results") as HTMLElement;
|
||||
const resultsContainer = document.getElementById("cs-search-results") as HTMLElement;
|
||||
if (!resultsContainer) return;
|
||||
|
||||
if (books.length === 0) {
|
||||
@@ -409,7 +460,8 @@ function displaySearchResults(books: BookInfo[]): void {
|
||||
(book) => `
|
||||
<div class="flex items-center gap-2 p-2 hover:bg-gray-700 rounded cursor-pointer"
|
||||
data-book-id="${book.media_item_id}"
|
||||
onclick="addBookToSelection('${book.media_item_id}', '${builderEscapeHtml(book.title)}', '${builderEscapeHtml(book.author)}')">
|
||||
data-book-title="${builderEscapeHtml(book.title)}"
|
||||
data-book-author="${builderEscapeHtml(book.author)}">
|
||||
<img src="${book.cover_image_path || "/static/placeholder-book.svg"}"
|
||||
alt="${builderEscapeHtml(book.title)}"
|
||||
class="w-10 h-15 object-cover rounded">
|
||||
@@ -464,7 +516,7 @@ function updateSelectedBooksDisplay(): void {
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1 m-1 rounded-full text-sm"
|
||||
style="background-color: var(--accent);">
|
||||
<span>${builderEscapeHtml(book.title)}</span>
|
||||
<button type="button" onclick="removeBookFromSelection('${book.media_item_id}')"
|
||||
<button type="button" data-remove-book="${book.media_item_id}"
|
||||
class="hover:opacity-70">×</button>
|
||||
</div>
|
||||
`,
|
||||
@@ -627,13 +679,6 @@ function builderEscapeHtml(text: string): string {
|
||||
|
||||
initCustomSectionBuilder();
|
||||
|
||||
// Inline onclick handlers in the generated selection HTML resolve against
|
||||
// globals; bundled module functions need explicit exposure.
|
||||
declare global {
|
||||
interface Window {
|
||||
addBookToSelection: typeof addBookToSelection;
|
||||
removeBookFromSelection: typeof removeBookFromSelection;
|
||||
}
|
||||
}
|
||||
window.addBookToSelection = addBookToSelection;
|
||||
window.removeBookFromSelection = removeBookFromSelection;
|
||||
// Picker/selection clicks are delegated inside initCustomSectionBuilder —
|
||||
// the CSP build refuses inline onclick= handlers, so the generated markup
|
||||
// carries data attributes instead and no globals are needed.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
import { Alpine } from "./alpine";
|
||||
import { authHeaders } from "./storage";
|
||||
import { showToast } from "./toast";
|
||||
|
||||
async function loadUnlinkedBooks(): Promise<void> {
|
||||
// cookie auth carries the call; a null bearer must not strand it
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/sync/unlinked-books", {
|
||||
headers: authHeaders(),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
renderUnlinkedBooks(data.unlinked || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load unlinked books:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function renderUnlinkedBooks(books: UnlinkedBookData[]): void {
|
||||
const container = document.getElementById("unlinked-books-list");
|
||||
if (!container) return;
|
||||
|
||||
if (books.length === 0) {
|
||||
container.innerHTML =
|
||||
'<p class="text-center p-4" style="color: var(--text-secondary)">No unlinked books</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = books
|
||||
.map(
|
||||
(book) => `
|
||||
<div class="p-4 rounded-lg border mb-2" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 class="font-medium" style="color: var(--text-primary)">${book.title_from_device}</h3>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">${book.device_name} (${book.device_type})</p>
|
||||
<p class="text-xs" style="color: var(--text-secondary)">${book.file_path}</p>
|
||||
<p class="text-xs mt-1" style="color: var(--text-secondary)">Confidence: ${Math.round(book.confidence_score * 100)}%</p>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button onclick="window.showMatchModal('${book.progress_id}')" class="btn-primary px-3 py-1 rounded text-sm">Link</button>
|
||||
</div>
|
||||
</div>
|
||||
${
|
||||
book.potential_matches && book.potential_matches.length > 0
|
||||
? `
|
||||
<div class="mt-3 pt-3 border-t" style="border-color: var(--border)">
|
||||
<p class="text-xs font-medium mb-2" style="color: var(--text-secondary)">Potential Matches:</p>
|
||||
${book.potential_matches
|
||||
.slice(0, 3)
|
||||
.map(
|
||||
(match) => `
|
||||
<div class="flex justify-between items-center p-2 rounded mb-1" style="background-color: var(--bg-primary)">
|
||||
<div>
|
||||
<p class="text-sm" style="color: var(--text-primary)">${match.title}</p>
|
||||
<p class="text-xs" style="color: var(--text-secondary)">${match.author} (${Math.round(match.confidence * 100)}%)</p>
|
||||
</div>
|
||||
<button onclick="window.linkBook('${book.progress_id}', '${match.media_item_id}')" class="btn-secondary px-2 py-1 rounded text-xs">Link</button>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function linkBook(progressId: string, mediaItemId: string): Promise<void> {
|
||||
// cookie auth carries the call; a null bearer must not strand it
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/sync/link-book", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
progress_id: progressId,
|
||||
media_item_id: mediaItemId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showToast("Book linked successfully", "success");
|
||||
loadUnlinkedBooks();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(error.error || "Failed to link book", "error");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to link book:", error);
|
||||
showToast("Failed to link book", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function autoLinkBooks(): Promise<void> {
|
||||
// cookie auth carries the call; a null bearer must not strand it
|
||||
|
||||
if (!confirm("Auto-link all books with high confidence matches?")) return;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/sync/auto-link", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ confidence_threshold: 0.9 }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
showToast(`Auto-linked ${data.linked_count || 0} books`, "success");
|
||||
loadUnlinkedBooks();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to auto-link:", error);
|
||||
showToast("Failed to auto-link books", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function getSuggestions(progressId: string): Promise<void> {
|
||||
// cookie auth carries the call; a null bearer must not strand it
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/sync/suggestions/${progressId}`, {
|
||||
headers: authHeaders(),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const suggestions = await response.json();
|
||||
showSuggestionsModal(progressId, suggestions);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to get suggestions:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function showSuggestionsModal(progressId: string, suggestions: PotentialMatchData[]): void {
|
||||
const modal = document.getElementById("match-modal");
|
||||
const content = document.getElementById("match-modal-content");
|
||||
|
||||
if (!modal || !content) return;
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="p-4">
|
||||
<h3 class="font-medium mb-4" style="color: var(--text-primary)">Select a match</h3>
|
||||
<div class="space-y-2">
|
||||
${suggestions
|
||||
.map(
|
||||
(s) => `
|
||||
<div class="p-3 rounded border cursor-pointer hover:border-opacity-50"
|
||||
style="background-color: var(--bg-primary); border-color: var(--border)"
|
||||
onclick="window.linkBook('${progressId}', '${s.media_item_id}'); window.hideMatchModal();">
|
||||
<p class="font-medium" style="color: var(--text-primary)">${s.title}</p>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">${s.author}</p>
|
||||
<p class="text-xs" style="color: var(--text-secondary)">${Math.round(s.confidence * 100)}% match</p>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
<button onclick="window.hideMatchModal()" class="mt-4 btn-secondary w-full py-2 rounded">Cancel</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
modal.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function hideMatchModal(): void {
|
||||
const modal = document.getElementById("match-modal");
|
||||
if (modal) {
|
||||
modal.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
Alpine.data("linking", () => ({
|
||||
autoLinkBooks,
|
||||
getSuggestions,
|
||||
hideMatchModal,
|
||||
linkBook,
|
||||
loadUnlinkedBooks,
|
||||
showSuggestionsModal,
|
||||
}));
|
||||
@@ -20,7 +20,6 @@ import "./index";
|
||||
import "./image_fallback";
|
||||
import { armAuthFormRedirects } from "./auth_forms";
|
||||
import "./library-switcher";
|
||||
import "./linking";
|
||||
import "./login";
|
||||
import "./password_validation";
|
||||
import "./processing_issues";
|
||||
|
||||
+20
-3
@@ -324,7 +324,9 @@ function renderSummary(summary: Summary): void {
|
||||
const mostActive = document.getElementById("stats-most-active");
|
||||
if (mostActive) {
|
||||
if (summary.most_active_day) {
|
||||
mostActive.innerHTML = `${summary.most_active_day} (${summary.most_active_day_minutes} min)<span class="block text-xs font-normal" style="color: ${themeVar("--text-secondary", "#999")}">${summary.most_active_weekday}</span>`;
|
||||
// Date and minutes on their own lines: one long line wraps mid-token
|
||||
// ("(148 / min)") in the narrow stat card.
|
||||
mostActive.innerHTML = `<span class="block">${summary.most_active_day}</span><span class="block">(${summary.most_active_day_minutes} min)</span><span class="block text-xs font-normal" style="color: ${themeVar("--text-secondary", "#999")}">${summary.most_active_weekday}</span>`;
|
||||
} else {
|
||||
mostActive.textContent = "—";
|
||||
}
|
||||
@@ -510,7 +512,7 @@ function renderTopBooks(books: TopBook[]): void {
|
||||
(book) => `
|
||||
<a href="/media/${book.media_item_id}" class="flex items-center gap-3 p-2 rounded-lg hover:bg-surface-hover">
|
||||
<span class="w-10 h-14 rounded overflow-hidden shrink-0" style="background-color: var(--bg-primary)">
|
||||
<img src="${book.cover_image_path || "/static/placeholder-book.svg"}" alt="" class="w-full h-full object-cover" loading="lazy" onerror="this.style.display='none'"/>
|
||||
<img src="${book.cover_image_path || "/static/placeholder-book.svg"}" alt="" class="w-full h-full object-cover" loading="lazy"/>
|
||||
</span>
|
||||
<span class="flex-1 min-w-0">
|
||||
<span class="block text-sm font-medium truncate" style="color: var(--text-primary)">${escapeHtml(book.title)}</span>
|
||||
@@ -684,7 +686,7 @@ function renderUsageItems(
|
||||
<a href="/media/${item.media_item_id}" class="block p-2 rounded-lg hover:bg-surface-hover">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-10 h-14 rounded overflow-hidden shrink-0" style="background-color: var(--bg-primary)">
|
||||
<img src="${item.cover_image_path || "/static/placeholder-book.svg"}" alt="" class="w-full h-full object-cover" loading="lazy" onerror="this.style.display='none'"/>
|
||||
<img src="${item.cover_image_path || "/static/placeholder-book.svg"}" alt="" class="w-full h-full object-cover" loading="lazy"/>
|
||||
</span>
|
||||
<span class="flex-1 min-w-0">
|
||||
<span class="block text-sm font-medium truncate" style="color: var(--text-primary)">${escapeHtml(item.title)}</span>
|
||||
@@ -710,6 +712,21 @@ function escapeHtml(value: string): string {
|
||||
}
|
||||
|
||||
function initStatsPage(): void {
|
||||
// Broken-cover fallback without inline onerror: the CSP build refuses
|
||||
// inline handlers, so listen in capture phase on each list container
|
||||
// (error does not bubble, but capture still reaches it; the containers
|
||||
// persist across innerHTML swaps).
|
||||
for (const containerID of ["stats-top-books", "stats-lib-longest", "stats-lib-largest"]) {
|
||||
document.getElementById(containerID)?.addEventListener(
|
||||
"error",
|
||||
(e) => {
|
||||
const img = e.target as HTMLElement;
|
||||
if (img instanceof HTMLImageElement) img.style.display = "none";
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const initial = params.get("range") || "7";
|
||||
currentRange = ["7", "30", "90", "year"].includes(initial) ? initial : "7";
|
||||
|
||||
@@ -618,6 +618,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes app-sheet-in {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* ---------- Surfaces ---------- */
|
||||
|
||||
@@ -628,6 +637,28 @@
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
/* ---------- Modal shell ---------- */
|
||||
|
||||
/* Reusable oversized-pop-up shell (.app-modal-panel): centered dialog on
|
||||
desktop (>=640px), full-height right slide-out sheet on phones. Pair
|
||||
with an overlay of `flex items-stretch justify-end sm:items-center
|
||||
sm:justify-center sm:p-4`. The slide-in animation replays on every
|
||||
open because the overlay toggles display none -> flex. Long content
|
||||
scrolls inside the panel; give the header/footer `sticky` + the card
|
||||
background so actions stay reachable. */
|
||||
.app-modal-panel {
|
||||
@apply w-full h-dvh overflow-y-auto overscroll-contain rounded-l-2xl;
|
||||
box-shadow: var(--shadow-pop);
|
||||
animation: app-sheet-in 240ms cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.app-modal-panel {
|
||||
@apply h-auto max-h-[calc(100vh-2rem)] rounded-2xl;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Buttons ---------- */
|
||||
|
||||
.btn {
|
||||
@@ -2018,6 +2049,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,2 +1,2 @@
|
||||
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
|
||||
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
|
||||
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
|
||||
Reference in new issue
Block a user