Compare commits

..
17 Commits
Author SHA1 Message Date
John O'Keefe f7ba4d24d6 style(web): main sidebar drops 16rem to 14rem (user review)
Release / build-and-push (push) Successful in 3m38s
The app-shell sidebar read as too wide against the content area.
--sidebar-w is the single source of truth for the shell geometry, so
one line moves all three consumers together: the .app-sidebar width,
the body:has(.app-sidebar) content offset, and the topbar's left
inset on desktop. No templ or JS change needed — nothing else pinned
16rem.

style.css is the regenerated tailwind build (build:css:prod), diff
limited to the one var; committed with its source per the generated-
artifacts rule.
2026-10-30 15:09:44 -04:00
John O'Keefe f47734dd7d fix(podcasts): generic media delete refuses episode rows (invariant 2)
The admin endpoint DELETE /api/media-items/:id predated podcasts and
would delete any media row — including podcast episodes — if hit
directly. Invariant 2 (absolute form, user decision 2026-10-30) says
episode rows are removed ONLY by whole-feed removal: "the feed should
match the XML until the entire feed is gone." The plan audit found
this endpoint was the one remaining surface that could violate it:
the poller, unsubscribe, and the admin file-deletion endpoints are
files-only, and the hash-conflict merge can't see episodes (they
never hash).

The guard sits right after the GetMediaItem read and BEFORE the
storage-down check, so a refused episode never touches storage
resolution. Same segregation pattern as the generic edit path
(D9/D10, UpdateMediaItem).

Pinned by TestDeleteMediaItemPodcastGuard: admin DELETE of an episode
→ 409 with the row surviving; a book delete through the same endpoint
→ 204 control.
2026-10-30 14:57:08 -04:00
John O'Keefe 5fee8bfb4a fix(admin): bulk delete swaps the episode list — Play → Download without reload
The bulk 'Delete selected files' button posted with hx-swap='none',
which makes htmx DISCARD the response — and the response is the
refreshed episode list partial. Rows (Play → Download, badges) stayed
stale until a manual refresh. Target the swap at #episode-list /
outerHTML like the Download All button; the afterSwap listeners then
reapply the (now-empty) selection and any active filter.
2026-10-30 14:13:39 -04:00
John O'Keefe 7d5bbd1177 feat(admin): multi-select episode file deletion — checkboxes on downloaded rows
The plan's per-EPISODE bulk select for the admin view (§0 item 8
follow-up — trimmed in the first admin-view commit; user called it
back). Admins now tick checkboxes on DOWNLOADED episode rows and delete
their files in one request:

- Checkbox per row, rendered ONLY for admins + downloaded episodes
  (nothing to delete otherwise), via the existing podcastDetail Alpine
  component: selection ids/count survive the list's htmx swaps
  (reapplied on afterSwap — same pattern as the text filter) and clear
  after a successful bulk delete.
- Toolbar segment outside #episode-list (survives swaps): 'N selected'
  + 'Delete selected files' (hx-include gathers the CHECKED boxes);
  disabled-looking at zero selection; hx-confirm states the
  re-materialization contract. CSP-safe method bindings only
  (bulkSelectionLabel/Class) — no ternaries in Alpine expressions.
- POST /feeds/:id/episodes/delete-files (admin): repeated
  episode-file-select keys parsed via ParseForm directly (never bound —
  repeated-key wire is another echo-bind gotcha); each id validated to
  belong to THIS feed, so a forged foreign id is refused while the real
  selections apply; rows reset to pending, files removed.
- Tests: bulk of two real ids + one forged id → deleted:2, files gone,
  rows pending; non-admin 403; all prior single/feed-level pins hold.
2026-10-30 14:06:54 -04:00
John O'Keefe 0741e98ea5 feat(admin): all-podcasts view — disk-first maintenance for every feed
§0 item 8 (Phase-4 item 1 pulled forward, user directive: maintenance
tooling ships WITH the features it maintains):

- /admin/podcasts (AdminMiddleware): every feed from EVERY user via the
  RLS admin escape (first-admin identity, poller/fetcher contract) —
  feed + host, library, subscriber count/names, downloaded/total, DISK
  USAGE, last fetch. Sorted disk DESC ('what's eating the disk'),
  totals header (feeds · episodes · bytes on disk).
- Per-feed actions: Refresh · Delete files (rows → pending, disk freed;
  confirms spell out the re-materialization contract) · Remove feed
  (rows + files for everyone — the same admin delete as the detail
  page). All confirms ride the global confirm modal.
- New sqlc: ListAllPodcastFeedsAdmin (correlated aggregates),
  ResetPodcastEpisodeFileDeleted / ResetPodcastFeedFilesDeleted —
  downloaded rows reset to pending with download-derived facts cleared
  (audio_metadata, chapter_count, cover, size, mime); file_path KEPT
  (fetch-maintained layout — a re-download lands on the same path).
  file deletion via the existing DeleteFeedFiles file-removal pattern
  (episode files + .cover.jpg siblings).
- Poll upsert never touches download_state, so deleted files STAY
  deleted — a feed refresh re-lists metadata but re-materializes
  nothing (pinned by TestAdminDeleteEpisodeFiles).
- Per-episode admin delete-file: POST /feeds/:id/episodes/:eid/
  delete-file (admin) — one episode's file gone, row pending, siblings
  untouched; HX swaps the episode list. (Found while testing: the
  handler first read c.Param("id") — the FEED's uuid — instead of
  :eid; the 404 'episode not found' trail led straight to it.)
- Tests: admin sees other users' feeds + subscriber names; regular
  users 403 on page AND actions; single + feed-level deletion reset
  rows/clear facts/delete files; refresh leaves pending rows pending.
- Admin nav gains 'Podcasts' under Administration.

Wrong-gotcha note for future agents: parseUUID on c.Param('id') inside
a /feeds/:id/episodes/:eid route resolves the FEED, not the episode.
2026-10-30 13:54:37 -04:00
John O'Keefe b293741eb1 feat(reader): podcast episode prev/next on the transport row's outer buttons
User design (round 3): one set of big buttons does the big navigation.
The outer prev/next step buttons were chapter-only even on podcasts,
while episode nav hid as small icons in the top-left cluster. Now:

- Podcasts (sequence context present): the outer buttons navigate
  EPISODES via goToPrev/goToNextEpisode (progress pushed before the
  hop); disabled ghost buttons at the first/last episode keep the row
  shape. Embedded-chapter jumps stay on the Chapter pill's list.
- Books: unchanged chapter stepping.
- Keyboard (h/l shortcuts) and hardware media keys (MediaTrackPrev/
  Next) funnel through stepChapter, which is now sequence-aware —
  headset next-track = next episode on podcasts.
- The top-left episode icons are gone; the 'Ep N/M' counter stays.
- Help-modal labels say 'chapter, or episode on podcasts'.

Same icons both modes (extracted audioStepPrev/NextIcon partials);
books are untouched — no sequence context, byte-identical behavior.
2026-10-30 12:46:36 -04:00
John O'Keefe ace9e57d27 fix(ui): hx-confirm interception actually worked — htmx 2 detail shape
User report: Remove Feed still used the browser dialog. Headless-proven
root cause (tests/ui/confirm_probe.spec.ts): the htmx:confirm handler
read evt.detail.element, which htmx 2 does not provide (its detail is
{ elt, question, issueRequest, ... }) — the handler returned at
'if (!elt) return' BEFORE preventDefault, so every hx-confirm sitewide
fell through to htmx's native confirm. The acbcc61a migration only ever
worked for direct confirmDialog() callers; hx-confirm interception was
dead on arrival against htmx 2 and no test watched the JS side.

Fix: read detail.question (htmx's own closest-attribute resolution of
hx-confirm); return without preventing default when absent. Probe
spec pins: click Remove Feed → #confirm-modal opens with the message,
cancel hides it, no dialog event fires.

Companion fixes in the same user-test round (one commit, one defect
family — templ boolean attributes + confirm-modal placement):
- Add-modal Download order never revealed: #podcast-download-all was
  nested in a wrapper div, so ':checked ~ .podcast-add-order' had no
  sibling relationship. Input, label, and order row are now siblings.
- Order/auto-download dropdowns always DISPLAYED the last option
  ('Newest first'/'Newest') whatever was saved: templ selected={ bool }
  emits selected="false" — presence selects, so every option was
  selected and the browser honored the last. Swept to selected?={...}
  across podcast_detail (14), book_detail_modals (10, incl. two
  always-checked checkboxes), collection_modal (2, same). Saves were
  correct all along; display only.
- Global confirm modal moved from SidebarSignIn (signed-out only!) into
  Header — signed-in pages had no #confirm-modal, so confirmDialog
  degraded to window.confirm even with a working interceptor.

Facet bar scale (user question): values now carry feed counts, sort
frequency-first, cap at 6 chips per group with a native <details>
'+N more' expander.

Probes: tests/ui/confirm_probe.spec.ts + add_order_probe.spec.ts
(live-server, BH_FEED-gated) — both pass against the dev instance.
2026-10-30 12:24:16 -04:00
John O'Keefe 2fd0828c06 fix(podcasts+ui): user-test round 3 — five browser-reported defects
1. Add-modal 'Download order' never revealed: the CSS sibling selector
   (#podcast-download-all:checked ~ .podcast-add-order) can't fire —
   the checkbox was nested inside a wrapper div, so the select row was
   no SIBLING of the input. Restructured: input, label, and order row
   are now siblings under one flex-wrap wrapper.

2. Order + auto-download dropdowns always displayed the LAST option
   ('Newest first'/'Newest') regardless of the saved value: templ's
   selected={ boolExpr } compiles to selected="false" — and the mere
   PRESENCE of the attribute selects the option, so every option was
   selected and the browser honored the last. Sweep to the boolean-
   attribute form selected?={...} (omits on false) across
   podcast_detail (14), book_detail_modals (10: manga type/reading
   direction selects + abridged/explicit checkboxes — same defect,
   always-checked), collection_modal (2: hide-finished/show-up-next
   always-checked). Values were SAVING correctly all along; this was
   pure display. audio_reader/collections :selected/:checked are
   Alpine bindings — unaffected.

3. Remove Feed (and every hx-confirm) still showed the browser dialog
   for signed-in users: the global confirm modal rendered at the end
   of SidebarSignIn — signed-IN pages have no SidebarSignIn, so no
   #confirm-modal existed and confirmDialog() degraded to
   window.confirm. Moved the modal into Header proper (rendered on
   every page, after </aside>).

4. Facet bar at scale: values now carry feed COUNTS, sort by frequency
   DESC then alphabetically, cap at 6 chips per group flat, and the
   rest collapse into a native <details> '+N more' expander — hundreds
   of hosts/tags degrade gracefully instead of a wall of chips.

Verified live on the dev instance (order select reflects the saved
override, override row persists, reveal fires, modal renders on
signed-in pages). Podcast + logout + media-pin suites green; vitest
167 green.
2026-10-30 12:03:44 -04:00
John O'Keefe 15f41e6fd4 test(pins): whitelist download_queue_pos on raw media rows (additive widening)
The contract pin caught the §0 item 4 column surfacing on media-list
raw sqlc rows — its job. Widened additively per doctrine: rawRowKeys +
docs/contract_pins.md changelog entry. NULL for scan rows and
never-queued episodes; nothing removed or renamed.
2026-10-30 10:40:34 -04:00
John O'Keefe 0da8e0cdf9 test(settings): cover the new float setting type in the defaults audit
TestSettingDefaults enumerates registry types; 'float' hit the unknown-
type branch. Added float validation (default parses, within [min,max])
mirroring the int case.
2026-10-30 10:31:02 -04:00
John O'Keefe b574df346a chore(podcasts): drop the dead QueueFeedDownloads wrapper
All callers moved to QueueFeedDownloadsAs in the item-5 commit; the
no-override shim had no remaining callers.
2026-10-30 10:29:45 -04:00
John O'Keefe f0f1eb6e8f feat(podcasts): hide played/downloaded toggle + episode text filter
Pocket Casts steal pulled forward from Phase 4 (§0 item 6): the episode
list gains a client-side 'Filter episodes…' box and a 'Hide
played/downloaded' toggle. Pure presentation — rows carry templ-rendered
data-* attributes (downloaded, listen-pct, search text); the filter
toggles DOM visibility with the 99.5% finishedThreshold. The toolbar
lives OUTSIDE #episode-list so its state survives the list's polling
swaps, and an htmx:afterSwap listener re-applies the active filter to
freshly swapped rows. No query change, no server round trip, no new JS
rendering surface (Alpine component method, CSP-safe).
2026-10-30 10:28:31 -04:00
John O'Keefe 5c545534c2 feat(podcasts): Download All queues in the LISTENING order + add-modal order dropdown
Download All previously queued in feed-native order — 'getting the
last chapter first doesn't help me'. Now the queue follows the feed's
effective listening order (§0 item 5b): QueueFeedDownloadsAs reads the
CALLER's order override and runs episodes through services.OrderEpisodes
(same helper as the sequence/reader/page surfaces — the consolidation
from item 5a landed with the reverse-order commit). Queue tickets
(SEQUENCE-stamped, item 4) make that enqueue order the claim order, so
chapters land in the order you'll actually listen.

Add modal (§0 item 5c): with 'Download all episodes now' checked, a
'Download order' select appears (pure-CSS :checked sibling reveal — no
JS island, CSP-clean). Its choice is the feed's INITIAL order override
(reader_settings upsert; 'feed' = no row) AND the queue order for the
initial batch. Per-book by design: the override IS the download order.

Test TestPodcastAddModalDownloadOrder: add with download_all+reverse →
the override row exists AND persisted tickets run position 3→1. Tickets
are asserted (not live queued rows) because the shared dev server's
worker legitimately races this test's queue — tickets survive every
state flip.
2026-10-30 10:26:00 -04:00
John O'Keefe 0f3c4a8cec feat(podcasts): concurrent download pool — fair share, tickets, admin tunables
Replaces the sequential worker with a download-manager-style pool
(§0 item 4). Model: the user's connection is the limit; semantics are
fair-share ACROSS feeds, not goroutine-per-book (a global FIFO would
make Book B wait behind Book A's 100 files).

- Atomic claim: GetNextQueuedEpisode + SetPodcastEpisodeDownloading
  was a read-then-write race under concurrency; ClaimQueuedPodcastEpisode
  converts queued→downloading in ONE guarded statement (two claimants,
  one winner — the loser gets ErrNoRows). SetPodcastEpisodeDownloading
  retired.
- Queue tickets: download_queue_pos BIGINT fed by a Postgres SEQUENCE
  stamped at enqueue — the enqueue loop's ordering IS the claim order
  (the foundation §0 item 5 builds on; previously all rows in a batch
  shared one updated_at, so within-feed order was arbitrary).
- Pool: up to podcast_download_concurrency (default 4, live range 1–16)
  claim-loop goroutines per drain round; the setting re-reads every
  round so admin changes take effect without restart.
- Fair share: claim prefers the feed least-recently-served
  (pickFeedToServe; never-served first), earliest ticket within a feed —
  books proceed in parallel while chapters land in order (start
  listening while the rest download).
- Bandwidth: shared token bucket across all workers
  (podcast_download_speed_mbps, default 0 = unlimited); take() reserves
  bandwidth and callers sleep outside the lock.
- Settings: new float setting type end to end (registry defaults +
  clamping, save validation, admin UI number-with-step input) + the two
  schema seeds; worker wires the registry via SetSettings (tests run on
  registered defaults).
- Tests: unit coverage for the bucket reservation math + fair-share
  pick; integration TestPodcastDownloadPool pins ticket order = enqueue
  order, claim exclusivity, and a full 3-episode pool drain.

Dev-DB note: the column/sequence/settings were applied live (idempotent
DDL, same statements the schema runs at next boot) since the running
container predates them.
2026-10-30 10:12:43 -04:00
John O'Keefe e3675536aa feat(podcasts): reverse order — position DESC, date-agnostic; single order helper
The 'Oldest first' order sorts by PUB DATE, which breaks on Scribl's
migrated podiobooks feeds (messy/all-equal dates). New order value
'reverse' = feed position DESC with a date-DESC tiebreak — dates are
not involved, so nothing can break.

- services.OrderEpisodes: THE single order source of truth (§0 items
  3+5a). The four surfaces (sequence JSON, reader chain context, SSR
  page list) previously carried three near-identical handwritten
  comparators — all now route through one generic helper. The SSR page
  list gains a SortDate key (lexical 2006-01-02) so the pub-date
  tiebreak is actually chronological; the old comparator compared
  'Jan 2, 2006' display strings alphabetically (latent drift bug).
- Dropdown becomes four options: Feed order / Feed order (reversed) /
  Oldest first (pub date) / Newest first (pub date).
- Serial suggestion button renamed 'Reverse feed order' (sets reverse,
  not oldest) and its response out-of-band-swaps the order select
  (PodcastOrderSelectOOB — same mechanism as Download All's policy
  select), so the dropdown mirrors the server state immediately.
- Validation: oneof=native reverse oldest newest everywhere.
- Tests: reverse binds via the form wire; messy-dates sequence
  returns 3,2,1; existing reorder-safety stays green.
2026-10-30 09:15:03 -04:00
John O'Keefe 7c7c08748e fix(podcasts): download_all binds on every wire — FlexBool + flip log
Reproduced the §0 item 2 suspect: echo v5 bool-binds form values via
strconv.ParseBool, which accepts "true"/"1" but REJECTS the
browser-native <input type=checkbox> value "on" — and a bind failure
kills the WHOLE AddFeed request (400 "invalid request"), not just
the checkbox. The add modal posts urlencoded (htmx), so any
checkbox-serialization that emits "on" made Download All silently
unusable.

Fix: FlexBool (encoding.TextUnmarshaler-style UnmarshalParam) binds
download_all permissively on every wire — "true"/"on"/"yes"/"checked"
=> true, explicit false/0/off => false, ABSENCE (unchecked box on any
wire) => false; JSON bool binding is native and unaffected. Tolerates
the exact wires htmx and browsers emit.

AddFeed's subscription flip now logs "[PODCAST] flip ... <feed> —
user" so the live flip is provable in app logs (user suspected it
never fired).

Integration test TestPodcastDownloadAllFormEncoding pins the urlencoded
wire (what htmx actually sends): true + on both flip the caller's
policy to 'all', an unchecked add leaves the default.
2026-10-30 09:06:41 -04:00
John O'Keefe cdf81f6cf4 fix(podcasts): page-load polls during downloads; indeterminate bar slides
A fresh detail-page load while downloads were active rendered the
NON-polling episode container (PollingEpisodeList was only reachable
via the /episodes partial route after Refresh), so the page froze
until the user refreshed manually. PodcastDetailPage now picks the
variant the same way the route does — HasActiveDownloads selects the
every-2s poll container.

Also: the indeterminate progress bar (feeds without Content-Length,
e.g. Scribl) used animate-pulse, which fades in place and reads as
frozen; replaced with a sliding shimmer keyframe
(.podcast-progress-indeterminate, 33% sliver, translating -100% ->
300%) in input.css. The live MB counter stays.

§0 item 1.
2026-10-30 08:53:35 -04:00
49 changed files with 4748 additions and 1612 deletions

No files matched your search

+4 -2
View File
@@ -179,10 +179,12 @@ func main() {
matchingHandler := handlers.NewMatchingHandler(queries, connManager) matchingHandler := handlers.NewMatchingHandler(queries, connManager)
jobsHandler := handlers.NewJobsHandler(queries, worker) jobsHandler := handlers.NewJobsHandler(queries, worker)
// Podcast subsystem: fetcher service + sequential download worker + // Podcast subsystem: fetcher service + concurrent download worker
// poller. One instance trio per server (per-feed locks dedupe). // (§0 item 4: pool, fair share, admin tunables) + poller. One
// instance trio per server (per-feed locks dedupe).
podcastService := services.NewPodcastService(queries) podcastService := services.NewPodcastService(queries)
podcastWorker := services.NewPodcastDownloadWorker(queries, podcastService) podcastWorker := services.NewPodcastDownloadWorker(queries, podcastService)
podcastWorker.SetSettings(registry)
podcastHandler := handlers.NewPodcastHandler(queries, podcastService, podcastWorker) podcastHandler := handlers.NewPodcastHandler(queries, podcastService, podcastWorker)
e := echo.New() e := echo.New()
+4
View File
@@ -761,6 +761,10 @@ func TestContractPin_MediaListBothBranches(t *testing.T) {
"declared_duration_seconds", "transcript_url", "chapters_url", "declared_duration_seconds", "transcript_url", "chapters_url",
"hosts", "guests", "download_state", "download_error", "hosts", "guests", "download_state", "download_error",
"download_attempts", "download_attempts",
// §0 item 4 (2026-10-30 additive widening): the download pool's
// queue ticket — SEQUENCE-stamped at enqueue, NULL for scan rows
// and never-queued episodes. See docs/contract_pins.md changelog.
"download_queue_pos",
} }
resolvedKeys := []string{ resolvedKeys := []string{
"id", "library_id", "title", "subtitle", "author", "authors", "isbn", "description", "id", "library_id", "title", "subtitle", "author", "authors", "isbn", "description",
+632 -14
View File
@@ -16,6 +16,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -104,18 +105,18 @@ func createPodcastsLibrary(t *testing.T, setup *TestServerSetup, name string) (d
func upsertTestEpisode(t *testing.T, setup *TestServerSetup, feed database.PodcastFeeds, guid, title string, position int) database.MediaItems { func upsertTestEpisode(t *testing.T, setup *TestServerSetup, feed database.PodcastFeeds, guid, title string, position int) database.MediaItems {
t.Helper() t.Helper()
ep, err := setup.DB.UpsertPodcastEpisode(adminSeedCtx(t, setup), database.UpsertPodcastEpisodeParams{ ep, err := setup.DB.UpsertPodcastEpisode(adminSeedCtx(t, setup), database.UpsertPodcastEpisodeParams{
LibraryID: feed.LibraryID, LibraryID: feed.LibraryID,
Title: title, Title: title,
Authors: []string{}, Authors: []string{},
AuthorsSearch: []string{}, AuthorsSearch: []string{},
FilePath: fmt.Sprintf("test-feed/%04d-%s.mp3", position, guid), FilePath: fmt.Sprintf("test-feed/%04d-%s.mp3", position, guid),
Tags: []string{}, Tags: []string{},
SourceFeedID: feed.ID, SourceFeedID: feed.ID,
EpisodeGuid: pgtype.Text{String: guid, Valid: true}, EpisodeGuid: pgtype.Text{String: guid, Valid: true},
FeedPosition: pgtype.Int4{Int32: int32(position), Valid: true}, FeedPosition: pgtype.Int4{Int32: int32(position), Valid: true},
EnclosureUrl: pgtype.Text{String: "https://cdn.invalid/" + guid + ".mp3", Valid: true}, EnclosureUrl: pgtype.Text{String: "https://cdn.invalid/" + guid + ".mp3", Valid: true},
Hosts: []string{}, Hosts: []string{},
Guests: []string{}, Guests: []string{},
}) })
require.NoError(t, err, "seed episode %q", title) require.NoError(t, err, "seed episode %q", title)
return ep return ep
@@ -422,6 +423,54 @@ func TestPodcastEditSegregation(t *testing.T) {
}) })
} }
// ---- invariant 2, absolute form: the generic DELETE never touches
// episode rows (only whole-feed removal does) ----
func TestDeleteMediaItemPodcastGuard(t *testing.T) {
setup := setupTestServer(t)
lib, _ := createPodcastsLibrary(t, setup, "test DeleteGuard Podcasts")
feed, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/delete-guard.xml",
Title: "DeleteGuard Feed",
})
require.NoError(t, err)
ep := upsertTestEpisode(t, setup, feed, "delguard-1", "Delete Guard Episode", 1)
epID := uuid.UUID(ep.ID.Bytes).String()
t.Run("admin_delete_of_episode_refused", func(t *testing.T) {
code, body := podcastAuthed(t, setup, setup.Token, "DELETE", "/api/media-items/"+epID, "")
assert.Equal(t, http.StatusConflict, code,
"PIN (invariant 2, 2026-10-30): episode rows are removed ONLY by whole-feed removal — the generic delete must 409: %s", string(body))
_, err := setup.DB.GetMediaItem(adminSeedCtx(t, setup), ep.ID)
require.NoError(t, err, "episode row must survive the refused delete")
})
t.Run("book_delete_still_works", func(t *testing.T) {
// Control: the rejection is podcast-specific, not a broken route.
libType, err := setup.DB.GetLibraryTypeByName(seedCtx(), "audiobooks")
require.NoError(t, err)
book, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: func() pgtype.UUID {
lib2, cerr := setup.DB.CreateLibrary(seedCtx(), database.CreateLibraryParams{
Name: "test DeleteGuard Books",
LibraryTypeID: libType.ID,
})
require.NoError(t, cerr)
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(seedCtx(), lib2.ID) })
return lib2.ID
}(),
Title: "Delete Control Book",
FilePath: "control-" + uuid.New().String() + ".m4b",
})
require.NoError(t, err)
code, body := podcastAuthed(t, setup, setup.Token, "DELETE",
"/api/media-items/"+uuid.UUID(book.ID.Bytes).String(), "")
assert.Equal(t, http.StatusNoContent, code,
"generic delete must stay intact for scanned media: %d %s", code, string(body))
})
}
// ---- download worker: state machine, idempotency, file layout ---- // ---- download worker: state machine, idempotency, file layout ----
func TestPodcastDownloadWorker(t *testing.T) { func TestPodcastDownloadWorker(t *testing.T) {
@@ -663,8 +712,6 @@ func adminScopeExec(t *testing.T, setup *TestServerSetup, adminID pgtype.UUID, s
require.NoError(t, err) require.NoError(t, err)
} }
// ---- task 1.9 residue: the htmx wire format is FORM-encoded (issue #2) ---- // ---- task 1.9 residue: the htmx wire format is FORM-encoded (issue #2) ----
func TestPodcastOrderFormEncoding(t *testing.T) { func TestPodcastOrderFormEncoding(t *testing.T) {
@@ -694,10 +741,194 @@ func TestPodcastOrderFormEncoding(t *testing.T) {
assert.Equal(t, http.StatusOK, formPut("order=oldest"), "form-encoded order must bind") assert.Equal(t, http.StatusOK, formPut("order=oldest"), "form-encoded order must bind")
assert.Equal(t, http.StatusOK, formPut("order=native"), "native binds too") assert.Equal(t, http.StatusOK, formPut("order=native"), "native binds too")
assert.Equal(t, http.StatusOK, formPut("order=reverse"), "reverse binds too (§0 item 3)")
assert.Equal(t, http.StatusBadRequest, formPut(""), "missing order stays rejected (required)") assert.Equal(t, http.StatusBadRequest, formPut(""), "missing order stays rejected (required)")
assert.Equal(t, http.StatusBadRequest, formPut("order=sideways"), "invalid order stays rejected") assert.Equal(t, http.StatusBadRequest, formPut("order=sideways"), "invalid order stays rejected")
} }
// ---- §0 item 3: reverse = feed position DESC (no pub dates involved) ----
func TestPodcastReverseOrder(t *testing.T) {
setup := setupTestServer(t)
adminCtx := adminSeedCtx(t, setup)
adminID := adminCtxValue(t, setup)
lib, _ := createPodcastsLibrary(t, setup, "test ReverseOrder Podcasts")
feed, err := setup.DB.CreatePodcastFeed(adminCtx, database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/reverse-order.xml",
Title: "Reverse Order Feed",
})
require.NoError(t, err)
feedIDStr := uuid.UUID(feed.ID.Bytes).String()
// Episodes with MESSY dates (the Scribl condition): all equal.
// reverse must be driven by feed position alone — position 3 first.
epA := upsertTestEpisode(t, setup, feed, "rr-1", "Reverse Alpha", 1)
epB := upsertTestEpisode(t, setup, feed, "rr-2", "Reverse Beta", 2)
epC := upsertTestEpisode(t, setup, feed, "rr-3", "Reverse Gamma", 3)
adminScopeExec(t, setup, adminID, `
UPDATE media_items SET
download_state = 'downloaded',
file_size = 1000,
date_published = '2020-01-01T00:00:00Z'::timestamptz
WHERE id IN ($1, $2, $3) AND source = 'podcast_episode'`, epA.ID, epB.ID, epC.ID)
subscribeRaw(t, setup, uuid.UUID(adminID.Bytes), feed.ID)
// Set the override to reverse (the wire the "Reverse feed order"
// button uses).
formPut := func(body string) int {
req, _ := http.NewRequest(http.MethodPut,
setup.Server.URL+"/api/podcasts/feeds/"+feedIDStr+"/order",
strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
return resp.StatusCode
}
require.Equal(t, http.StatusOK, formPut("order=reverse"))
// The SEQUENCE endpoint must return position 3, 2, 1 — driven by
// feed position DESC, dates being all equal (the Scribl guard).
_, body := podcastAuthed(t, setup, setup.Token, "GET",
"/api/podcasts/feeds/"+feedIDStr+"/sequence", "")
var seq struct {
Order string `json:"order"`
Episodes []struct {
Title string `json:"title"`
FeedPosition int `json:"feed_position"`
} `json:"episodes"`
}
require.NoError(t, json.Unmarshal(body, &seq))
assert.Equal(t, "reverse", seq.Order, "the endpoint echoes the effective order")
require.Len(t, seq.Episodes, 3)
assert.Equal(t, 3, seq.Episodes[0].FeedPosition, "PIN: reverse = position DESC, date-agnostic")
assert.Equal(t, 1, seq.Episodes[2].FeedPosition)
assert.Equal(t, "Reverse Gamma", seq.Episodes[0].Title,
"position 3 first — a messy-date feed reorders by position, not dates")
}
// ---- §0 item 4: concurrent pool — tickets, exclusivity, drain ----
func TestPodcastDownloadPool(t *testing.T) {
setup := setupTestServer(t)
if _, err := exec.LookPath("ffmpeg"); err != nil {
if _, err := os.Stat("/usr/bin/ffmpeg"); err != nil {
t.Skip("ffmpeg unavailable: worker pipeline test needs runtime-generated audio")
}
}
lib, _ := createPodcastsLibrary(t, setup, "test Pool Podcasts")
mp3Path := filepath.Join(t.TempDir(), "tone.mp3")
gen := exec.Command("ffmpeg", "-y", "-v", "error", "-f", "lavfi",
"-i", "sine=frequency=440:duration=1", "-b:a", "32k", mp3Path)
out, err := gen.CombinedOutput()
require.NoError(t, err, "ffmpeg generate: %s", string(out))
audio, err := os.ReadFile(mp3Path)
require.NoError(t, err)
mux := http.NewServeMux()
mux.HandleFunc("/audio/", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "audio/mpeg")
_, _ = w.Write(audio)
})
mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml")
_, _ = w.Write([]byte(podcastFixture("Pool Feed", map[string]string{
"pl-1": "Pool One", "pl-2": "Pool Two", "pl-3": "Pool Three",
})))
})
srv := swapFeedServer(t, mux.ServeHTTP)
libID := uuid.UUID(lib.ID.Bytes).String()
resp, body := podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds",
fmt.Sprintf(`{"library_id":%q,"url":%q}`, libID, srv.URL+"/feed.xml"))
require.Equal(t, http.StatusCreated, resp, string(body))
adminCtx := adminSeedCtx(t, setup)
feeds, err := setup.DB.ListPodcastFeedsForPolling(adminCtx)
require.NoError(t, err)
var feedRow database.PodcastFeeds
for _, f := range feeds {
if f.Title == "Pool Feed" {
feedRow = f
}
}
require.NotZero(t, feedRow.ID, "feed must exist after add")
eps, err := setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID)
require.NoError(t, err)
require.Len(t, eps, 3)
// Point enclosures at the test server (admin-declared write).
adminScope := database.NewScopedDB(setup.DBPool)
_, err = adminScope.Exec(rls.WithUser(context.Background(), adminCtxValue(t, setup)),
`UPDATE media_items SET enclosure_url = $2 WHERE source_feed_id = $1`,
feedRow.ID, srv.URL+"/audio/ep.mp3")
require.NoError(t, err)
worker := services.NewPodcastDownloadWorker(setup.DB, services.NewPodcastService(setup.DB))
actx, err := worker.AdminCtx(context.Background())
require.NoError(t, err)
// Queue in a KNOWN order: tickets (download_queue_pos SEQUENCE) make
// the enqueue loop's ordering the claim order — the §0 item 5
// foundation ("getting the last chapter first doesn't help me").
ordered := make([]database.MediaItems, 0, len(eps))
for _, wantPos := range []int32{1, 2, 3} {
for _, ep := range eps {
if ep.FeedPosition.Int32 == wantPos {
ordered = append(ordered, ep)
}
}
}
for _, ep := range ordered {
_, err := setup.DB.SetPodcastEpisodeDownloadQueued(actx, ep.ID)
require.NoError(t, err)
}
queued, err := setup.DB.ListQueuedPodcastEpisodes(actx)
require.NoError(t, err)
require.Len(t, queued, 3)
for i, ep := range ordered {
assert.Equal(t, ep.ID, queued[i].ID,
"PIN: ticket order = enqueue order (claim claims in enqueue order)")
}
t.Run("claim_is_exclusive", func(t *testing.T) {
first, err := setup.DB.ClaimQueuedPodcastEpisode(actx, queued[0].ID)
require.NoError(t, err)
assert.Equal(t, "downloading", first.DownloadState)
// A second claimant racing the same row gets NOTHING — the
// single-statement guard (queued → downloading) is the
// SKIP-LOCKED-equivalent guarantee.
_, err = setup.DB.ClaimQueuedPodcastEpisode(actx, queued[0].ID)
assert.Error(t, err, "second claim of a downloading row must not match")
// Hand it back for the drain below (enqueue only takes
// pending|failed — a stuck 'downloading' needs the manual reset,
// exactly what the crash-recovery path does).
require.NoError(t, setup.DB.SetPodcastEpisodePending(actx, queued[0].ID))
_, err = setup.DB.SetPodcastEpisodeDownloadQueued(actx, queued[0].ID)
require.NoError(t, err)
})
worker.DrainNow(context.Background())
require.Eventually(t, func() bool {
rowset, err := setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID)
if err != nil {
return false
}
for _, row := range rowset {
if row.DownloadState != "downloaded" {
return false
}
}
return len(rowset) == 3
}, 30*time.Second, 250*time.Millisecond, "all 3 episodes must download through the pool")
}
// ---- issue #8: page search hits EPISODE titles/descriptions too ---- // ---- issue #8: page search hits EPISODE titles/descriptions too ----
func TestPodcastPageSearchEpisodes(t *testing.T) { func TestPodcastPageSearchEpisodes(t *testing.T) {
@@ -733,6 +964,22 @@ func TestPodcastPageSearchEpisodes(t *testing.T) {
// ---- issue #3: Download All flips the CALLER's auto-download to all ---- // ---- issue #3: Download All flips the CALLER's auto-download to all ----
// podcastAuthedForm posts urlencoded (what htmx actually sends for non-GET —
// the bug class in §0 item 2: the add modal posts a form, not JSON).
func podcastAuthedForm(t *testing.T, setup *TestServerSetup, token, method, path string, form url.Values) (int, []byte) {
t.Helper()
req, _ := http.NewRequest(method, setup.Server.URL+path,
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
var buf bytes.Buffer
_, _ = buf.ReadFrom(resp.Body)
return resp.StatusCode, buf.Bytes()
}
func TestPodcastDownloadAllPolicyFlip(t *testing.T) { func TestPodcastDownloadAllPolicyFlip(t *testing.T) {
setup := setupTestServer(t) setup := setupTestServer(t)
regularID := getRegularUserID(t, setup.DB) regularID := getRegularUserID(t, setup.DB)
@@ -807,6 +1054,84 @@ func TestPodcastDownloadAllPolicyFlip(t *testing.T) {
}) })
} }
// ---- §0 item 2: download_all via the WIRE the add modal uses (urlencoded) ----
func TestPodcastDownloadAllFormEncoding(t *testing.T) {
setup := setupTestServer(t)
lib, _ := createPodcastsLibrary(t, setup, "test FormEnc Podcasts")
mux := http.NewServeMux()
mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml")
_, _ = w.Write([]byte(podcastFixture("FormEnc Feed", map[string]string{"fe-1": "FormEnc One"})))
})
mux.HandleFunc("/feed2.xml", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml")
_, _ = w.Write([]byte(podcastFixture("FormEnc Default Feed", map[string]string{"fd-1": "FormEnc Default One"})))
})
mux.HandleFunc("/feed3.xml", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml")
_, _ = w.Write([]byte(podcastFixture("FormEnc On Feed", map[string]string{"fo-1": "FormEnc On One"})))
})
srv := swapFeedServer(t, mux.ServeHTTP)
t.Run("urlencoded_add_with_download_all_true_flips_policy", func(t *testing.T) {
form := url.Values{}
form.Set("library_id", uuid.UUID(lib.ID.Bytes).String())
form.Set("url", srv.URL+"/feed.xml")
form.Set("download_all", "true") // what the add modal's value="true" checkbox submits
resp, body := podcastAuthedForm(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds", form)
require.Equal(t, http.StatusCreated, resp, string(body))
ignore := assertPodcastPolicy(t, setup, "FormEnc Feed", srv.URL+"/feed.xml")
assert.Equal(t, "all", ignore,
"PIN: urlencoded download_all=true binds exactly like JSON — htmx posts forms, not JSON")
})
t.Run("urlencoded_add_with_browser_native_on_also_flips", func(t *testing.T) {
form := url.Values{}
form.Set("library_id", uuid.UUID(lib.ID.Bytes).String())
form.Set("url", srv.URL+"/feed3.xml")
form.Set("download_all", "on") // the raw <input type=checkbox> value — echo's ParseBool chokes on it and failed the WHOLE bind (400) before the FlexBool fix
resp, body := podcastAuthedForm(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds", form)
require.Equal(t, http.StatusCreated, resp, string(body))
assert.Equal(t, "all", assertPodcastPolicy(t, setup, "FormEnc On Feed", srv.URL+"/feed3.xml"))
})
t.Run("urlencoded_add_without_download_all_keeps_default", func(t *testing.T) {
form := url.Values{}
form.Set("library_id", uuid.UUID(lib.ID.Bytes).String())
form.Set("url", srv.URL+"/feed2.xml")
resp, body := podcastAuthedForm(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds", form)
require.Equal(t, http.StatusCreated, resp, string(body))
assert.Equal(t, "off", assertPodcastPolicy(t, setup, "FormEnc Default Feed", srv.URL+"/feed2.xml"),
"PIN: an UNchecked add must NOT flip the policy (absent field ≠ true)")
})
}
// assertPodcastPolicy returns the regular user's auto_download for the
// subscribed feed identified by (title, url).
func assertPodcastPolicy(t *testing.T, setup *TestServerSetup, title, feedURL string) string {
t.Helper()
regularID := getRegularUserID(t, setup.DB)
regularPG := pgtype.UUID{Bytes: regularID, Valid: true}
feeds, _ := setup.DB.ListPodcastFeedsForPolling(rls.WithUser(context.Background(), regularPG))
var feedID pgtype.UUID
for _, f := range feeds {
if f.Title == title && f.FeedUrl == feedURL {
feedID = f.ID
}
}
require.True(t, feedID.Valid, "subscribed feed %q (%s) must exist", title, feedURL)
sub, err := setup.DB.GetPodcastSubscription(rls.WithUser(context.Background(), regularPG), database.GetPodcastSubscriptionParams{
UserID: regularPG, FeedID: feedID,
})
require.NoError(t, err)
return sub.AutoDownload
}
// ---- logout actually signs out (fake-signout bug, 2026-10-30) ---- // ---- logout actually signs out (fake-signout bug, 2026-10-30) ----
func TestLogoutClearsSession(t *testing.T) { func TestLogoutClearsSession(t *testing.T) {
@@ -882,3 +1207,296 @@ func TestLogoutClearsSession(t *testing.T) {
} }
assert.True(t, cleared, "cookie clear must not depend on the request body") assert.True(t, cleared, "cookie clear must not depend on the request body")
} }
// ---- §0 item 5: add-modal Download order — override + queue order ----
func TestPodcastAddModalDownloadOrder(t *testing.T) {
setup := setupTestServer(t)
lib, _ := createPodcastsLibrary(t, setup, "test AddOrder Podcasts")
mux := http.NewServeMux()
mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml")
_, _ = w.Write([]byte(podcastFixture("AddOrder Feed", map[string]string{
"ao-1": "AddOrder One", "ao-2": "AddOrder Two", "ao-3": "AddOrder Three",
})))
})
srv := swapFeedServer(t, mux.ServeHTTP)
form := url.Values{}
form.Set("library_id", uuid.UUID(lib.ID.Bytes).String())
form.Set("url", srv.URL+"/feed.xml")
form.Set("download_all", "true")
form.Set("download_order", "reverse") // the add-modal dropdown (§0 item 5c)
resp, body := podcastAuthedForm(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds", form)
require.Equal(t, http.StatusCreated, resp, string(body))
regularID := getRegularUserID(t, setup.DB)
regularPG := pgtype.UUID{Bytes: regularID, Valid: true}
rctx := rls.WithUser(context.Background(), regularPG)
workerCtx := adminSeedCtx(t, setup)
feeds, _ := setup.DB.ListPodcastFeedsForPolling(rctx)
var feedID pgtype.UUID
for _, f := range feeds {
if f.Title == "AddOrder Feed" {
feedID = f.ID
}
}
require.True(t, feedID.Valid, "subscribed feed must exist")
// PIN 1: the override row exists (the dropdown IS the initial order).
row, err := setup.DB.GetUserSettingByKey(rctx, database.GetUserSettingByKeyParams{
UserID: regularPG, SettingKey: "podcast_order:" + feedID.String(),
})
require.NoError(t, err)
assert.Contains(t, string(row), "reverse", "PIN: the add-modal dropdown sets the caller's order override")
// PIN 2: the queue follows the LISTENING order — with reverse, the
// tickets run position 3 → 1 (claim order = download order). Read
// the PERSISTED tickets rather than live queued rows: the shared
// dev server's worker races this test's queue and flips rows to
// failed (its container can't reach the fixture URLs) — tickets
// survive every state flip.
var tickets []struct {
ID string
Pos int64
}
adminScope := database.NewScopedDB(setup.DBPool)
ticketRows, err := adminScope.Query(workerCtx,
`SELECT id::TEXT, download_queue_pos FROM media_items
WHERE source_feed_id = $1 AND download_queue_pos IS NOT NULL
ORDER BY download_queue_pos ASC`, feedID)
require.NoError(t, err)
for ticketRows.Next() {
var id string
var pos int64
require.NoError(t, ticketRows.Scan(&id, &pos), "scan ticket")
tickets = append(tickets, struct {
ID string
Pos int64
}{id, pos})
}
ticketRows.Close()
require.Len(t, tickets, 3, "all 3 episodes got tickets")
eps, err := setup.DB.ListPodcastEpisodes(workerCtx, feedID)
require.NoError(t, err)
byID := map[string]int32{}
for _, ep := range eps {
byID[uuid.UUID(ep.ID.Bytes).String()] = ep.FeedPosition.Int32
}
assert.Equal(t, int32(3), byID[tickets[0].ID], "PIN: reverse download order — position 3 claims first")
assert.Equal(t, int32(1), byID[tickets[2].ID])
}
// ---- §0 item 8: admin all-podcasts view + file maintenance ----
func TestAdminPodcastsView(t *testing.T) {
setup := setupTestServer(t)
lib, _ := createPodcastsLibrary(t, setup, "test AdminView Podcasts")
mux := http.NewServeMux()
mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml")
_, _ = w.Write([]byte(podcastFixture("AdminView Feed", map[string]string{"av-1": "AdminView One"})))
})
srv := swapFeedServer(t, mux.ServeHTTP)
// Subscribe the REGULAR user: the admin view must show their feed +
// their name even though the library was created by/for the test.
form := url.Values{}
form.Set("library_id", uuid.UUID(lib.ID.Bytes).String())
form.Set("url", srv.URL+"/feed.xml")
resp, body := podcastAuthedForm(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds", form)
require.Equal(t, http.StatusCreated, resp, string(body))
// Admin sees the page with the regular user's feed + their name.
code, page := podcastAuthed(t, setup, setup.Token, "GET", "/admin/podcasts", "")
require.Equal(t, http.StatusOK, code, string(page))
assert.Contains(t, string(page), "AdminView Feed",
"PIN: the admin view lists feeds the admin does not subscribe to (RLS admin escape)")
assert.Contains(t, string(page), "All Podcasts")
regularName := "testregularuser"
assert.Contains(t, string(page), regularName, "subscriber names render")
// Regular users are locked out of the page AND the maintenance actions.
code, _ = podcastAuthed(t, setup, setup.RegularToken, "GET", "/admin/podcasts", "")
assert.Equal(t, http.StatusForbidden, code, "PIN: non-admins never see the all-podcasts view")
feeds, _ := setup.DB.ListPodcastFeedsForPolling(adminSeedCtx(t, setup))
var feedIDStr string
for _, f := range feeds {
if f.Title == "AdminView Feed" {
feedIDStr = uuid.UUID(f.ID.Bytes).String()
}
}
require.NotEmpty(t, feedIDStr)
code, _ = podcastAuthed(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds/"+feedIDStr+"/delete-files", "")
assert.Equal(t, http.StatusForbidden, code, "PIN: non-admins cannot delete files")
}
func TestAdminDeleteEpisodeFiles(t *testing.T) {
setup := setupTestServer(t)
if _, err := exec.LookPath("ffmpeg"); err != nil {
if _, err := os.Stat("/usr/bin/ffmpeg"); err != nil {
t.Skip("ffmpeg unavailable: worker pipeline test needs runtime-generated audio")
}
}
lib, root := createPodcastsLibrary(t, setup, "test AdminFiles Podcasts")
mp3Path := filepath.Join(t.TempDir(), "tone.mp3")
gen := exec.Command("ffmpeg", "-y", "-v", "error", "-f", "lavfi",
"-i", "sine=frequency=440:duration=1", "-b:a", "32k", mp3Path)
out, err := gen.CombinedOutput()
require.NoError(t, err, "ffmpeg generate: %s", string(out))
audio, err := os.ReadFile(mp3Path)
require.NoError(t, err)
mux := http.NewServeMux()
mux.HandleFunc("/audio/", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "audio/mpeg")
_, _ = w.Write(audio)
})
mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml")
_, _ = w.Write([]byte(podcastFixture("AdminFiles Feed", map[string]string{
"af-1": "AdminFiles One", "af-2": "AdminFiles Two",
})))
})
srv := swapFeedServer(t, mux.ServeHTTP)
form := url.Values{}
form.Set("library_id", uuid.UUID(lib.ID.Bytes).String())
form.Set("url", srv.URL+"/feed.xml")
resp, body := podcastAuthedForm(t, setup, setup.Token, "POST", "/api/podcasts/feeds", form)
require.Equal(t, http.StatusCreated, resp, string(body))
adminCtx := adminSeedCtx(t, setup)
feeds, err := setup.DB.ListPodcastFeedsForPolling(adminCtx)
require.NoError(t, err)
var feedRow database.PodcastFeeds
for _, f := range feeds {
if f.Title == "AdminFiles Feed" {
feedRow = f
}
}
require.NotZero(t, feedRow.ID, "feed must exist")
feedIDStr := uuid.UUID(feedRow.ID.Bytes).String()
// Point enclosures at the fixture server, THEN queue + drain
// immediately: queueing after re-pointing keeps the dev server's
// worker (which can't reach the fixture URL and would fail the
// rows) out of the race.
adminScope := database.NewScopedDB(setup.DBPool)
_, err = adminScope.Exec(rls.WithUser(context.Background(), adminCtxValue(t, setup)),
`UPDATE media_items SET enclosure_url = $2 WHERE source_feed_id = $1`,
feedRow.ID, srv.URL+"/audio/ep.mp3")
require.NoError(t, err)
worker := services.NewPodcastDownloadWorker(setup.DB, services.NewPodcastService(setup.DB))
eps, err := setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID)
require.NoError(t, err)
for _, ep := range eps {
_, err := setup.DB.SetPodcastEpisodeDownloadQueued(adminCtx, ep.ID)
require.NoError(t, err)
}
worker.DrainNow(context.Background())
require.Eventually(t, func() bool {
eps, err := setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID)
if err != nil || len(eps) != 2 {
return false
}
for _, ep := range eps {
if ep.DownloadState != "downloaded" {
return false
}
}
return true
}, 30*time.Second, 250*time.Millisecond, "both episodes downloaded")
eps, err = setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID)
require.NoError(t, err)
ep1, ep2 := eps[0], eps[1]
pathFor := func(ep database.MediaItems) string { return filepath.Join(root, ep.FilePath) }
require.FileExists(t, pathFor(ep1))
require.FileExists(t, pathFor(ep2))
// --- single-episode delete-file (admin) ---
ep1Str := uuid.UUID(ep1.ID.Bytes).String()
code, rbody := podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds/"+feedIDStr+"/episodes/"+ep1Str+"/delete-file", "")
require.Equal(t, http.StatusOK, code, string(rbody))
after1, err := setup.DB.GetPodcastEpisode(adminCtx, ep1.ID)
require.NoError(t, err)
assert.Equal(t, "pending", after1.DownloadState, "PIN: deleted-file episode resets to pending")
assert.False(t, after1.FileSize.Valid, "file facts cleared")
assert.Nil(t, after1.AudioMetadata, "audio_metadata cleared (it described the deleted file)")
after2, err := setup.DB.GetPodcastEpisode(adminCtx, ep2.ID)
require.NoError(t, err)
assert.Equal(t, "downloaded", after2.DownloadState, "the sibling episode is untouched")
assert.NoFileExists(t, pathFor(after1), "disk file removed")
assert.FileExists(t, pathFor(after2), "sibling file kept")
// --- per-feed delete-files (admin) ---
code, rbody = podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds/"+feedIDStr+"/delete-files", "")
require.Equal(t, http.StatusOK, code, string(rbody))
assert.Contains(t, string(rbody), "reset_episodes")
after2, err = setup.DB.GetPodcastEpisode(adminCtx, ep2.ID)
require.NoError(t, err)
assert.Equal(t, "pending", after2.DownloadState)
assert.NoFileExists(t, pathFor(after2), "feed-level deletion clears the remaining file")
// --- bulk multi-select delete-files (§0 item 8 follow-up) ---
// Re-download both, then delete them in ONE request with a forged
// foreign id mixed in: only the matching-feed ids are applied.
for _, ep := range []database.MediaItems{ep1, ep2} {
require.NoError(t, setup.DB.SetPodcastEpisodePending(adminCtx, ep.ID))
_, err = setup.DB.SetPodcastEpisodeDownloadQueued(adminCtx, ep.ID)
require.NoError(t, err)
}
worker.DrainNow(context.Background())
require.Eventually(t, func() bool {
eps, err := setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID)
if err != nil || len(eps) != 2 {
return false
}
for _, ep := range eps {
if ep.DownloadState != "downloaded" {
return false
}
}
return true
}, 30*time.Second, 250*time.Millisecond, "both re-downloaded for the bulk test")
foreignID := uuid.New().String()
bulkForm := url.Values{}
bulkForm["episode-file-select"] = []string{uuid.UUID(ep1.ID.Bytes).String(), foreignID, uuid.UUID(ep2.ID.Bytes).String()}
code, rbody = podcastAuthedForm(t, setup, setup.Token, "POST", "/api/podcasts/feeds/"+feedIDStr+"/episodes/delete-files", bulkForm)
require.Equal(t, http.StatusOK, code, string(rbody))
assert.Contains(t, string(rbody), `"deleted":2`, "the forged foreign id is refused, the real two applied")
assert.NoFileExists(t, pathFor(ep1))
assert.NoFileExists(t, pathFor(ep2))
afterBulk, err := setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID)
require.NoError(t, err)
for _, ep := range afterBulk {
assert.Equal(t, "pending", ep.DownloadState, "bulk deletion resets every selected row (%s)", ep.Title)
}
// Non-admins cannot bulk-delete either.
code, _ = podcastAuthed(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds/"+feedIDStr+"/episodes/delete-files", "")
assert.Equal(t, http.StatusForbidden, code, "PIN: non-admins cannot bulk-delete files")
// --- refresh must NOT re-materialize deleted files (invariant 2):
// the poll upsert rewrites metadata but never touches download_state.
code, rbody = podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds/"+feedIDStr+"/refresh", "")
require.Equal(t, http.StatusOK, code, string(rbody))
refreshed, err := setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID)
require.NoError(t, err)
require.Len(t, refreshed, 2)
for _, ep := range refreshed {
assert.Equal(t, "pending", ep.DownloadState,
"PIN: a feed refresh does not resurrect deleted downloads (%s)", ep.Title)
assert.NoFileExists(t, pathFor(ep), "no file re-materialized without an explicit download")
}
}
+15 -2
View File
@@ -102,7 +102,9 @@ INSERT INTO system_settings (setting_key, setting_value, description, setting_ty
('worker_pool_size', '3', 'Number of background worker goroutines', 'int', '1', '100', TRUE, 'performance'), ('worker_pool_size', '3', 'Number of background worker goroutines', 'int', '1', '100', TRUE, 'performance'),
('worker_queue_cap', '100', 'Background worker job queue capacity', 'int', '1', '10000', TRUE, 'performance'), ('worker_queue_cap', '100', 'Background worker job queue capacity', 'int', '1', '10000', TRUE, 'performance'),
-- podcasts -- podcasts
('podcast_poll_interval_seconds', '3600', 'How often to poll podcast feeds for new episodes (seconds; complete feeds back off)', 'int', '300', '604800', FALSE, 'podcasts') ('podcast_poll_interval_seconds', '3600', 'How often to poll podcast feeds for new episodes (seconds; complete feeds back off)', 'int', '300', '604800', FALSE, 'podcasts'),
('podcast_download_concurrency', '4', 'Podcast episode downloads in flight server-wide (the connection is the limit; fair-share spreads them across feeds)', 'int', '1', '16', FALSE, 'podcasts'),
('podcast_download_speed_mbps', '0', 'Shared bandwidth cap for podcast downloads in megabits/second (0 = unlimited)', 'float', '0', '10000', FALSE, 'podcasts')
ON CONFLICT (setting_key) DO NOTHING; ON CONFLICT (setting_key) DO NOTHING;
-- Create refresh_tokens table -- Create refresh_tokens table
@@ -1989,7 +1991,16 @@ ALTER TABLE media_items
-- list renders this inline (D8/D19) — never Processing Issues. -- list renders this inline (D8/D19) — never Processing Issues.
ADD COLUMN IF NOT EXISTS download_state VARCHAR(20) NOT NULL DEFAULT 'pending', ADD COLUMN IF NOT EXISTS download_state VARCHAR(20) NOT NULL DEFAULT 'pending',
ADD COLUMN IF NOT EXISTS download_error TEXT, ADD COLUMN IF NOT EXISTS download_error TEXT,
ADD COLUMN IF NOT EXISTS download_attempts INT NOT NULL DEFAULT 0; ADD COLUMN IF NOT EXISTS download_attempts INT NOT NULL DEFAULT 0,
-- Monotonic queue ticket (Postgres SEQUENCE nextval at enqueue
-- time): the download pool claims in ticket order, so the enqueue
-- loop's ordering IS the download order (effective listening order
-- — §0 items 4/5). NULL for rows never queued.
ADD COLUMN IF NOT EXISTS download_queue_pos BIGINT;
-- Queue ticket source: atomic across concurrent enqueues (per-feed
-- locks serialize within a feed; the sequence covers across feeds).
CREATE SEQUENCE IF NOT EXISTS podcast_queue_seq;
-- Identity + ordering + worker-worklist indexes (all partial: scan -- Identity + ordering + worker-worklist indexes (all partial: scan
-- rows never touch these columns). -- rows never touch these columns).
@@ -1999,6 +2010,8 @@ CREATE INDEX IF NOT EXISTS idx_media_items_feed_pos
ON media_items(source_feed_id, feed_position) WHERE source_feed_id IS NOT NULL; ON media_items(source_feed_id, feed_position) WHERE source_feed_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_media_items_feed_undownloaded CREATE INDEX IF NOT EXISTS idx_media_items_feed_undownloaded
ON media_items(source_feed_id) WHERE source_feed_id IS NOT NULL AND download_state <> 'downloaded'; ON media_items(source_feed_id) WHERE source_feed_id IS NOT NULL AND download_state <> 'downloaded';
CREATE INDEX IF NOT EXISTS idx_media_items_queue
ON media_items(download_queue_pos) WHERE source = 'podcast_episode' AND download_state = 'queued';
-- ============================================================ -- ============================================================
+9
View File
@@ -459,3 +459,12 @@ resolved unscoped map:
Scan-sourced rows carry the inert defaults ('scan' / NULLs / 'pending'); Scan-sourced rows carry the inert defaults ('scan' / NULLs / 'pending');
raw-row consumers that filter `source='scan'` (scanner paths) are unaffected. raw-row consumers that filter `source='scan'` (scanner paths) are unaffected.
Nothing removed, nothing renamed. Nothing removed, nothing renamed.
## 2026-10-30 — download_queue_pos joins media_items (additive widening)
§0 item 4 of podcast-integration.md: the download pool's monotonic
queue ticket. `download_queue_pos BIGINT` (NULL for scan rows and
never-queued episodes) is stamped from the `podcast_queue_seq` SEQUENCE
at enqueue time so claim order = enqueue order (the effective listening
order). Raw-row pins (TestContractPin_MediaListBothBranches →
rawRowKeys) whitelist the key. Nothing removed, nothing renamed.
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.31.1 // sqlc v1.31.0
package database package database
+2 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.31.1 // sqlc v1.31.0
package database package database
@@ -355,6 +355,7 @@ type MediaItems struct {
DownloadState string `db:"download_state" json:"download_state"` DownloadState string `db:"download_state" json:"download_state"`
DownloadError pgtype.Text `db:"download_error" json:"download_error"` DownloadError pgtype.Text `db:"download_error" json:"download_error"`
DownloadAttempts int32 `db:"download_attempts" json:"download_attempts"` DownloadAttempts int32 `db:"download_attempts" json:"download_attempts"`
DownloadQueuePos pgtype.Int8 `db:"download_queue_pos" json:"download_queue_pos"`
} }
type MediaNotes struct { type MediaNotes struct {
+35 -6
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.31.1 // sqlc v1.31.0
package database package database
@@ -32,6 +32,11 @@ type Querier interface {
BulkUpdateFormatGroups(ctx context.Context) error BulkUpdateFormatGroups(ctx context.Context) error
BulkUpdateProgressFromSync(ctx context.Context, arg BulkUpdateProgressFromSyncParams) ([]interface{}, error) BulkUpdateProgressFromSync(ctx context.Context, arg BulkUpdateProgressFromSyncParams) ([]interface{}, error)
CheckForProgressConflicts(ctx context.Context, arg CheckForProgressConflictsParams) (int64, error) CheckForProgressConflicts(ctx context.Context, arg CheckForProgressConflictsParams) (int64, error)
// Atomic claim (§0 item 4): converts queued -> downloading in ONE
// statement. Two claimants racing on the same row: exactly one UPDATE
// matches download_state='queued'; the loser gets no rows (ErrNoRows).
// This is the SKIP-LOCKED guarantee without a second round-trip.
ClaimQueuedPodcastEpisode(ctx context.Context, id pgtype.UUID) (MediaItems, error)
// Cleanup expired OPDS tokens // Cleanup expired OPDS tokens
CleanupExpiredOpdsTokens(ctx context.Context) error CleanupExpiredOpdsTokens(ctx context.Context) error
CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error
@@ -446,8 +451,6 @@ type Querier interface {
// GetMediaScanState reads one item's manifest (per-item rescan paths // GetMediaScanState reads one item's manifest (per-item rescan paths
// and the manifest integration tests). // and the manifest integration tests).
GetMediaScanState(ctx context.Context, mediaItemID pgtype.UUID) (MediaScanState, error) GetMediaScanState(ctx context.Context, mediaItemID pgtype.UUID) (MediaScanState, error)
// Sequential download worker's claim source (single worker, day one).
GetNextQueuedEpisode(ctx context.Context) (MediaItems, error)
GetNextRetryTime(ctx context.Context) (interface{}, error) GetNextRetryTime(ctx context.Context) (interface{}, error)
GetNotStartedItems(ctx context.Context, arg GetNotStartedItemsParams) ([]MediaItems, error) GetNotStartedItems(ctx context.Context, arg GetNotStartedItemsParams) ([]MediaItems, error)
// Get OPDS token // Get OPDS token
@@ -599,6 +602,12 @@ type Querier interface {
// Link unlinked book to media item // Link unlinked book to media item
LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookParams) (UnlinkedBooks, error) LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookParams) (UnlinkedBooks, error)
ListAllConflictsByUserAndStatus(ctx context.Context, arg ListAllConflictsByUserAndStatusParams) ([]ListAllConflictsByUserAndStatusRow, error) ListAllConflictsByUserAndStatus(ctx context.Context, arg ListAllConflictsByUserAndStatusParams) ([]ListAllConflictsByUserAndStatusRow, error)
// Admin all-podcasts view (§0 item 8 / Phase-4 item 1): EVERY feed from
// EVERY user with its maintenance aggregates. Runs under the first-
// admin identity (invariant 12 — same contract as the poller). Sorted
// by disk usage DESC: the maintenance question is "what's eating the
// disk", so the hogs surface first.
ListAllPodcastFeedsAdmin(ctx context.Context) ([]ListAllPodcastFeedsAdminRow, error)
ListAllSyncQueueItems(ctx context.Context, arg ListAllSyncQueueItemsParams) ([]ListAllSyncQueueItemsRow, error) ListAllSyncQueueItems(ctx context.Context, arg ListAllSyncQueueItemsParams) ([]ListAllSyncQueueItemsRow, error)
ListConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListConflictsByUserRow, error) ListConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListConflictsByUserRow, error)
// ============================================ // ============================================
@@ -656,6 +665,11 @@ type Querier interface {
// Item-side projections COALESCE to keep them non-nullable strings; // Item-side projections COALESCE to keep them non-nullable strings;
// the display file path falls back to the issue's own for row-less rows. // the display file path falls back to the issue's own for row-less rows.
ListProcessingIssuesByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListProcessingIssuesByLibraryRow, error) ListProcessingIssuesByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListProcessingIssuesByLibraryRow, error)
// Fair-share candidate scan (§0 item 4): the download pool claims in
// TICKET order (download_queue_pos = SEQUENCE nextval at enqueue — the
// enqueue loop's ordering IS the download order, item 5). The worker
// groups these per feed and prefers the least-recently-served feed.
ListQueuedPodcastEpisodes(ctx context.Context) ([]ListQueuedPodcastEpisodesRow, error)
ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error) ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error)
ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error) ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error)
// Download All + policy union worklist. // Download All + policy union worklist.
@@ -668,6 +682,9 @@ type Querier interface {
ListUsers(ctx context.Context) ([]ListUsersRow, error) ListUsers(ctx context.Context) ([]ListUsersRow, error)
// First consecutive scan that cannot find the file on disk. // First consecutive scan that cannot find the file on disk.
MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error
// SetPodcastEpisodeDownloading RETIRED (§0 item 4): the pair
// GetNextQueuedEpisode + SetPodcastEpisodeDownloading raced under
// concurrency; ClaimQueuedPodcastEpisode does both atomically.
// SUCCESS-only write (invariant 7): the file is on disk and probed. // SUCCESS-only write (invariant 7): the file is on disk and probed.
// ffprobe facts (real duration via audio_metadata, precise size/mime) // ffprobe facts (real duration via audio_metadata, precise size/mime)
// reconcile the feed's declared values. // reconcile the feed's declared values.
@@ -710,6 +727,17 @@ type Querier interface {
ReorderCollectionItems(ctx context.Context, arg ReorderCollectionItemsParams) error ReorderCollectionItems(ctx context.Context, arg ReorderCollectionItemsParams) error
// Re-parent all child rows of p_source onto p_target (defined in schema.sql) // Re-parent all child rows of p_source onto p_target (defined in schema.sql)
ReparentMediaItemChildren(ctx context.Context, arg ReparentMediaItemChildrenParams) error ReparentMediaItemChildren(ctx context.Context, arg ReparentMediaItemChildrenParams) error
// Admin file deletion (§0 item 8): ONE downloaded episode's disk file
// was removed — reset the row to pending and clear the download-derived
// facts. file_path is deliberately KEPT: it is the fetch-maintained
// layout (written at fetch time, not download time), so a re-download
// lands on the same path. The poll upsert never touches download_state,
// so a deleted file stays deleted (invariant 2: polling never deletes;
// admins delete knowingly).
ResetPodcastEpisodeFileDeleted(ctx context.Context, id pgtype.UUID) error
// Same reset for EVERY downloaded episode of a feed (per-feed
// "Delete files" action on the admin view).
ResetPodcastFeedFilesDeleted(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error)
ResetSystemCollectionMetadata(ctx context.Context, arg ResetSystemCollectionMetadataParams) error ResetSystemCollectionMetadata(ctx context.Context, arg ResetSystemCollectionMetadataParams) error
ResolveHashConflict(ctx context.Context, arg ResolveHashConflictParams) error ResolveHashConflict(ctx context.Context, arg ResolveHashConflictParams) error
// Ledger self-heal (faceted plan Phase 11): a scan that processes the // Ledger self-heal (faceted plan Phase 11): a scan that processes the
@@ -753,10 +781,11 @@ type Querier interface {
SetDeviceSyncEnabled(ctx context.Context, arg SetDeviceSyncEnabledParams) (Devices, error) SetDeviceSyncEnabled(ctx context.Context, arg SetDeviceSyncEnabledParams) (Devices, error)
// Library Visibility queries // Library Visibility queries
SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibilityParams) (LibraryVisibility, error) SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibilityParams) (LibraryVisibility, error)
// Worker claim: pending|failed -> queued. Attempts incremented at // Enqueue: pending|failed -> queued, stamped with the NEXT QUEUE TICKET
// claim time so a crashed worker still counts the try. // (§0 items 4/5) so claim order = enqueue order (the effective
// listening order the enqueue loop applied). Attempts increment at
// enqueue so a crashed worker still counts the try.
SetPodcastEpisodeDownloadQueued(ctx context.Context, id pgtype.UUID) (int64, error) SetPodcastEpisodeDownloadQueued(ctx context.Context, id pgtype.UUID) (int64, error)
SetPodcastEpisodeDownloading(ctx context.Context, id pgtype.UUID) error
// Manual retry (same endpoint as download): reset to pending so the // Manual retry (same endpoint as download): reset to pending so the
// worker re-claims it; the reason text clears with the fresh attempt. // worker re-claims it; the reason text clears with the fresh attempt.
SetPodcastEpisodePending(ctx context.Context, id pgtype.UUID) error SetPodcastEpisodePending(ctx context.Context, id pgtype.UUID) error
File diff suppressed because it is too large. Load diff
+89 -11
View File
@@ -4698,6 +4698,68 @@ WHERE feed_id = $1;
-- name: CountPodcastFeedSubscribers :one -- name: CountPodcastFeedSubscribers :one
SELECT COUNT(*) FROM podcast_subscriptions WHERE feed_id = $1; SELECT COUNT(*) FROM podcast_subscriptions WHERE feed_id = $1;
-- name: ListAllPodcastFeedsAdmin :many
-- Admin all-podcasts view (§0 item 8 / Phase-4 item 1): EVERY feed from
-- EVERY user with its maintenance aggregates. Runs under the first-
-- admin identity (invariant 12 — same contract as the poller). Sorted
-- by disk usage DESC: the maintenance question is "what's eating the
-- disk", so the hogs surface first.
SELECT f.id, f.title, f.host, f.last_fetched_at, f.library_id,
l.name AS library_name,
(SELECT COUNT(*) FROM media_items m
WHERE m.source_feed_id = f.id AND m.source = 'podcast_episode') AS episode_total,
(SELECT COUNT(*) FROM media_items m
WHERE m.source_feed_id = f.id AND m.source = 'podcast_episode'
AND m.download_state = 'downloaded') AS episode_downloaded,
(SELECT COALESCE(SUM(m.file_size), 0)::BIGINT FROM media_items m
WHERE m.source_feed_id = f.id AND m.source = 'podcast_episode'
AND m.download_state = 'downloaded') AS disk_bytes,
(SELECT COUNT(*) FROM podcast_subscriptions s
WHERE s.feed_id = f.id) AS subscriber_count,
(SELECT COALESCE(string_agg(u.username, ', ' ORDER BY u.username), '')::TEXT
FROM podcast_subscriptions s
JOIN users u ON u.id = s.user_id
WHERE s.feed_id = f.id) AS subscriber_names
FROM podcast_feeds f
JOIN libraries l ON l.id = f.library_id
ORDER BY 9 DESC, f.title ASC;
-- name: ResetPodcastEpisodeFileDeleted :exec
-- Admin file deletion (§0 item 8): ONE downloaded episode's disk file
-- was removed — reset the row to pending and clear the download-derived
-- facts. file_path is deliberately KEPT: it is the fetch-maintained
-- layout (written at fetch time, not download time), so a re-download
-- lands on the same path. The poll upsert never touches download_state,
-- so a deleted file stays deleted (invariant 2: polling never deletes;
-- admins delete knowingly).
UPDATE media_items
SET download_state = 'pending',
download_error = NULL,
download_attempts = 0,
file_size = NULL,
mime_type = NULL,
cover_image_path = NULL,
audio_metadata = NULL,
chapter_count = NULL,
updated_at = NOW()
WHERE id = $1 AND source = 'podcast_episode' AND download_state = 'downloaded';
-- name: ResetPodcastFeedFilesDeleted :execrows
-- Same reset for EVERY downloaded episode of a feed (per-feed
-- "Delete files" action on the admin view).
UPDATE media_items
SET download_state = 'pending',
download_error = NULL,
download_attempts = 0,
file_size = NULL,
mime_type = NULL,
cover_image_path = NULL,
audio_metadata = NULL,
chapter_count = NULL,
updated_at = NOW()
WHERE source_feed_id = $1 AND source = 'podcast_episode'
AND download_state = 'downloaded';
-- name: ListPodcastFeedsForPolling :many -- name: ListPodcastFeedsForPolling :many
-- Poller worklist (admin identity): every feed with its validators and -- Poller worklist (admin identity): every feed with its validators and
-- the back-off flag (is_complete feeds poll rarely, never disappear). -- the back-off flag (is_complete feeds poll rarely, never disappear).
@@ -4862,10 +4924,13 @@ SELECT * FROM media_items
WHERE id = $1 AND source = 'podcast_episode'; WHERE id = $1 AND source = 'podcast_episode';
-- name: SetPodcastEpisodeDownloadQueued :execrows -- name: SetPodcastEpisodeDownloadQueued :execrows
-- Worker claim: pending|failed -> queued. Attempts incremented at -- Enqueue: pending|failed -> queued, stamped with the NEXT QUEUE TICKET
-- claim time so a crashed worker still counts the try. -- (§0 items 4/5) so claim order = enqueue order (the effective
-- listening order the enqueue loop applied). Attempts increment at
-- enqueue so a crashed worker still counts the try.
UPDATE media_items UPDATE media_items
SET download_state = 'queued', SET download_state = 'queued',
download_queue_pos = nextval('podcast_queue_seq'),
download_error = NULL, download_error = NULL,
download_attempts = download_attempts + 1, download_attempts = download_attempts + 1,
updated_at = NOW() updated_at = NOW()
@@ -4873,10 +4938,9 @@ WHERE id = $1
AND source = 'podcast_episode' AND source = 'podcast_episode'
AND download_state IN ('pending', 'failed'); AND download_state IN ('pending', 'failed');
-- name: SetPodcastEpisodeDownloading :exec -- SetPodcastEpisodeDownloading RETIRED (§0 item 4): the pair
UPDATE media_items -- GetNextQueuedEpisode + SetPodcastEpisodeDownloading raced under
SET download_state = 'downloading', updated_at = NOW() -- concurrency; ClaimQueuedPodcastEpisode does both atomically.
WHERE id = $1 AND source = 'podcast_episode';
-- name: MarkPodcastEpisodeDownloaded :one -- name: MarkPodcastEpisodeDownloaded :one
-- SUCCESS-only write (invariant 7): the file is on disk and probed. -- SUCCESS-only write (invariant 7): the file is on disk and probed.
@@ -4964,12 +5028,26 @@ WHERE s.user_id = sqlc.arg('user_id')
OR COALESCE(f.publisher, '') ILIKE '%' || sqlc.arg('search_query')::text || '%') OR COALESCE(f.publisher, '') ILIKE '%' || sqlc.arg('search_query')::text || '%')
ORDER BY COALESCE(s.display_name, f.title) ASC; ORDER BY COALESCE(s.display_name, f.title) ASC;
-- name: GetNextQueuedEpisode :one -- name: ListQueuedPodcastEpisodes :many
-- Sequential download worker's claim source (single worker, day one). -- Fair-share candidate scan (§0 item 4): the download pool claims in
SELECT * FROM media_items -- TICKET order (download_queue_pos = SEQUENCE nextval at enqueue — the
-- enqueue loop's ordering IS the download order, item 5). The worker
-- groups these per feed and prefers the least-recently-served feed.
SELECT id, source_feed_id FROM media_items
WHERE source = 'podcast_episode' AND download_state = 'queued' WHERE source = 'podcast_episode' AND download_state = 'queued'
ORDER BY updated_at ASC ORDER BY download_queue_pos ASC NULLS LAST;
LIMIT 1;
-- name: ClaimQueuedPodcastEpisode :one
-- Atomic claim (§0 item 4): converts queued -> downloading in ONE
-- statement. Two claimants racing on the same row: exactly one UPDATE
-- matches download_state='queued'; the loser gets no rows (ErrNoRows).
-- This is the SKIP-LOCKED guarantee without a second round-trip.
UPDATE media_items
SET download_state = 'downloading', updated_at = NOW()
WHERE id = $1
AND source = 'podcast_episode'
AND download_state = 'queued'
RETURNING media_items.*;
-- name: CountQueuedEpisodes :one -- name: CountQueuedEpisodes :one
SELECT COUNT(*) FROM media_items SELECT COUNT(*) FROM media_items
+37 -3
View File
@@ -25,6 +25,7 @@ import (
// SettingType enumerates the value types stored in system_settings.setting_type. // SettingType enumerates the value types stored in system_settings.setting_type.
const ( const (
SettingTypeInt = "int" SettingTypeInt = "int"
SettingTypeFloat = "float"
SettingTypeBool = "bool" SettingTypeBool = "bool"
SettingTypeString = "string" SettingTypeString = "string"
SettingTypeStringList = "string_list" SettingTypeStringList = "string_list"
@@ -83,6 +84,8 @@ var SettingDefaults = []SettingDefault{
{Key: "worker_pool_size", Value: "3", Type: SettingTypeInt, Min: "1", Max: "100", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Number of background worker goroutines"}, {Key: "worker_pool_size", Value: "3", Type: SettingTypeInt, Min: "1", Max: "100", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Number of background worker goroutines"},
{Key: "worker_queue_cap", Value: "100", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Background worker job queue capacity"}, {Key: "worker_queue_cap", Value: "100", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Background worker job queue capacity"},
{Key: "podcast_poll_interval_seconds", Value: "3600", Type: SettingTypeInt, Min: "300", Max: "604800", Category: "podcasts", Group: "Podcasts", Description: "How often to poll podcast feeds for new episodes (seconds; complete feeds back off)"}, {Key: "podcast_poll_interval_seconds", Value: "3600", Type: SettingTypeInt, Min: "300", Max: "604800", Category: "podcasts", Group: "Podcasts", Description: "How often to poll podcast feeds for new episodes (seconds; complete feeds back off)"},
{Key: "podcast_download_concurrency", Value: "4", Type: SettingTypeInt, Min: "1", Max: "16", Category: "podcasts", Group: "Podcasts", Description: "Podcast episode downloads in flight server-wide (the connection is the limit; fair-share spreads them across feeds)"},
{Key: "podcast_download_speed_mbps", Value: "0", Type: SettingTypeFloat, Min: "0", Max: "10000", Category: "podcasts", Group: "Podcasts", Description: "Shared bandwidth cap for podcast downloads in megabits/second (0 = unlimited)"},
} }
// defaultBy indexes SettingDefaults by key for O(1) lookup. // defaultBy indexes SettingDefaults by key for O(1) lookup.
@@ -173,6 +176,26 @@ func (r *SettingsRegistry) getInt(key string) int {
return n return n
} }
func (r *SettingsRegistry) getFloat(key string) float64 {
d := defaultBy[key]
v := r.raw(key)
f, err := strconv.ParseFloat(v, 64)
if err != nil {
f, _ = strconv.ParseFloat(d.Value, 64)
}
if d.Min != "" {
if mn, err := strconv.ParseFloat(d.Min, 64); err == nil && f < mn {
f = mn
}
}
if d.Max != "" {
if mx, err := strconv.ParseFloat(d.Max, 64); err == nil && f > mx {
f = mx
}
}
return f
}
func (r *SettingsRegistry) getBool(key string) bool { func (r *SettingsRegistry) getBool(key string) bool {
v := r.raw(key) v := r.raw(key)
b, err := strconv.ParseBool(v) b, err := strconv.ParseBool(v)
@@ -189,13 +212,24 @@ func (r *SettingsRegistry) ScanPollInterval() time.Duration {
return time.Duration(r.getInt("scan_poll_interval_seconds")) * time.Second return time.Duration(r.getInt("scan_poll_interval_seconds")) * time.Second
} }
// PodcastPollInterval is the feed-poller tick (live setting; the poller // PodcastPollInterval is how often the poller checks feeds, as a
// re-reads it every cycle — writes go through the settings API path, // duration (live setting; the poller re-reads it every cycle — writes
// AGENTS.md gotcha #2). // go through the settings API path, AGENTS.md gotcha #2).
func (r *SettingsRegistry) PodcastPollInterval() time.Duration { func (r *SettingsRegistry) PodcastPollInterval() time.Duration {
return time.Duration(r.getInt("podcast_poll_interval_seconds")) * time.Second return time.Duration(r.getInt("podcast_poll_interval_seconds")) * time.Second
} }
// PodcastDownloadConcurrency: episode downloads in flight server-wide
// (the connection is the limit; fair-share spreads them across feeds).
func (r *SettingsRegistry) PodcastDownloadConcurrency() int {
return r.getInt("podcast_download_concurrency")
}
// PodcastDownloadSpeedMbps: shared bandwidth cap (0 = unlimited).
func (r *SettingsRegistry) PodcastDownloadSpeedMbps() float64 {
return r.getFloat("podcast_download_speed_mbps")
}
// AutoScanEnabled reports whether auto-scanning is on. // AutoScanEnabled reports whether auto-scanning is on.
func (r *SettingsRegistry) AutoScanEnabled() bool { return r.getBool("auto_scan_enabled") } func (r *SettingsRegistry) AutoScanEnabled() bool { return r.getBool("auto_scan_enabled") }
@@ -36,6 +36,21 @@ func TestSettingDefaults(t *testing.T) {
if _, err := strconv.ParseBool(d.Value); err != nil { if _, err := strconv.ParseBool(d.Value); err != nil {
t.Errorf("bool setting %s default %q is not a bool", d.Key, d.Value) t.Errorf("bool setting %s default %q is not a bool", d.Key, d.Value)
} }
case SettingTypeFloat:
f, err := strconv.ParseFloat(d.Value, 64)
if err != nil {
t.Errorf("float setting %s default %q is not a float", d.Key, d.Value)
}
if d.Min != "" {
if mn, err := strconv.ParseFloat(d.Min, 64); err == nil && f < mn {
t.Errorf("float setting %s default %q below min %q", d.Key, d.Value, d.Min)
}
}
if d.Max != "" {
if mx, err := strconv.ParseFloat(d.Max, 64); err == nil && f > mx {
t.Errorf("float setting %s default %q above max %q", d.Key, d.Value, d.Max)
}
}
case SettingTypeString: case SettingTypeString:
if d.Value == "" { if d.Value == "" {
t.Errorf("string setting %s has empty default", d.Key) t.Errorf("string setting %s has empty default", d.Key)
+11
View File
@@ -1881,6 +1881,17 @@ func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
} }
// Invariant 2 (podcast plan §10, absolute form): episode rows are
// removed ONLY when the whole feed is removed — the feed must match
// its XML until then. Same segregation as the edit path (D9/D10):
// episodes are fetcher-owned; delete FILES via the podcast surfaces
// (feed page / admin podcast view) instead.
if media.Source == "podcast_episode" {
return c.JSON(http.StatusConflict, map[string]string{
"error": "podcast episodes are removed only by removing the whole feed — delete the episode's files instead (feed page or admin podcast view)",
})
}
// Storage-down guard (see HandleBulkDelete): refuse while the library // Storage-down guard (see HandleBulkDelete): refuse while the library
// storage is unreachable so the cascade can't destroy annotations for // storage is unreachable so the cascade can't destroy annotations for
// a file that will re-import on the next scan. // a file that will re-import on the next scan.
+194 -46
View File
@@ -9,7 +9,7 @@ package handlers
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "log"
"sort" "sort"
"bookhoard/internal/database" "bookhoard/internal/database"
@@ -38,8 +38,11 @@ type PodcastHandler struct {
// EpisodeListRenderer renders the post-action episode list for an htmx // EpisodeListRenderer renders the post-action episode list for an htmx
// request. includePolicySelect additionally out-of-band-swaps the // request. includePolicySelect additionally out-of-band-swaps the
// auto-download select (Download All flips the caller's policy — the // auto-download select (Download All flips the caller's policy — the
// dropdown must reflect it immediately, issue #3). // dropdown must reflect it immediately, issue #3);
type EpisodeListRenderer func(c *echo.Context, feedID string, includePolicySelect bool) error // includeOrderSelect likewise rides along for the order endpoint (the
// "Reverse feed order" button flips to reverse — the dropdown must
// mirror the server state, §0 item 3).
type EpisodeListRenderer func(c *echo.Context, feedID string, includePolicySelect, includeOrderSelect bool) error
// SetEpisodeListRenderer wires the renderer (router-only; product wiring). // SetEpisodeListRenderer wires the renderer (router-only; product wiring).
func (h *PodcastHandler) SetEpisodeListRenderer(r EpisodeListRenderer) { func (h *PodcastHandler) SetEpisodeListRenderer(r EpisodeListRenderer) {
@@ -48,13 +51,127 @@ func (h *PodcastHandler) SetEpisodeListRenderer(r EpisodeListRenderer) {
// hxEpisodeList answers an htmx action with the refreshed partial when // hxEpisodeList answers an htmx action with the refreshed partial when
// a renderer is wired; otherwise falls back to 204 + HX-Trigger. // a renderer is wired; otherwise falls back to 204 + HX-Trigger.
func (h *PodcastHandler) hxEpisodeList(c *echo.Context, feedID string, includePolicySelect bool) error { func (h *PodcastHandler) hxEpisodeList(c *echo.Context, feedID string, includePolicySelect, includeOrderSelect bool) error {
if h.episodeListRenderer != nil { if h.episodeListRenderer != nil {
return h.episodeListRenderer(c, feedID, includePolicySelect) return h.episodeListRenderer(c, feedID, includePolicySelect, includeOrderSelect)
} }
return hxAccepted(c, "") return hxAccepted(c, "")
} }
// AdminDeleteFeedFiles POST /api/podcasts/feeds/:id/delete-files —
// admin-only (§0 item 8): delete every downloaded episode file of the
// feed; rows reset to pending. HX answers HX-Refresh (the admin table
// re-renders with fresh aggregates).
func (h *PodcastHandler) AdminDeleteFeedFiles(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
if user.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin only"})
}
feedID, err := parseUUID(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
}
// media_items writes run under the first-admin identity (invariant
// 12 — same contract as the fetcher/worker).
actx, err := h.worker.AdminCtx(c.Request().Context())
if err != nil {
return podcastErrorResponse(c, err)
}
reset, err := h.podcasts.AdminDeleteFeedFiles(actx, feedID)
if err != nil {
return podcastErrorResponse(c, err)
}
log.Printf("[PODCAST] admin: deleted files for %d episode(s) of feed %s (user %s)", reset, feedID, user.Username)
if isHXRequest(c) {
c.Response().Header().Set("HX-Refresh", "true")
return c.JSON(http.StatusOK, map[string]interface{}{"reset_episodes": reset})
}
return c.JSON(http.StatusOK, map[string]interface{}{"reset_episodes": reset})
}
// AdminDeleteEpisodeFile POST /api/podcasts/episodes/:id/delete-file —
// admin-only (§0 item 8): ONE episode's file deleted, row reset to
// pending. HX answers with the refreshed episode list.
func (h *PodcastHandler) AdminDeleteEpisodeFile(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
if user.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin only"})
}
episodeID, err := parseUUID(c.Param("eid"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid episode id"})
}
actx, err := h.worker.AdminCtx(c.Request().Context())
if err != nil {
return podcastErrorResponse(c, err)
}
if err := h.podcasts.AdminDeleteEpisodeFile(actx, episodeID); err != nil {
return podcastErrorResponse(c, err)
}
log.Printf("[PODCAST] admin: deleted file of episode %s (user %s)", episodeID, user.Username)
if isHXRequest(c) {
// Re-render the episode list: the row needs its fresh state. The
// episode's feed id comes from the row we just reset.
if ep, err := h.db.GetPodcastEpisode(actx, episodeID); err == nil && ep.SourceFeedID.Valid {
return h.hxEpisodeList(c, ep.SourceFeedID.String(), false, false)
}
return hxAccepted(c, "")
}
return c.JSON(http.StatusOK, map[string]string{"message": "file deleted"})
}
// AdminDeleteEpisodeFilesBulk POST /api/podcasts/feeds/:id/episodes/delete-files —
// admin-only (§0 item 8): multi-select file deletion. Body carries the
// CHECKED boxes' values (form-encoded, repeated episode-file-select
// keys — hx-include gathers them; parsed directly, never bound: the
// repeated-key wire is another echo-bind gotcha). Each id is validated
// to belong to THIS feed; rows reset to pending, files removed.
func (h *PodcastHandler) AdminDeleteEpisodeFilesBulk(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
if user.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin only"})
}
feedID, err := parseUUID(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
}
if err := c.Request().ParseForm(); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
rawIDs := c.Request().PostForm["episode-file-select"]
if len(rawIDs) == 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "no episodes selected"})
}
actx, err := h.worker.AdminCtx(c.Request().Context())
if err != nil {
return podcastErrorResponse(c, err)
}
deleted := 0
for _, raw := range rawIDs {
episodeID, perr := parseUUID(raw)
if perr != nil {
continue
}
// The checkbox list only renders this feed's rows, but the wire
// is user-controlled: a forged id for a foreign feed's episode
// is refused (feed-match guard), not silently applied.
ep, gerr := h.db.GetPodcastEpisode(actx, episodeID)
if gerr != nil || ep.SourceFeedID != feedID {
continue
}
if derr := h.podcasts.AdminDeleteEpisodeFile(actx, episodeID); derr == nil {
deleted++
}
}
log.Printf("[PODCAST] admin: bulk-deleted files for %d/%d episode(s) of feed %s (user %s)",
deleted, len(rawIDs), feedID, user.Username)
if isHXRequest(c) {
return h.hxEpisodeList(c, feedID.String(), false, false)
}
return c.JSON(http.StatusOK, map[string]interface{}{"deleted": deleted})
}
// flipSubscriptionToAll implements issue #3: Download All (button or // flipSubscriptionToAll implements issue #3: Download All (button or
// add-feed checkbox) sets the CALLER's subscription policy to 'all' — // add-feed checkbox) sets the CALLER's subscription policy to 'all' —
// per-user, never global (files are shared D6, policies are not). The // per-user, never global (files are shared D6, policies are not). The
@@ -74,6 +191,7 @@ func (h *PodcastHandler) flipSubscriptionToAll(ctx context.Context, userID, feed
AutoDownload: "all", AutoDownload: "all",
AutoDownloadCount: pgtype.Int4{}, // all needs no count AutoDownloadCount: pgtype.Int4{}, // all needs no count
}) })
log.Printf("[PODCAST] flip: user %s → policy 'all' for feed %s (Download All / add checkbox)", userID, feedID)
} }
func NewPodcastHandler(db *database.Queries, svc *services.PodcastService, worker *services.PodcastDownloadWorker) *PodcastHandler { func NewPodcastHandler(db *database.Queries, svc *services.PodcastService, worker *services.PodcastDownloadWorker) *PodcastHandler {
@@ -83,9 +201,35 @@ func NewPodcastHandler(db *database.Queries, svc *services.PodcastService, worke
// ---- request/response shapes ---- // ---- request/response shapes ----
type AddPodcastFeedRequest struct { type AddPodcastFeedRequest struct {
LibraryID string `json:"library_id" form:"library_id" validate:"required"` LibraryID string `json:"library_id" form:"library_id" validate:"required"`
URL string `json:"url" form:"url" validate:"required"` URL string `json:"url" form:"url" validate:"required"`
DownloadAll bool `json:"download_all" form:"download_all"` DownloadAll FlexBool `json:"download_all" form:"download_all"`
// DownloadOrder: the initial listening order for THIS feed, applied
// only with DownloadAll (§0 item 5c) — it both seeds the queue in
// listening order and sets the caller's order override. ""/"feed" =
// no override row.
DownloadOrder string `json:"download_order,omitempty" form:"download_order"`
}
// FlexBool binds checkbox-style booleans on every wire. echo bool-binds
// via strconv.ParseBool, which accepts "true"/"1" but REJECTS the
// browser-native checkbox value "on" — and a bind failure is fatal for
// the WHOLE request (the add modal would 400 "invalid request" on any
// form variant that posts "on"). §0 item 2 (2026-10-30).
//
// A checkbox only contributes its field when CHECKED, so for form
// posts presence really is the signal — JSON's native bool binding
// ("false"→false) is unaffected. Absence on either wire = false.
type FlexBool bool
func (b *FlexBool) UnmarshalParam(v string) error {
switch strings.ToLower(strings.TrimSpace(v)) {
case "false", "0", "off", "n", "no":
*b = false
default:
*b = true // "true", "on", "yes", "checked", "1" — or any truthy variant
}
return nil
} }
type UpdatePodcastSubscriptionRequest struct { type UpdatePodcastSubscriptionRequest struct {
@@ -130,11 +274,11 @@ func podcastErrorResponse(c *echo.Context, err error) error {
// feed counts are tens, not thousands; no facet SQL needed). // feed counts are tens, not thousands; no facet SQL needed).
type podcastFeedSummary struct { type podcastFeedSummary struct {
Feed Feed
SubscribedAt string `json:"subscribed_at"` SubscribedAt string `json:"subscribed_at"`
EpisodeTotal int `json:"episode_total"` EpisodeTotal int `json:"episode_total"`
DownloadedN int `json:"episode_downloaded"` DownloadedN int `json:"episode_downloaded"`
UndownloadedN int `json:"episode_undownloaded"` UndownloadedN int `json:"episode_undownloaded"`
NewEpisodes bool `json:"new_episodes"` NewEpisodes bool `json:"new_episodes"`
} }
// AddFeed POST /api/podcasts/feeds // AddFeed POST /api/podcasts/feeds
@@ -157,7 +301,23 @@ func (h *PodcastHandler) AddFeed(c *echo.Context) error {
return podcastErrorResponse(c, err) return podcastErrorResponse(c, err)
} }
if req.DownloadAll && res.Feed != nil { if req.DownloadAll && res.Feed != nil {
if _, err := h.worker.QueueFeedDownloads(c.Request().Context(), res.Feed.ID); err != nil { // §0 item 5c: the add modal's order dropdown is the feed's
// INITIAL listening order — it IS the download order (the queue
// seeds in that order below). 'feed'/'native' = no override row.
order := "native"
switch req.DownloadOrder {
case "reverse", "oldest", "newest":
order = req.DownloadOrder
payload, _ := json.Marshal(map[string]string{"order": order})
if err := h.db.UpsertUserSettingByKey(c.Request().Context(), database.UpsertUserSettingByKeyParams{
UserID: user.ID, SettingKey: podcastOrderSettingKey(res.Feed.ID.String()), SettingValue: payload,
}); err != nil {
// subscription + queue stand; the dropdown just won't
// preselect it (surfaced on the next order set).
_ = err
}
}
if _, err := h.worker.QueueFeedDownloadsAs(c.Request().Context(), user.ID, res.Feed.ID); err != nil {
// subscription stands; download-all failure surfaces via the // subscription stands; download-all failure surfaces via the
// episode list states. // episode list states.
} }
@@ -245,10 +405,10 @@ func (h *PodcastHandler) GetFeed(c *echo.Context) error {
episodeViews = append(episodeViews, episodeView(&eps[i])) episodeViews = append(episodeViews, episodeView(&eps[i]))
} }
return c.JSON(http.StatusOK, map[string]interface{}{ return c.JSON(http.StatusOK, map[string]interface{}{
"feed": feedWithSubscription(&feed, sub, subFound == nil), "feed": feedWithSubscription(&feed, sub, subFound == nil),
"episodes": episodeViews, "episodes": episodeViews,
"gap_count": countGaps(episodeViews), "gap_count": countGaps(episodeViews),
"undownloaded": len(episodeViews) - countDownloaded(episodeViews), "undownloaded": len(episodeViews) - countDownloaded(episodeViews),
"has_library_access": true, // RLS already gated the row; kept for client convenience "has_library_access": true, // RLS already gated the row; kept for client convenience
}) })
} }
@@ -305,7 +465,7 @@ func (h *PodcastHandler) UpdateSubscription(c *echo.Context) error {
if isHXRequest(c) { if isHXRequest(c) {
// Policy changes feed the auto-download union on the next poll. // Policy changes feed the auto-download union on the next poll.
_, _ = h.worker.ApplyAutoPolicies(ctx, feedID) _, _ = h.worker.ApplyAutoPolicies(ctx, feedID)
return h.hxEpisodeList(c, feedID.String(), true) return h.hxEpisodeList(c, feedID.String(), true, false)
} }
return c.JSON(http.StatusOK, updated) return c.JSON(http.StatusOK, updated)
} }
@@ -344,7 +504,7 @@ func (h *PodcastHandler) RefreshFeed(c *echo.Context) error {
return podcastErrorResponse(c, err) return podcastErrorResponse(c, err)
} }
if isHXRequest(c) { if isHXRequest(c) {
return h.hxEpisodeList(c, feedID.String(), false) return h.hxEpisodeList(c, feedID.String(), false, false)
} }
return c.JSON(http.StatusOK, map[string]string{"message": "refreshed"}) return c.JSON(http.StatusOK, map[string]string{"message": "refreshed"})
} }
@@ -387,7 +547,8 @@ func (h *PodcastHandler) DownloadEpisodes(c *echo.Context) error {
eidRaw := c.Param("eid") eidRaw := c.Param("eid")
if eidRaw == "all" { if eidRaw == "all" {
// Download All affordance: exactly the non-downloaded set (pin). // Download All affordance: exactly the non-downloaded set (pin).
queued, err := h.worker.QueueFeedDownloads(ctx, feedID) // Queues in the CALLER's listening order (§0 item 5b).
queued, err := h.worker.QueueFeedDownloadsAs(ctx, user.ID, feedID)
if err != nil { if err != nil {
return podcastErrorResponse(c, err) return podcastErrorResponse(c, err)
} }
@@ -395,7 +556,7 @@ func (h *PodcastHandler) DownloadEpisodes(c *echo.Context) error {
// Download All flips the caller's policy to all (issue #3) // Download All flips the caller's policy to all (issue #3)
// — the oob select keeps the dropdown honest. // — the oob select keeps the dropdown honest.
h.flipSubscriptionToAll(ctx, user.ID, feedID) h.flipSubscriptionToAll(ctx, user.ID, feedID)
return h.hxEpisodeList(c, feedID.String(), true) return h.hxEpisodeList(c, feedID.String(), true, false)
} }
return c.JSON(http.StatusAccepted, map[string]interface{}{"queued": queued}) return c.JSON(http.StatusAccepted, map[string]interface{}{"queued": queued})
} }
@@ -407,7 +568,7 @@ func (h *PodcastHandler) DownloadEpisodes(c *echo.Context) error {
return podcastErrorResponse(c, err) return podcastErrorResponse(c, err)
} }
if isHXRequest(c) { if isHXRequest(c) {
return h.hxEpisodeList(c, feedID.String(), false) return h.hxEpisodeList(c, feedID.String(), false, false)
} }
return c.JSON(http.StatusAccepted, map[string]string{"message": "queued"}) return c.JSON(http.StatusAccepted, map[string]string{"message": "queued"})
} }
@@ -480,9 +641,10 @@ func podcastOrderSettingKey(feedID string) string {
return "podcast_order:" + feedID return "podcast_order:" + feedID
} }
// podcastOrderValid: native (feed position) | oldest | newest. // podcastOrderValid native (feed position) | reverse (position DESC,
// no dates) | oldest | newest.
func podcastOrderValid(o string) bool { func podcastOrderValid(o string) bool {
return o == "native" || o == "oldest" || o == "newest" return services.ValidPodcastEpisodeOrder(o)
} }
// GetFeedOrder reads the caller's effective order for a feed // GetFeedOrder reads the caller's effective order for a feed
@@ -515,7 +677,7 @@ func (h *PodcastHandler) SetFeedOrder(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"}) return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
} }
var req struct { var req struct {
Order string `json:"order" form:"order" validate:"required,oneof=native oldest newest"` Order string `json:"order" form:"order" validate:"required,oneof=native reverse oldest newest"`
} }
if err := c.Bind(&req); err != nil { if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
@@ -541,7 +703,7 @@ func (h *PodcastHandler) SetFeedOrder(c *echo.Context) error {
} }
} }
if isHXRequest(c) { if isHXRequest(c) {
return h.hxEpisodeList(c, feedID.String(), false) return h.hxEpisodeList(c, feedID.String(), false, true)
} }
return c.JSON(http.StatusOK, map[string]string{"order": req.Order}) return c.JSON(http.StatusOK, map[string]string{"order": req.Order})
} }
@@ -567,19 +729,11 @@ func (h *PodcastHandler) GetFeedSequence(c *echo.Context) error {
for i := range eps { for i := range eps {
views = append(views, episodeView(&eps[i])) views = append(views, episodeView(&eps[i]))
} }
sortSlice(views, func(a, b PodcastEpisodeView) bool { // Effective listening order (order consolidation §0 item 5a).
switch order { services.OrderEpisodes(views, order,
case "oldest": func(v PodcastEpisodeView) int32 { return int32(v.FeedPosition) },
return episodeSortKey(a) < episodeSortKey(b) func(v PodcastEpisodeView) string { return v.Date },
case "newest": )
return episodeSortKey(a) > episodeSortKey(b)
default: // native: feed position (fetch-maintained)
if a.FeedPosition != b.FeedPosition {
return a.FeedPosition < b.FeedPosition
}
return a.Date < b.Date
}
})
sequence := make([]PodcastEpisodeView, 0, len(views)) sequence := make([]PodcastEpisodeView, 0, len(views))
gaps := make([]PodcastEpisodeView, 0) gaps := make([]PodcastEpisodeView, 0)
@@ -598,12 +752,6 @@ func (h *PodcastHandler) GetFeedSequence(c *echo.Context) error {
}) })
} }
// episodeSortKey: chronological key for oldest/newest (date, then
// native position as the same-day tiebreaker).
func episodeSortKey(v PodcastEpisodeView) string {
return v.Date + fmt.Sprintf("%06d", v.FeedPosition)
}
// sortSlice: sort.Slice with a controller-friendly wrapper. // sortSlice: sort.Slice with a controller-friendly wrapper.
func sortSlice[T any](s []T, less func(a, b T) bool) { func sortSlice[T any](s []T, less func(a, b T) bool) {
sort.Slice(s, func(i, j int) bool { return less(s[i], s[j]) }) sort.Slice(s, func(i, j int) bool { return less(s[i], s[j]) })
+15
View File
@@ -178,6 +178,21 @@ func validateSettingValue(def database.SettingDefault, value string) error {
if _, err := strconv.ParseBool(value); err != nil { if _, err := strconv.ParseBool(value); err != nil {
return fmt.Errorf("value must be true or false") return fmt.Errorf("value must be true or false")
} }
case database.SettingTypeFloat:
f, err := strconv.ParseFloat(value, 64)
if err != nil {
return fmt.Errorf("value must be a number")
}
if def.Min != "" {
if mn, err := strconv.ParseFloat(def.Min, 64); err == nil && f < mn {
return fmt.Errorf("value must be >= %s", def.Min)
}
}
if def.Max != "" {
if mx, err := strconv.ParseFloat(def.Max, 64); err == nil && f > mx {
return fmt.Errorf("value must be <= %s", def.Max)
}
}
case database.SettingTypeString: case database.SettingTypeString:
if value == "" { if value == "" {
return fmt.Errorf("value must not be empty") return fmt.Errorf("value must not be empty")
+58 -2
View File
@@ -14,6 +14,7 @@ import (
"bookhoard/internal/config" "bookhoard/internal/config"
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/rls"
"bookhoard/internal/handlers" "bookhoard/internal/handlers"
"bookhoard/internal/services" "bookhoard/internal/services"
"bookhoard/internal/utils" "bookhoard/internal/utils"
@@ -1507,8 +1508,7 @@ func registerFrontendRoutes(cfg *Config) {
// Admin hash conflicts page: content-duplicate groups flagged during hash // Admin hash conflicts page: content-duplicate groups flagged during hash
// backfill or rescan, resolved by keeping all copies or merging into one. // backfill or rescan, resolved by keeping all copies or merging into one.
frontendProtected.GET("/admin/hash-conflicts", handlers.AdminMiddleware(func(c *echo.Context) error { frontendProtected.GET("/admin/hash-conflicts", handlers.AdminMiddleware(func(c *echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg)
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil { if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error") return renderErrorPage(c, "Error loading user", "user_load_error")
} }
@@ -1562,6 +1562,62 @@ func registerFrontendRoutes(cfg *Config) {
return c.HTML(http.StatusOK, buf.String()) return c.HTML(http.StatusOK, buf.String())
})) }))
// Admin all-podcasts view (§0 item 8): every feed from every user with
// maintenance aggregates (disk usage first). The aggregate read runs
// under the FIRST-ADMIN identity — the RLS admin escape (invariant
// 12, poller/fetcher contract) — so admins see feeds from libraries
// hidden even from them.
frontendProtected.GET("/admin/podcasts", handlers.AdminMiddleware(func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
adminUUID, err := cfg.Queries.GetFirstAdmin(context.Background())
if err != nil {
return renderErrorPage(c, "Error loading podcasts", "podcasts_load_error")
}
// cfg.Queries is ScopedDB-backed: the context identity is all the
// aggregate read needs.
actx := rls.WithUser(c.Request().Context(), adminUUID)
rows, err := cfg.Queries.ListAllPodcastFeedsAdmin(actx)
if err != nil {
return renderErrorPage(c, "Error loading podcasts", "podcasts_load_error")
}
feeds := make([]templates.AdminPodcastFeedData, 0, len(rows))
var totalBytes int64
totalDownloaded, totalEpisodes := 0, 0
for _, r := range rows {
lastFetch := "never"
if r.LastFetchedAt.Valid {
lastFetch = r.LastFetchedAt.Time.In(utils.UserLocation(user.Timezone)).Format("Jan 2, 2006 15:04")
}
feeds = append(feeds, templates.AdminPodcastFeedData{
ID: uuid.UUID(r.ID.Bytes).String(),
Title: r.Title,
Host: r.Host.String,
LibraryName: r.LibraryName,
Subscribers: int(r.SubscriberCount),
SubscriberNames: r.SubscriberNames,
EpisodeTotal: int(r.EpisodeTotal),
EpisodeDownloaded: int(r.EpisodeDownloaded),
DiskBytes: r.DiskBytes,
LastFetched: lastFetch,
})
totalBytes += r.DiskBytes
totalEpisodes += int(r.EpisodeTotal)
totalDownloaded += int(r.EpisodeDownloaded)
}
var buf bytes.Buffer
if err := templates.AdminPodcasts(user, feeds, len(feeds), totalDownloaded, totalEpisodes, totalBytes).Render(c.Request().Context(), &buf); err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
}))
// Admin users page // Admin users page
frontendProtected.GET("/admin/users", handlers.AdminMiddleware(func(c *echo.Context) error { frontendProtected.GET("/admin/users", handlers.AdminMiddleware(func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg) user, err := getTemplateUserWithTheme(c, cfg)
+16 -1
View File
@@ -8,6 +8,7 @@ import (
"bytes" "bytes"
"net/http" "net/http"
"bookhoard/internal/handlers"
"bookhoard/templates" "bookhoard/templates"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
@@ -20,7 +21,7 @@ func registerPodcastRoutes(cfg *Config) {
// refreshed partial directly — no event indirection to miss. The // refreshed partial directly — no event indirection to miss. The
// view assembly lives here (templates) while the action logic lives // view assembly lives here (templates) while the action logic lives
// in the handler; this closure is the sanctioned seam. // in the handler; this closure is the sanctioned seam.
cfg.PodcastHandler.SetEpisodeListRenderer(func(c *echo.Context, feedID string, includePolicySelect bool) error { cfg.PodcastHandler.SetEpisodeListRenderer(func(c *echo.Context, feedID string, includePolicySelect, includeOrderSelect bool) error {
user, err := getTemplateUserWithTheme(c, cfg) user, err := getTemplateUserWithTheme(c, cfg)
if err != nil { if err != nil {
return err return err
@@ -48,6 +49,14 @@ func registerPodcastRoutes(cfg *Config) {
return err return err
} }
} }
if includeOrderSelect {
// §0 item 3: out-of-band swap of the order select — the
// "Reverse feed order" button flips to reverse; the
// dropdown mirrors the server state immediately.
if err := templates.PodcastOrderSelectOOB(data.Feed.ID, data.Order).Render(c.Request().Context(), &buf); err != nil {
return err
}
}
return c.HTML(http.StatusOK, buf.String()) return c.HTML(http.StatusOK, buf.String())
}) })
@@ -65,4 +74,10 @@ func registerPodcastRoutes(cfg *Config) {
feeds.POST("/refresh", cfg.PodcastHandler.RefreshFeed) feeds.POST("/refresh", cfg.PodcastHandler.RefreshFeed)
feeds.POST("/episodes/:eid/download", cfg.PodcastHandler.DownloadEpisodes) feeds.POST("/episodes/:eid/download", cfg.PodcastHandler.DownloadEpisodes)
feeds.DELETE("", cfg.PodcastHandler.DeleteFeed) // admin-only (handler checks role) feeds.DELETE("", cfg.PodcastHandler.DeleteFeed) // admin-only (handler checks role)
// Admin maintenance (§0 item 8): file deletion frees the disk and
// resets rows to pending. Handler checks admin role.
feeds.POST("/delete-files", handlers.AdminMiddleware(cfg.PodcastHandler.AdminDeleteFeedFiles))
feeds.POST("/episodes/delete-files", handlers.AdminMiddleware(cfg.PodcastHandler.AdminDeleteEpisodeFilesBulk))
feeds.POST("/episodes/:eid/delete-file", handlers.AdminMiddleware(cfg.PodcastHandler.AdminDeleteEpisodeFile))
} }
+103 -96
View File
@@ -9,6 +9,7 @@ package router
import ( import (
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/handlers" "bookhoard/internal/handlers"
"bookhoard/internal/services"
"bookhoard/internal/utils" "bookhoard/internal/utils"
"bookhoard/templates" "bookhoard/templates"
"bytes" "bytes"
@@ -105,9 +106,9 @@ func buildPodcastsPageData(c *echo.Context, cfg *Config) (*templates.PodcastsPag
} }
for _, lib := range libs { for _, lib := range libs {
data.Libraries = append(data.Libraries, templates.LibraryData{ data.Libraries = append(data.Libraries, templates.LibraryData{
ID: uuid.UUID(lib.ID.Bytes).String(), ID: uuid.UUID(lib.ID.Bytes).String(),
Name: lib.Name, Name: lib.Name,
TypeName: "podcasts", TypeName: "podcasts",
}) })
} }
@@ -132,30 +133,30 @@ func buildPodcastsPageData(c *echo.Context, cfg *Config) (*templates.PodcastsPag
total, _ := cfg.Queries.CountPodcastEpisodesForFeed(ctx, row.ID) total, _ := cfg.Queries.CountPodcastEpisodesForFeed(ctx, row.ID)
downloaded, _ := cfg.Queries.CountDownloadedEpisodes(ctx, row.ID) downloaded, _ := cfg.Queries.CountDownloadedEpisodes(ctx, row.ID)
data.Feeds = append(data.Feeds, templates.PodcastFeedCard{ data.Feeds = append(data.Feeds, templates.PodcastFeedCard{
ID: uuid.UUID(row.ID.Bytes).String(), ID: uuid.UUID(row.ID.Bytes).String(),
LibraryID: uuid.UUID(row.LibraryID.Bytes).String(), LibraryID: uuid.UUID(row.LibraryID.Bytes).String(),
Title: row.Title, Title: row.Title,
DisplayName: row.DisplayName.String, DisplayName: row.DisplayName.String,
Description: firstLine(row.Description.String, 220), Description: firstLine(row.Description.String, 220),
Host: row.Host.String, Host: row.Host.String,
Publisher: row.Publisher.String, Publisher: row.Publisher.String,
Language: row.Language.String, Language: row.Language.String,
Tags: row.Tags, Tags: row.Tags,
Explicit: row.Explicit, Explicit: row.Explicit,
ArtworkPath: row.ArtworkPath.String, ArtworkPath: row.ArtworkPath.String,
FeedType: row.FeedType.String, FeedType: row.FeedType.String,
Medium: row.Medium.String, Medium: row.Medium.String,
IsComplete: row.IsComplete, IsComplete: row.IsComplete,
FeedURL: row.FeedUrl, FeedURL: row.FeedUrl,
LastFetched: pgTimeDisplay(row.LastFetchedAt, utils.PgTextString(user.Timezone)), LastFetched: pgTimeDisplay(row.LastFetchedAt, utils.PgTextString(user.Timezone)),
SubscribedAt: pgTimeDisplay(row.SubscribedAt, utils.PgTextString(user.Timezone)), SubscribedAt: pgTimeDisplay(row.SubscribedAt, utils.PgTextString(user.Timezone)),
EpisodeTotal: int(total), EpisodeTotal: int(total),
DownloadedN: int(downloaded), DownloadedN: int(downloaded),
UndownloadedN: int(total) - int(downloaded), UndownloadedN: int(total) - int(downloaded),
NewEpisodes: total-downloaded > 0, NewEpisodes: total-downloaded > 0,
AutoDownload: row.AutoDownload, AutoDownload: row.AutoDownload,
AutoDownloadCount: int(row.AutoDownloadCount.Int32), AutoDownloadCount: int(row.AutoDownloadCount.Int32),
Subscribed: true, Subscribed: true,
}) })
if cont := continueByFeed[uuid.UUID(row.ID.Bytes).String()]; cont != nil { if cont := continueByFeed[uuid.UUID(row.ID.Bytes).String()]; cont != nil {
card := &data.Feeds[len(data.Feeds)-1] card := &data.Feeds[len(data.Feeds)-1]
@@ -253,7 +254,7 @@ func buildPodcastDetailData(c *echo.Context, cfg *Config, user templates.User, f
UserID: actor.ID, SourceFeedID: feedID, UserID: actor.ID, SourceFeedID: feedID,
}) })
progressByItem := make(map[string]struct { progressByItem := make(map[string]struct {
Pct float64 Pct float64
LastRead pgtype.Timestamptz LastRead pgtype.Timestamptz
}, len(progressRows)) }, len(progressRows))
for _, p := range progressRows { for _, p := range progressRows {
@@ -268,29 +269,29 @@ func buildPodcastDetailData(c *echo.Context, cfg *Config, user templates.User, f
subscriberCount, _ := cfg.Queries.CountPodcastFeedSubscribers(ctx, feedID) subscriberCount, _ := cfg.Queries.CountPodcastFeedSubscribers(ctx, feedID)
card := templates.PodcastFeedCard{ card := templates.PodcastFeedCard{
ID: uuid.UUID(feed.ID.Bytes).String(), ID: uuid.UUID(feed.ID.Bytes).String(),
LibraryID: uuid.UUID(feed.LibraryID.Bytes).String(), LibraryID: uuid.UUID(feed.LibraryID.Bytes).String(),
Title: feed.Title, Title: feed.Title,
DisplayName: sub.DisplayName.String, DisplayName: sub.DisplayName.String,
Description: feed.Description.String, Description: feed.Description.String,
Host: feed.Host.String, Host: feed.Host.String,
Publisher: feed.Publisher.String, Publisher: feed.Publisher.String,
Language: feed.Language.String, Language: feed.Language.String,
Tags: feed.Tags, Tags: feed.Tags,
Explicit: feed.Explicit, Explicit: feed.Explicit,
ArtworkPath: feed.ArtworkPath.String, ArtworkPath: feed.ArtworkPath.String,
FeedType: feed.FeedType.String, FeedType: feed.FeedType.String,
Medium: feed.Medium.String, Medium: feed.Medium.String,
IsComplete: feed.IsComplete, IsComplete: feed.IsComplete,
FeedURL: feed.FeedUrl, FeedURL: feed.FeedUrl,
LastFetched: pgTimeDisplay(feed.LastFetchedAt, user.Timezone), LastFetched: pgTimeDisplay(feed.LastFetchedAt, user.Timezone),
EpisodeTotal: int(total), EpisodeTotal: int(total),
DownloadedN: int(downloaded), DownloadedN: int(downloaded),
UndownloadedN: int(total) - int(downloaded), UndownloadedN: int(total) - int(downloaded),
NewEpisodes: total-downloaded > 0, NewEpisodes: total-downloaded > 0,
AutoDownload: sub.AutoDownload, AutoDownload: sub.AutoDownload,
AutoDownloadCount: int(sub.AutoDownloadCount.Int32), AutoDownloadCount: int(sub.AutoDownloadCount.Int32),
Subscribed: sub.ID.Valid, Subscribed: sub.ID.Valid,
} }
rows := make([]templates.PodcastEpisodeRow, 0, len(eps)) rows := make([]templates.PodcastEpisodeRow, 0, len(eps))
@@ -316,46 +317,45 @@ func buildPodcastDetailData(c *echo.Context, cfg *Config, user templates.User, f
rowID := uuid.UUID(ep.ID.Bytes).String() rowID := uuid.UUID(ep.ID.Bytes).String()
p := progressByItem[rowID] p := progressByItem[rowID]
dateDisplay := "" dateDisplay := ""
dateSort := ""
if ep.DatePublished.Valid { if ep.DatePublished.Valid {
dateSort = ep.DatePublished.Time.Format("2006-01-02")
dateDisplay = ep.DatePublished.Time.In(utils.UserLocation(user.Timezone)).Format("Jan 2, 2006") dateDisplay = ep.DatePublished.Time.In(utils.UserLocation(user.Timezone)).Format("Jan 2, 2006")
} }
rows = append(rows, templates.PodcastEpisodeRow{ rows = append(rows, templates.PodcastEpisodeRow{
ID: uuid.UUID(ep.ID.Bytes).String(), ID: uuid.UUID(ep.ID.Bytes).String(),
FeedID: uuid.UUID(ep.SourceFeedID.Bytes).String(), FeedID: uuid.UUID(ep.SourceFeedID.Bytes).String(),
Title: ep.Title, Title: ep.Title,
Description: firstLine(ep.Description.String, 200), Description: firstLine(ep.Description.String, 200),
Date: dateDisplay, Date: dateDisplay,
Position: int(ep.FeedPosition.Int32), SortDate: dateSort,
Duration: templates.FormatEpisodeDuration(int(ep.DeclaredDurationSeconds.Int32)), Position: int(ep.FeedPosition.Int32),
Size: templates.FormatEpisodeSize(ep.FileSize.Int64), Duration: templates.FormatEpisodeDuration(int(ep.DeclaredDurationSeconds.Int32)),
PlayURL: "/readers/" + uuid.UUID(ep.ID.Bytes).String(), Size: templates.FormatEpisodeSize(ep.FileSize.Int64),
ListenPct: p.Pct, PlayURL: "/readers/" + uuid.UUID(ep.ID.Bytes).String(),
Downloaded: ep.DownloadState == "downloaded", ListenPct: p.Pct,
Downloaded: ep.DownloadState == "downloaded",
DownloadState: ep.DownloadState, DownloadState: ep.DownloadState,
DownloadError: ep.DownloadError.String, DownloadError: ep.DownloadError.String,
}) })
} }
// Order override reshapes the LIST too (the sequence endpoint is the // Order override reshapes the LIST too (the sequence endpoint is the
// strict downloaded-only view; the list shows everything in the // strict downloaded-only view; the list shows everything in the
// chosen order so pending episodes stay visible). // chosen order so pending episodes stay visible). Consolidated sort
if order == "oldest" || order == "newest" { // (§0 item 5a) — SortDate is the lexical pub-date key so the
sort.SliceStable(rows, func(a, b int) bool { // tiebreak is chronological even in display-tz date strings.
ka := rows[a].Date + itoaPad(rows[a].Position) services.OrderEpisodes(rows, order,
kb := rows[b].Date + itoaPad(rows[b].Position) func(r templates.PodcastEpisodeRow) int32 { return int32(r.Position) },
if order == "newest" { func(r templates.PodcastEpisodeRow) string { return r.SortDate },
return ka > kb )
}
return ka < kb
})
}
detail := &templates.PodcastDetailData{ detail := &templates.PodcastDetailData{
Feed: card, Feed: card,
Episodes: rows, Episodes: rows,
GapCount: gaps, GapCount: gaps,
Order: order, Order: order,
IsAdmin: actor.Role == "admin", IsAdmin: actor.Role == "admin",
IsSerial: feed.FeedType.String == "serial" || feed.Medium.String == "audiobook", IsSerial: feed.FeedType.String == "serial" || feed.Medium.String == "audiobook",
SubscriberCount: int(subscriberCount), SubscriberCount: int(subscriberCount),
} }
@@ -396,40 +396,48 @@ func podcastOrderForFeed(cfg *Config, c *echo.Context, feedID string) string {
if err := json.Unmarshal(row, &v); err != nil { if err := json.Unmarshal(row, &v); err != nil {
return "native" return "native"
} }
switch v.Order { if !services.ValidPodcastEpisodeOrder(v.Order) {
case "oldest", "newest": return "native"
return v.Order
} }
return "native" return v.Order
} }
// facetValuesFromCards derives the filter bar values. // facetValuesFromCards derives the filter bar values.
func facetValuesFromCards(feeds []templates.PodcastFeedCard) map[string][]string { // facetValuesFromCards derives the filter-bar values with feed counts,
hosts, tags, langs := map[string]bool{}, map[string]bool{}, map[string]bool{} // sorted by frequency DESC then alphabetically — the top of each group
// is what a user with hundreds of feeds actually filters by.
func facetValuesFromCards(feeds []templates.PodcastFeedCard) map[string][]templates.FacetValue {
hosts, tags, langs := map[string]int{}, map[string]int{}, map[string]int{}
for _, f := range feeds { for _, f := range feeds {
if f.Host != "" { if f.Host != "" {
hosts[f.Host] = true hosts[f.Host]++
} }
for _, t := range f.Tags { for _, t := range f.Tags {
tags[t] = true tags[t]++
} }
if f.Language != "" { if f.Language != "" {
langs[f.Language] = true langs[f.Language]++
} }
} }
return map[string][]string{ return map[string][]templates.FacetValue{
"hosts": sortedSet(hosts), "hosts": sortedFacet(hosts),
"tags": sortedSet(tags), "tags": sortedFacet(tags),
"language": sortedSet(langs), "language": sortedFacet(langs),
} }
} }
func sortedSet(set map[string]bool) []string { // sortedFacet: count DESC, then value ASC (stable, scannable).
out := make([]string, 0, len(set)) func sortedFacet(set map[string]int) []templates.FacetValue {
for k := range set { out := make([]templates.FacetValue, 0, len(set))
out = append(out, k) for k, n := range set {
out = append(out, templates.FacetValue{Value: k, Count: n})
} }
sort.Strings(out) sort.Slice(out, func(i, j int) bool {
if out[i].Count != out[j].Count {
return out[i].Count > out[j].Count
}
return out[i].Value < out[j].Value
})
return out return out
} }
@@ -455,7 +463,6 @@ func itoaPad(n int) string {
return fmt.Sprintf("%06d", n) return fmt.Sprintf("%06d", n)
} }
// parsePodcastUUID parses an id path param (handlers.parseUUID is // parsePodcastUUID parses an id path param (handlers.parseUUID is
// package-private). // package-private).
func parsePodcastUUID(s string) (pgtype.UUID, error) { func parsePodcastUUID(s string) (pgtype.UUID, error) {
+6 -14
View File
@@ -9,9 +9,7 @@ import (
"bookhoard/templates" "bookhoard/templates"
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"sort"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
@@ -186,7 +184,7 @@ func buildPodcastSequenceContext(c *echo.Context, cfg *Config, item *database.Me
var v struct { var v struct {
Order string `json:"order"` Order string `json:"order"`
} }
if json.Unmarshal(row, &v) == nil && (v.Order == "oldest" || v.Order == "newest") { if json.Unmarshal(row, &v) == nil && services.ValidPodcastEpisodeOrder(v.Order) {
order = v.Order order = v.Order
} }
} }
@@ -215,17 +213,11 @@ func buildPodcastSequenceContext(c *echo.Context, cfg *Config, item *database.Me
pos: ep.FeedPosition.Int32, pos: ep.FeedPosition.Int32,
}) })
} }
sort.SliceStable(ordered, func(a, b int) bool { // Effective listening order (order consolidation §0 item 5a).
ka := ordered[a].date + fmt.Sprintf("%06d", ordered[a].pos) services.OrderEpisodes(ordered, order,
kb := ordered[b].date + fmt.Sprintf("%06d", ordered[b].pos) func(e seqEp) int32 { return e.pos },
if order == "newest" { func(e seqEp) string { return e.date },
return ka > kb )
}
if order == "oldest" {
return ka < kb
}
return ordered[a].pos < ordered[b].pos
})
idx := -1 idx := -1
for i, ep := range ordered { for i, ep := range ordered {
+60
View File
@@ -0,0 +1,60 @@
package services
import (
"fmt"
"sort"
)
// Podcast episode ordering — THE single source of order truth (§0 item
// 3 + item 5a, 2026-10-30). Every surface (sequence JSON, reader chain
// context, SSR page list, download queueing) routes through
// OrderEpisodes; per-surface sort blocks were the drift risk (three
// near-identical comparators existed by hand).
// PodcastEpisodeOrder values:
// - native: feed position ASC, date ASC tiebreak (fetch-maintained order)
// - reverse: feed position DESC, date DESC tiebreak ("Reverse feed
// order" — no pub dates involved, so Scribl's messy/all-equal
// migrated podiobooks dates can't break it)
// - oldest / newest: chronological by pub date string (2006-01-02 or
// anything lexical-comparable), position as same-date tiebreak
func ValidPodcastEpisodeOrder(o string) bool {
return o == "native" || o == "reverse" || o == "oldest" || o == "newest"
}
// OrderEpisodes sorts eps in place into the given order. Accessor
// functions supply each episode's feed position and pub date key
// (LEN lexical-comparable, e.g. RFC3339 or 2006-01-02).
func OrderEpisodes[T any](eps []T, order string, pos func(T) int32, date func(T) string) {
switch order {
case "reverse":
sort.SliceStable(eps, func(i, j int) bool {
if pos(eps[i]) != pos(eps[j]) {
return pos(eps[i]) > pos(eps[j])
}
return date(eps[i]) > date(eps[j])
})
case "oldest", "newest":
sort.SliceStable(eps, func(i, j int) bool {
ka := date(eps[i]) + episodePosKey(pos(eps[i]))
kb := date(eps[j]) + episodePosKey(pos(eps[j]))
if order == "newest" {
return ka > kb
}
return ka < kb
})
default: // native
sort.SliceStable(eps, func(i, j int) bool {
if pos(eps[i]) != pos(eps[j]) {
return pos(eps[i]) < pos(eps[j])
}
return date(eps[i]) < date(eps[j])
})
}
}
// episodePosKey: zero-padded position so lexical date keys tiebreak
// numerically.
func episodePosKey(pos int32) string {
return fmt.Sprintf("%06d", pos)
}
+60
View File
@@ -542,6 +542,66 @@ func (s *PodcastService) DeleteFeedFiles(ctx context.Context, feed *database.Pod
_ = os.Remove(filepath.Join(root, feedSlug(feed.Title, feed.ID))) _ = os.Remove(filepath.Join(root, feedSlug(feed.Title, feed.ID)))
} }
// AdminDeleteEpisodeFile (§0 item 8): ONE downloaded episode's file is
// removed from disk and its row reset to pending (facts cleared,
// file_path kept — fetch-maintained layout). Runs under the first-admin
// identity (caller supplies the admin ctx — invariant 12). The poll
// upsert never touches download_state, so the file stays deleted.
func (s *PodcastService) AdminDeleteEpisodeFile(ctx context.Context, episodeID pgtype.UUID) error {
ep, err := s.db.GetPodcastEpisode(ctx, episodeID)
if err != nil {
return errNotFound("episode not found")
}
if !ep.SourceFeedID.Valid {
return fmt.Errorf("episode has no feed reference")
}
if ep.DownloadState != "downloaded" {
return nil // nothing on disk; idempotent no-op
}
feed, err := s.db.GetPodcastFeed(ctx, ep.SourceFeedID)
if err != nil {
return fmt.Errorf("feed lookup: %w", errNotFound("feed not found"))
}
root, err := s.downloadRootAbs(ctx, &feed)
if err != nil {
return err
}
if ep.FilePath != "" {
_ = os.Remove(filepath.Join(root, ep.FilePath))
base := filepath.Join(root, strings.TrimSuffix(ep.FilePath, filepath.Ext(ep.FilePath)))
_ = os.Remove(base + ".cover.jpg")
}
return s.db.ResetPodcastEpisodeFileDeleted(ctx, episodeID)
}
// AdminDeleteFeedFiles (§0 item 8): every DOWNLOADED episode of the
// feed loses its disk file; rows reset to pending. Returns how many
// rows were reset. Poll upsert never touches download_state → a
// refresh re-lists metadata but does NOT re-materialize files.
func (s *PodcastService) AdminDeleteFeedFiles(ctx context.Context, feedID pgtype.UUID) (int64, error) {
feed, err := s.db.GetPodcastFeed(ctx, feedID)
if err != nil {
return 0, errNotFound("feed not found")
}
eps, err := s.db.ListPodcastEpisodes(ctx, feedID)
if err != nil {
return 0, err
}
root, err := s.downloadRootAbs(ctx, &feed)
if err != nil {
return 0, err
}
for _, ep := range eps {
if ep.DownloadState != "downloaded" || ep.FilePath == "" {
continue
}
_ = os.Remove(filepath.Join(root, ep.FilePath))
base := filepath.Join(root, strings.TrimSuffix(ep.FilePath, filepath.Ext(ep.FilePath)))
_ = os.Remove(base + ".cover.jpg")
}
return s.db.ResetPodcastFeedFilesDeleted(ctx, feedID)
}
// downloadRootAbs resolves the podcasts library's single download root // downloadRootAbs resolves the podcasts library's single download root
// (purpose='podcast') as an absolute filesystem path. // (purpose='podcast') as an absolute filesystem path.
func (s *PodcastService) downloadRootAbs(ctx context.Context, feed *database.PodcastFeeds) (string, error) { func (s *PodcastService) downloadRootAbs(ctx context.Context, feed *database.PodcastFeeds) (string, error) {
+271 -58
View File
@@ -1,11 +1,21 @@
// Podcast download worker (plan §7 / D6 / D8 / D19): ONE sequential // Podcast download worker (plan §7 / D6 / D8 / D19; pool since §0 item
// worker (day one) driving the episode state machine // 4): a download-manager-style pool driving the episode state machine
// //
// pending → queued → downloading → downloaded // pending → queued → downloading → downloaded
// ↘ failed (+ download_error) // ↘ failed (+ download_error)
// //
// queued rows are the DB-backed worklist — the state is persisted so // queued rows are the DB-backed worklist — the state is persisted so
// the episode list ALWAYS renders truth (no in-memory queue to lose). // the episode list ALWAYS renders truth (no in-memory queue to lose).
// Each queue ticket (download_queue_pos, a SEQUENCE nextval at
// enqueue) makes the enqueue loop's ordering the download order.
//
// Concurrency: up to podcast_download_concurrency episodes in flight
// server-wide (default 4, admin-tunable live 1–16). Fair share: the
// claim prefers the feed least-recently-served, so several books
// download in parallel while chapters within a book land in ticket
// order (start listening while the rest catch up). Bandwidth: an
// optional shared token bucket (podcast_download_speed_mbps, 0 =
// unlimited) paces all workers together.
// //
// Idempotency: a download request for an episode whose file already // Idempotency: a download request for an episode whose file already
// exists and probes cleanly re-marks it downloaded without re-fetching // exists and probes cleanly re-marks it downloaded without re-fetching
@@ -29,10 +39,10 @@ import (
"fmt" "fmt"
"io" "io"
"log" "log"
"os"
"path/filepath"
"net/http" "net/http"
"net/url" "net/url"
"os"
"path/filepath"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -48,25 +58,64 @@ const maxAutoDownloadAttempts = 5
// PodcastDownloadWorker processes queued episode downloads. // PodcastDownloadWorker processes queued episode downloads.
type PodcastDownloadWorker struct { type PodcastDownloadWorker struct {
db *database.Queries db *database.Queries
svc *PodcastService svc *PodcastService
wake chan struct{} settings *database.SettingsRegistry
wake chan struct{}
// Fair-share bookkeeping (§0 item 4): feed UUID string → last
// time the pool served it. Claim prefers the smallest value;
// never-served feeds (missing key) go first. Guarded by fairMu.
fairMu sync.Mutex
lastServed map[string]time.Time
// Shared bandwidth bucket across all pool workers (§0 item 4).
limiter bandwidthLimiter
// Live byte-progress slots for in-flight downloads (issue #1). // Live byte-progress slots for in-flight downloads (issue #1).
// Keyed by episode UUID string; guarded by progMu. // Keyed by episode UUID string; guarded by progMu.
progMu sync.Mutex progMu sync.Mutex
progress map[string]*episodeProgress progress map[string]*episodeProgress
} }
func NewPodcastDownloadWorker(db *database.Queries, svc *PodcastService) *PodcastDownloadWorker { func NewPodcastDownloadWorker(db *database.Queries, svc *PodcastService) *PodcastDownloadWorker {
return &PodcastDownloadWorker{ return &PodcastDownloadWorker{
db: db, db: db,
svc: svc, svc: svc,
wake: make(chan struct{}, 1), wake: make(chan struct{}, 1),
progress: make(map[string]*episodeProgress), lastServed: make(map[string]time.Time),
progress: make(map[string]*episodeProgress),
} }
} }
// SetSettings wires the live settings registry (admin tunables:
// concurrency + bandwidth cap). Without it the worker runs on the
// registered defaults (4 in flight, unlimited speed) — the test
// harness constructs workers without settings.
func (w *PodcastDownloadWorker) SetSettings(r *database.SettingsRegistry) {
w.settings = r
}
// concurrency re-reads the live setting each drain cycle.
func (w *PodcastDownloadWorker) concurrency() int {
if w.settings == nil {
return 4 // registered default
}
if n := w.settings.PodcastDownloadConcurrency(); n >= 1 {
return n
}
return 4
}
// refillLimiter re-syncs the shared bucket's rate (bytes/sec) from the
// live setting; 0 = unlimited.
func (w *PodcastDownloadWorker) refillLimiter() {
if w.settings == nil {
return
}
w.limiter.setRate(int64(w.settings.PodcastDownloadSpeedMbps() * 125000)) // Mbps → bytes/sec
}
// Wake nudges the loop (new work queued). // Wake nudges the loop (new work queued).
func (w *PodcastDownloadWorker) Wake() { func (w *PodcastDownloadWorker) Wake() {
select { select {
@@ -93,21 +142,129 @@ func (w *PodcastDownloadWorker) Start(ctx context.Context) {
}() }()
} }
// drain processes queued episodes until the queue is empty. // DrainNow runs one bounded pool round synchronously — the test hook
// and the future admin "retry now" seam (normally the wake channel
// drives rounds).
func (w *PodcastDownloadWorker) DrainNow(ctx context.Context) {
w.drain(ctx)
}
// drain runs one pool round: up to concurrency() claim-loop goroutines
// each claim (fair-share) → download → settle, until the queue is
// empty. The cap setting is re-read every round — live-tunable.
func (w *PodcastDownloadWorker) drain(ctx context.Context) { func (w *PodcastDownloadWorker) drain(ctx context.Context) {
for { if ctx.Err() != nil {
if ctx.Err() != nil { return
return }
} w.refillLimiter()
acted, err := w.processNext(ctx) limit := w.concurrency()
var wg sync.WaitGroup
for i := 0; i < limit; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
if ctx.Err() != nil {
return
}
ep, ok := w.claimNext(ctx)
if !ok {
return // queue drained (or nothing claimable)
}
w.runOne(ctx, ep)
}
}()
}
wg.Wait()
}
// claimNext atomically claims the next queued episode, preferring the
// feed least-recently-served (fair share — books proceed in parallel)
// and the earliest queue ticket within a feed (chapters land in
// enqueue order). Returns ok=false when nothing is claimable.
func (w *PodcastDownloadWorker) claimNext(ctx context.Context) (database.MediaItems, bool) {
actx, err := w.svc.adminCtx(ctx)
if err != nil {
return database.MediaItems{}, false
}
cands, err := w.db.ListQueuedPodcastEpisodes(actx)
if err != nil || len(cands) == 0 {
return database.MediaItems{}, false
}
// Group per feed preserving ticket order (the SQL scan is ordered).
perFeed := make(map[pgtype.UUID][]pgtype.UUID, 8)
for _, c := range cands {
perFeed[c.SourceFeedID] = append(perFeed[c.SourceFeedID], c.ID)
}
w.fairMu.Lock()
defer w.fairMu.Unlock()
pickFeed := pickFeedToServe(perFeed, w.lastServed)
if !pickFeed.Valid {
return database.MediaItems{}, false
}
// Claim the head ticket of the chosen feed; if another claimant
// won it meanwhile (retry raced), fall through the feed's tickets.
for _, id := range perFeed[pickFeed] {
ep, err := w.db.ClaimQueuedPodcastEpisode(actx, id)
if err != nil { if err != nil {
log.Printf("[PODCAST-DL] worker step: %v", err) continue // claimed elsewhere / state moved on
return
} }
if !acted { w.lastServed[pickFeed.String()] = time.Now()
return return ep, true
}
return database.MediaItems{}, false
}
// pickFeedToServe chooses the feed least-recently-served (never-served
// first); among never-served feeds the first encountered wins (any is
// equally fair). Unit-testable seam for the fair-share rule.
func pickFeedToServe(perFeed map[pgtype.UUID][]pgtype.UUID, lastServed map[string]time.Time) pgtype.UUID {
var pick pgtype.UUID
var best time.Time
haveNever := false
for feed := range perFeed {
last, seen := lastServed[feed.String()]
if !seen {
if !haveNever {
pick, haveNever = feed, true
}
continue
}
if haveNever {
continue // a never-served feed already won
}
if !pick.Valid || last.Before(best) {
pick, best = feed, last
} }
} }
return pick
}
// runOne downloads a claimed episode and settles the row either way
// (failed/rejected: state + reason persist; metadata/file facts
// untouched — invariant 7, write-after-success only).
func (w *PodcastDownloadWorker) runOne(ctx context.Context, ep database.MediaItems) {
actx, err := w.svc.adminCtx(ctx)
if err != nil {
return
}
if err := w.runPipeline(actx, ep); err != nil {
_ = w.db.MarkPodcastEpisodeFailed(actx, database.MarkPodcastEpisodeFailedParams{
ID: ep.ID,
DownloadError: pgtypeText(err.Error()),
Column3: false, // auto failure: never resets the attempt budget
})
log.Printf("[PODCAST-DL] episode %s failed: %v", ep.ID, err)
}
}
// AdminCtx exposes the worker's first-admin identity (tests use it for
// worker-scope DB statements).
func (w *PodcastDownloadWorker) AdminCtx(ctx context.Context) (context.Context, error) {
return w.svc.adminCtx(ctx)
} }
// QueueEpisode marks one episode queued (manual download / retry path). // QueueEpisode marks one episode queued (manual download / retry path).
@@ -141,9 +298,12 @@ func (w *PodcastDownloadWorker) QueueEpisode(ctx context.Context, episodeID, fee
return nil return nil
} }
// QueueFeedDownloads implements "Download All" (D8): queue exactly the // QueueFeedDownloadsAs is Download All honoring the CALLER's listening
// non-downloaded set — pending AND failed rows (retry is the same path). // order (§0 item 5b): episodes queue in the feed's effective order (the
func (w *PodcastDownloadWorker) QueueFeedDownloads(ctx context.Context, feedID pgtype.UUID) (int, error) { // caller's override, feed-native fallback). Tickets (§0 item 4) make
// that queue order the download order — "getting the last chapter
// first doesn't help me". userID zero = no override (feed order).
func (w *PodcastDownloadWorker) QueueFeedDownloadsAs(ctx context.Context, userID, feedID pgtype.UUID) (int, error) {
actx, err := w.svc.adminCtx(ctx) actx, err := w.svc.adminCtx(ctx)
if err != nil { if err != nil {
return 0, err return 0, err
@@ -156,6 +316,23 @@ func (w *PodcastDownloadWorker) QueueFeedDownloads(ctx context.Context, feedID p
if err != nil { if err != nil {
return 0, err return 0, err
} }
order := "native"
if userID.Valid {
order = w.orderOverrideFor(actx, userID, feedID)
}
// THE order helper (§0 item 5a) — same semantics as the sequence,
// reader chain, and page list; no per-surface sort drift.
OrderEpisodes(eps, order,
func(ep database.MediaItems) int32 { return ep.FeedPosition.Int32 },
func(ep database.MediaItems) string {
if ep.DatePublished.Valid {
return ep.DatePublished.Time.Format("2006-01-02")
}
return ""
},
)
queued := 0 queued := 0
for _, ep := range eps { for _, ep := range eps {
if _, err := w.db.SetPodcastEpisodeDownloadQueued(actx, ep.ID); err == nil { if _, err := w.db.SetPodcastEpisodeDownloadQueued(actx, ep.ID); err == nil {
@@ -166,6 +343,24 @@ func (w *PodcastDownloadWorker) QueueFeedDownloads(ctx context.Context, feedID p
return queued, nil return queued, nil
} }
// orderOverrideFor reads a user's podcast_order override via the
// reader_settings KV (the D5 storage; native when absent/invalid).
func (w *PodcastDownloadWorker) orderOverrideFor(ctx context.Context, userID, feedID pgtype.UUID) string {
row, err := w.db.GetUserSettingByKey(ctx, database.GetUserSettingByKeyParams{
UserID: userID, SettingKey: "podcast_order:" + feedID.String(),
})
if err != nil {
return "native"
}
var v struct {
Order string `json:"order"`
}
if json.Unmarshal(row, &v) != nil || !ValidPodcastEpisodeOrder(v.Order) {
return "native"
}
return v.Order
}
// ApplyAutoPolicies unions subscriber policies for a feed (D6: most // ApplyAutoPolicies unions subscriber policies for a feed (D6: most
// aggressive wins for what ARRIVES — deletion stays admin-only) and // aggressive wins for what ARRIVES — deletion stays admin-only) and
// queues the target set, respecting the auto-retry attempt cap. // queues the target set, respecting the auto-retry attempt cap.
@@ -228,33 +423,6 @@ func (w *PodcastDownloadWorker) ApplyAutoPolicies(ctx context.Context, feedID pg
return queued, nil return queued, nil
} }
// processNext claims and processes one queued episode. Returns false
// when the queue was empty.
func (w *PodcastDownloadWorker) processNext(ctx context.Context) (bool, error) {
actx, err := w.svc.adminCtx(ctx)
if err != nil {
return false, err
}
ep, err := w.db.GetNextQueuedEpisode(actx)
if err != nil {
return false, nil // empty queue (or read failure — logged upstream)
}
_ = w.db.SetPodcastEpisodeDownloading(actx, ep.ID)
if err := w.runPipeline(actx, ep); err != nil {
// failed/rejected: state + reason persist; metadata/file facts
// untouched (invariant 7 — write-after-success only).
_ = w.db.MarkPodcastEpisodeFailed(actx, database.MarkPodcastEpisodeFailedParams{
ID: ep.ID,
DownloadError: pgtypeText(err.Error()),
Column3: false, // auto failure: never resets the attempt budget
})
log.Printf("[PODCAST-DL] episode %s failed: %v", ep.ID, err)
}
return true, nil
}
// runPipeline: fetch enclosure (SSRF/size guards) → temp file → MIME/ // runPipeline: fetch enclosure (SSRF/size guards) → temp file → MIME/
// size validation → ffprobe reconcile → move into place → SUCCESS-only // size validation → ffprobe reconcile → move into place → SUCCESS-only
// row update. // row update.
@@ -398,6 +566,42 @@ func (w *PodcastDownloadWorker) EpisodeProgress(episodeID pgtype.UUID) (received
return p.Received, p.Total, true return p.Received, p.Total, true
} }
// ---- shared bandwidth bucket (§0 item 4) ----
// One leaky-bucket-with-reservations limiter across ALL pool workers:
// each caller reserves the next slot of bandwidth and sleeps outside
// the lock — reservation order = transmission order, and N workers
// share one cap instead of N caps.
type bandwidthLimiter struct {
mu sync.Mutex
rate int64 // bytes per second; <=0 = unlimited
next time.Time // when the currently-reserved budget runs out
}
func (l *bandwidthLimiter) setRate(bytesPerSec int64) {
l.mu.Lock()
defer l.mu.Unlock()
l.rate = bytesPerSec
}
// take reserves n bytes of bandwidth and returns how long the caller
// must wait before sending them (0 when free flow).
func (l *bandwidthLimiter) take(n int64) time.Duration {
l.mu.Lock()
defer l.mu.Unlock()
if l.rate <= 0 {
return 0
}
now := time.Now()
if l.next.Before(now) {
l.next = now
}
d := time.Duration(float64(n) / float64(l.rate) * float64(time.Second))
start := l.next // my window begins when earlier reservations end
l.next = start.Add(d)
return start.Sub(now)
}
// streamEnclosure performs the SSRF-guarded enclosure download straight // streamEnclosure performs the SSRF-guarded enclosure download straight
// to tmpPath while updating the progress slot. Returns bytes received // to tmpPath while updating the progress slot. Returns bytes received
// and the authoritative total (0 when unknown). Scheme and size guards // and the authoritative total (0 when unknown). Scheme and size guards
@@ -456,6 +660,15 @@ func (w *PodcastDownloadWorker) streamEnclosure(ctx context.Context, ep database
if received > maxEnclosureCapBytes { if received > maxEnclosureCapBytes {
return received, total, fmt.Errorf("rejected: enclosure exceeds %d byte cap", maxEnclosureCapBytes) return received, total, fmt.Errorf("rejected: enclosure exceeds %d byte cap", maxEnclosureCapBytes)
} }
// Shared bandwidth cap: pace against the server-wide
// bucket (0 rate = unlimited, take returns 0).
if wait := w.limiter.take(int64(n)); wait > 0 {
select {
case <-ctx.Done():
return received, total, fmt.Errorf("download failed: cancelled")
case <-time.After(wait):
}
}
w.addProgress(ep.ID, int64(n)) w.addProgress(ep.ID, int64(n))
if _, werr := out.Write(buf[:n]); werr != nil { if _, werr := out.Write(buf[:n]); werr != nil {
return received, total, fmt.Errorf("write: %w", werr) return received, total, fmt.Errorf("write: %w", werr)
@@ -496,13 +709,13 @@ func (w *PodcastDownloadWorker) markDownloaded(ctx context.Context, ep database.
} }
_, err = w.db.MarkPodcastEpisodeDownloaded(ctx, database.MarkPodcastEpisodeDownloadedParams{ _, err = w.db.MarkPodcastEpisodeDownloaded(ctx, database.MarkPodcastEpisodeDownloadedParams{
ID: ep.ID, ID: ep.ID,
FilePath: ep.FilePath, FilePath: ep.FilePath,
FileSize: pgtypeInt8(info.Size()), FileSize: pgtypeInt8(info.Size()),
MimeType: pgtypeText(strings.TrimSpace(pickMimeType(ep, absPath))), MimeType: pgtypeText(strings.TrimSpace(pickMimeType(ep, absPath))),
CoverImagePath: coverRel, CoverImagePath: coverRel,
AudioMetadata: audioPayloadJSONB(payload), AudioMetadata: audioPayloadJSONB(payload),
ChapterCount: chapterCount, ChapterCount: chapterCount,
}) })
if err != nil { if err != nil {
return fmt.Errorf("mark downloaded: %w", err) return fmt.Errorf("mark downloaded: %w", err)
+80
View File
@@ -0,0 +1,80 @@
// Unit tests for the §0 item 4 download-pool mechanics: the shared
// bandwidth bucket (reservation math, unlimited passthrough) and the
// fair-share feed pick (never-served first, least-recently-served
// after). Pure logic — no DB.
package services
import (
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
)
func uuidOf(s string) pgtype.UUID {
var u pgtype.UUID
_ = u.Scan(s)
return u
}
func TestBandwidthLimiter(t *testing.T) {
t.Run("zero rate is unlimited", func(t *testing.T) {
var l bandwidthLimiter
l.setRate(0)
assert.Zero(t, l.take(1<<20), "unlimited → no wait, ever")
assert.Zero(t, l.take(1<<20))
})
t.Run("reservations share one bucket", func(t *testing.T) {
var l bandwidthLimiter
l.setRate(1_000_000) // 1 MB/s
start := time.Now()
w1 := l.take(1_000_000) // first caller: free flow
w2 := l.take(1_000_000) // second caller: waits one full second
w3 := l.take(1_000_000) // third caller: waits two seconds
assert.Less(t, int64(w1), int64(50*time.Millisecond), "first reservation starts immediately")
assert.GreaterOrEqual(t, int64(w2), int64(900*time.Millisecond), "second waits ~1s at 1MB/s")
assert.GreaterOrEqual(t, int64(w3), int64(1900*time.Millisecond), "third waits ~2s — SHARED bucket, not per-worker")
assert.Less(t, time.Since(start), 100*time.Millisecond, "take() only reserves — callers sleep outside the lock")
})
t.Run("idle bucket recovers", func(t *testing.T) {
var l bandwidthLimiter
l.setRate(1_000_000)
_ = l.take(1_000_000)
l.next = time.Now().Add(-time.Second) // simulate a second of idle flow
assert.Less(t, int64(l.take(500_000)), int64(50*time.Millisecond),
"budget accrued while idle → next take flows immediately")
})
}
func TestPickFeedToServe(t *testing.T) {
a, b, c := uuidOf("00000000-0000-0000-0000-00000000000a"),
uuidOf("00000000-0000-0000-0000-00000000000b"),
uuidOf("00000000-0000-0000-0000-00000000000c")
all := map[pgtype.UUID][]pgtype.UUID{a: {}, b: {}, c: {}}
t.Run("never-served beats served", func(t *testing.T) {
served := map[string]time.Time{
a.String(): time.Now().Add(-time.Hour),
b.String(): time.Now().Add(-time.Minute),
}
assert.Equal(t, c, pickFeedToServe(all, served), "c was never served → c wins even though a starved longest")
})
t.Run("least-recently-served wins", func(t *testing.T) {
served := map[string]time.Time{
a.String(): time.Now().Add(-time.Hour), // starved longest
b.String(): time.Now().Add(-time.Minute), // served recently
}
two := map[pgtype.UUID][]pgtype.UUID{a: {}, b: {}}
assert.Equal(t, a, pickFeedToServe(two, served))
})
t.Run("empty queue picks nothing", func(t *testing.T) {
assert.False(t, pickFeedToServe(map[pgtype.UUID][]pgtype.UUID{}, nil).Valid)
})
}
+127
View File
@@ -0,0 +1,127 @@
package templates
// Admin all-podcasts view (§0 item 8 / Phase-4 item 1): EVERY feed from
// EVERY user with its maintenance aggregates, sorted by disk usage.
// The maintenance surface for the podcast subsystem — files are the
// disk hogs, so the hogs surface first. Actions: Refresh (re-fetch),
// Delete files (rows → pending, disk freed), Remove feed (rows + files
// + subscriptions gone — the same admin delete the detail page offers).
// AdminPodcastFeedData is one row of the admin all-podcasts table.
type AdminPodcastFeedData struct {
ID string
Title string
Host string
LibraryName string
Subscribers int
SubscriberNames string
EpisodeTotal int
EpisodeDownloaded int
DiskBytes int64
LastFetched string
}
templ AdminPodcasts(user User, feeds []AdminPodcastFeedData, totalFeeds int, totalDownloaded int, totalEpisodes int, totalBytes int64) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>All Podcasts - Bookhoard</title>
<link href="/static/style.css" rel="stylesheet"/>
<script type="module" src={ "/static/main.js?v=" + MainJSVersion() }></script>
</head>
<body class={ "theme-" + user.Theme }>
@Header(user, "/admin/podcasts")
<main class="container mx-auto px-4 py-8">
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<h1 class="text-2xl font-bold" style="color: var(--text-primary)">All Podcasts</h1>
<div class="text-sm tabular-nums" style="color: var(--text-secondary)">
{ podcastInt(totalFeeds) } feeds · { podcastInt(totalDownloaded) }/{ podcastInt(totalEpisodes) } episodes downloaded · { FormatEpisodeSize(totalBytes) } on disk
</div>
</div>
<p class="text-sm mb-6" style="color: var(--text-secondary)">
Maintenance view: every feed from every user. "Delete files" frees the disk and
resets episodes to pending (they re-download on demand — a feed refresh will NOT
re-materialize them). "Remove feed" deletes rows, files and subscriptions for everyone.
</p>
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">
<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>
for _, f := range feeds {
<tr style="border-bottom: 1px solid var(--border);" id={ "admin-feed-" + f.ID }>
<td class="px-4 py-3">
<a href={ "/podcasts/" + f.ID } class="font-medium hover:underline" style="color: var(--text-primary)">{ f.Title }</a>
if f.Host != "" {
<div class="text-xs" style="color: var(--text-secondary)">{ f.Host }</div>
}
</td>
<td class="px-4 py-3" style="color: var(--text-secondary)">{ f.LibraryName }</td>
<td class="px-4 py-3">
if f.Subscribers > 0 {
<span title={ f.SubscriberNames } style="color: var(--text-primary)">{ podcastInt(f.Subscribers) }</span>
} else {
<span class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">orphaned</span>
}
</td>
<td class="px-4 py-3 text-right tabular-nums" style="color: var(--text-primary)">
{ podcastInt(f.EpisodeDownloaded) }/{ podcastInt(f.EpisodeTotal) }
</td>
<td class="px-4 py-3 text-right tabular-nums" style="color: var(--text-primary)">{ FormatEpisodeSize(f.DiskBytes) }</td>
<td class="px-4 py-3 text-xs" style="color: var(--text-secondary)">{ f.LastFetched }</td>
<td class="px-4 py-3">
<div class="flex justify-end gap-2">
<button
class="btn btn-secondary text-xs"
hx-post={ "/api/podcasts/feeds/" + f.ID + "/refresh" }
hx-swap="none"
title="Check the feed for new episodes now"
>
Refresh
</button>
if f.EpisodeDownloaded > 0 {
<button
class="btn btn-secondary text-xs"
hx-post={ "/api/podcasts/feeds/" + f.ID + "/delete-files" }
hx-confirm={ "Delete the " + podcastInt(f.EpisodeDownloaded) + " downloaded file(s) of \"" + f.Title + "\"? Episodes reset to pending; a feed refresh will not re-materialize them." }
hx-swap="none"
style="color: var(--status-warning, #d97706);"
>
Delete files
</button>
}
<button
class="btn btn-secondary text-xs"
hx-delete={ "/api/podcasts/feeds/" + f.ID }
hx-confirm={ "ADMIN: remove \"" + f.Title + "\" for everyone? Episode rows AND downloaded files are deleted." }
hx-swap="none"
style="color: var(--status-danger);"
>
Remove
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</main>
</body>
</html>
}
+424
View File
@@ -0,0 +1,424 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
// Admin all-podcasts view (§0 item 8 / Phase-4 item 1): EVERY feed from
// EVERY user with its maintenance aggregates, sorted by disk usage.
// The maintenance surface for the podcast subsystem — files are the
// disk hogs, so the hogs surface first. Actions: Refresh (re-fetch),
// Delete files (rows → pending, disk freed), Remove feed (rows + files
// + subscriptions gone — the same admin delete the detail page offers).
// AdminPodcastFeedData is one row of the admin all-podcasts table.
type AdminPodcastFeedData struct {
ID string
Title string
Host string
LibraryName string
Subscribers int
SubscriberNames string
EpisodeTotal int
EpisodeDownloaded int
DiskBytes int64
LastFetched string
}
func AdminPodcasts(user User, feeds []AdminPodcastFeedData, totalFeeds int, totalDownloaded int, totalEpisodes int, totalBytes int64) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>All Podcasts - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><script type=\"module\" src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/static/main.js?v=" + MainJSVersion())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 32, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"></script></head>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 = []any{"theme-" + user.Theme}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var3...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<body class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var3).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Header(user, "/admin/podcasts").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<main class=\"container mx-auto px-4 py-8\"><div class=\"flex flex-wrap items-center justify-between gap-3 mb-4\"><h1 class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">All Podcasts</h1><div class=\"text-sm tabular-nums\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(totalFeeds))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 40, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " feeds · ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(totalDownloaded))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 40, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "/")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(totalEpisodes))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 40, Col: 101}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " episodes downloaded · ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatEpisodeSize(totalBytes))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 40, Col: 158}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " on disk</div></div><p class=\"text-sm mb-6\" style=\"color: var(--text-secondary)\">Maintenance view: every feed from every user. \"Delete files\" frees the disk and resets episodes to pending (they re-download on demand — a feed refresh will NOT re-materialize them). \"Remove feed\" deletes rows, files and subscriptions for everyone.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(feeds) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"card p-6 text-center text-sm\" style=\"color: var(--text-secondary)\">No podcast feeds exist yet.</div>")
if templ_7745c5c3_Err != nil {
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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, f := range feeds {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<tr style=\"border-bottom: 1px solid var(--border);\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("admin-feed-" + f.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 66, Col: 86}
}
_, 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, 13, "\"><td class=\"px-4 py-3\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 templ.SafeURL
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinURLErrs("/podcasts/" + f.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 68, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" class=\"font-medium hover:underline\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(f.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 68, Col: 123}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if f.Host != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"text-xs\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(f.Host)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 70, Col: 78}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</td><td class=\"px-4 py-3\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(f.LibraryName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 73, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</td><td class=\"px-4 py-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if f.Subscribers > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<span title=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.SubscriberNames)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 76, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(f.Subscribers))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 76, Col: 108}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<span class=\"badge\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary)\">orphaned</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</td><td class=\"px-4 py-3 text-right tabular-nums\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(f.EpisodeDownloaded))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 82, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "/")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(f.EpisodeTotal))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 82, Col: 75}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</td><td class=\"px-4 py-3 text-right tabular-nums\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(FormatEpisodeSize(f.DiskBytes))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 84, Col: 123}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</td><td class=\"px-4 py-3 text-xs\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(f.LastFetched)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 85, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</td><td class=\"px-4 py-3\"><div class=\"flex justify-end gap-2\"><button class=\"btn btn-secondary text-xs\" hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + f.ID + "/refresh")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 90, Col: 65}
}
_, 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, 29, "\" hx-swap=\"none\" title=\"Check the feed for new episodes now\">Refresh</button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if f.EpisodeDownloaded > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<button class=\"btn btn-secondary text-xs\" hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + f.ID + "/delete-files")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 99, Col: 71}
}
_, 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, 31, "\" hx-confirm=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("Delete the " + podcastInt(f.EpisodeDownloaded) + " downloaded file(s) of \"" + f.Title + "\"? Episodes reset to pending; a feed refresh will not re-materialize them.")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 100, Col: 194}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" hx-swap=\"none\" style=\"color: var(--status-warning, #d97706);\">Delete files</button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<button class=\"btn btn-secondary text-xs\" hx-delete=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + f.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 109, Col: 54}
}
_, 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, 34, "\" hx-confirm=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue("ADMIN: remove \"" + f.Title + "\" for everyone? Episode rows AND downloaded files are deleted.")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 110, Col: 122}
}
_, 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, 35, "\" hx-swap=\"none\" style=\"color: var(--status-danger);\">Remove</button></div></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</tbody></table></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</main></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+14
View File
@@ -244,6 +244,20 @@ templ TunableSettingRow(e SettingEntry) {
} }
class="input py-1.5 text-sm w-32" class="input py-1.5 text-sm w-32"
/> />
} else if e.Type == "float" {
<input
type="number"
step="any"
name="value"
value={ e.Value }
if e.Min != "" {
min={ e.Min }
}
if e.Max != "" {
max={ e.Max }
}
class="input py-1.5 text-sm w-32"
/>
} else { } else {
<input <input
type="text" type="text"
+81 -21
View File
@@ -813,26 +813,86 @@ func TunableSettingRow(e SettingEntry) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } else if e.Type == "float" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "<input type=\"text\" name=\"value\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "<input type=\"number\" step=\"any\" name=\"value\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var22 string var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value) templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 251, Col: 20} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 252, Col: 20}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "\" class=\"input py-1.5 text-sm w-40\"> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if e.Min != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, " min=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Min)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 254, Col: 17}
}
_, 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, 107, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if e.Max != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, " max=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Max)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 257, Col: 17}
}
_, 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, 109, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, " class=\"input py-1.5 text-sm w-32\"> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "<input type=\"text\" name=\"value\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 265, Col: 20}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "\" class=\"input py-1.5 text-sm w-40\"> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "<button type=\"submit\" class=\"btn btn-secondary px-3 py-1.5 text-sm\" hx-disabled-elt=\"this\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "<button type=\"submit\" class=\"btn btn-secondary px-3 py-1.5 text-sm\" hx-disabled-elt=\"this\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -840,20 +900,20 @@ func TunableSettingRow(e SettingEntry) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "Save</button></form><span id=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "Save</button></form><span id=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var23 string var templ_7745c5c3_Var26 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("status-" + e.Key) templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue("status-" + e.Key)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 260, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 274, Col: 30}
} }
_, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "\" class=\"text-xs w-24 text-right\" style=\"color: var(--text-secondary)\"></span></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "\" class=\"text-xs w-24 text-right\" style=\"color: var(--text-secondary)\"></span></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -880,12 +940,12 @@ func AboutSection() templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var24 := templ.GetChildren(ctx) templ_7745c5c3_Var27 := templ.GetChildren(ctx)
if templ_7745c5c3_Var24 == nil { if templ_7745c5c3_Var27 == nil {
templ_7745c5c3_Var24 = templ.NopComponent templ_7745c5c3_Var27 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "<div class=\"mt-6 card p-6\"><div class=\"flex items-center gap-2 mb-4\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "<div class=\"mt-6 card p-6\"><div class=\"flex items-center gap-2 mb-4\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -893,20 +953,20 @@ func AboutSection() templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">About</h3></div><div class=\"space-y-2 text-sm\" style=\"color: var(--text-secondary);\"><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Version:</span> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">About</h3></div><div class=\"space-y-2 text-sm\" style=\"color: var(--text-secondary);\"><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Version:</span> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var25 string var templ_7745c5c3_Var28 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(version.Version) templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(version.Version)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 274, Col: 134} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 288, Col: 134}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "</p></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "</p></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+85 -38
View File
@@ -34,6 +34,23 @@ func AudioReaderConfigJSON(metadata ReaderMetadata) string {
return string(b) return string(b)
} }
// audioStepPrevIcon / audioStepNextIcon: the transport row's outer
// step icons (chapter-step for books, EPISODE-step for podcasts — the
// same buttons serve both, round-3 user design 2026-10-30).
templ audioStepPrevIcon() {
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M19 20 9 12l10-8v16z"></path>
<path d="M5 19V5"></path>
</svg>
}
templ audioStepNextIcon() {
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 4l10 8-10 8V4z"></path>
<path d="M19 5v14"></path>
</svg>
}
templ AudioReader(user User, metadata ReaderMetadata) { templ AudioReader(user User, metadata ReaderMetadata) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@@ -91,22 +108,14 @@ templ AudioReader(user User, metadata ReaderMetadata) {
<path d="m12 19-7-7 7-7"></path> <path d="m12 19-7-7 7-7"></path>
</svg> </svg>
</button> </button>
<!-- Podcast sequence (D7): episode prev/next + "Ep N of M". <!-- Podcast sequence (D7 + round-3 user design): "Ep N of M"
Rendered only when the server supplied sequence context. --> indicator. The episode prev/next live on the transport
row's outer buttons (same buttons books use for chapter
stepping) — not as separate icons here. -->
if metadata.PodcastSequence != nil { if metadata.PodcastSequence != nil {
<span class="text-xs tabular-nums ml-1" style="color: var(--text-secondary)"> <span class="text-xs tabular-nums ml-1" style="color: var(--text-secondary)">
Ep { fmt.Sprintf("%d", metadata.PodcastSequence.EpisodeIndex) }/{ fmt.Sprintf("%d", metadata.PodcastSequence.IndexTotal) } Ep { fmt.Sprintf("%d", metadata.PodcastSequence.EpisodeIndex) }/{ fmt.Sprintf("%d", metadata.PodcastSequence.IndexTotal) }
</span> </span>
if metadata.PodcastSequence.PrevURL != "" {
<a href={ metadata.PodcastSequence.PrevURL } class="rounded-full p-2 hover:bg-surface-hover transition-colors" style="color: var(--text-primary);" aria-label="Previous episode" title="Previous episode">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M6 5h2v14H6zM20 5v14l-11-7z"></path></svg>
</a>
}
if metadata.PodcastSequence.NextURL != "" {
<a href={ metadata.PodcastSequence.NextURL } class="rounded-full p-2 hover:bg-surface-hover transition-colors" style="color: var(--text-primary);" aria-label={ "Next episode: " + metadata.PodcastSequence.NextTitle } title={ "Next: " + metadata.PodcastSequence.NextTitle }>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M16 5h2v14h-2zM4 5v14l11-7z"></path></svg>
</a>
}
} }
</span> </span>
<span class="inline-flex items-center gap-2"> <span class="inline-flex items-center gap-2">
@@ -334,19 +343,39 @@ templ AudioReader(user User, metadata ReaderMetadata) {
>0:00</button> >0:00</button>
</div> </div>
<div class="flex items-center justify-center gap-3 md:gap-5"> <div class="flex items-center justify-center gap-3 md:gap-5">
<button <!-- Outer left button: books = previous chapter; podcasts =
type="button" previous EPISODE (round-3 user design: one set of big
@click="stepChapter(-1)" buttons does the big navigation; keyboard + hardware
class="rounded-full p-2.5 hover:bg-surface-hover transition-colors" media keys route through stepChapter and follow). -->
aria-label="Previous chapter" if metadata.PodcastSequence != nil {
title="Previous chapter" if metadata.PodcastSequence.PrevURL != "" {
style="color: var(--text-primary);" <button
> type="button"
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> @click="goToPrevEpisode()"
<path d="M19 20 9 12l10-8v16z"></path> class="rounded-full p-2.5 hover:bg-surface-hover transition-colors"
<path d="M5 19V5"></path> aria-label="Previous episode"
</svg> title="Previous episode"
</button> style="color: var(--text-primary);"
>
@audioStepPrevIcon()
</button>
} else {
<span class="rounded-full p-2.5 opacity-30 cursor-not-allowed" aria-disabled="true" title="No previous episode">
@audioStepPrevIcon()
</span>
}
} else {
<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);"
>
@audioStepPrevIcon()
</button>
}
<button <button
type="button" type="button"
@click="skip(-1)" @click="skip(-1)"
@@ -399,19 +428,37 @@ templ AudioReader(user User, metadata ReaderMetadata) {
<span x-text="skipSeconds">10</span> <span x-text="skipSeconds">10</span>
</span> </span>
</button> </button>
<button <!-- Outer right button: books = next chapter; podcasts = next
type="button" EPISODE (disabled at the end of the feed). -->
@click="stepChapter(1)" if metadata.PodcastSequence != nil {
class="rounded-full p-2.5 hover:bg-surface-hover transition-colors" if metadata.PodcastSequence.NextURL != "" {
aria-label="Next chapter" <button
title="Next chapter" type="button"
style="color: var(--text-primary);" @click="goToNextEpisode(false)"
> class="rounded-full p-2.5 hover:bg-surface-hover transition-colors"
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> aria-label={ "Next episode: " + metadata.PodcastSequence.NextTitle }
<path d="M5 4l10 8-10 8V4z"></path> title={ "Next: " + metadata.PodcastSequence.NextTitle }
<path d="M19 5v14"></path> style="color: var(--text-primary);"
</svg> >
</button> @audioStepNextIcon()
</button>
} else {
<span class="rounded-full p-2.5 opacity-30 cursor-not-allowed" aria-disabled="true" title="No next episode">
@audioStepNextIcon()
</span>
}
} else {
<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);"
>
@audioStepNextIcon()
</button>
}
</div> </div>
</div> </div>
+287 -176
View File
@@ -42,7 +42,10 @@ func AudioReaderConfigJSON(metadata ReaderMetadata) string {
return string(b) return string(b)
} }
func AudioReader(user User, metadata ReaderMetadata) templ.Component { // audioStepPrevIcon / audioStepNextIcon: the transport row's outer
// step icons (chapter-step for books, EPISODE-step for podcasts — the
// same buttons serve both, round-3 user design 2026-10-30).
func audioStepPrevIcon() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -63,327 +66,435 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent templ_7745c5c3_Var1 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><title>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 20 9 12l10-8v16z\"></path> <path d=\"M5 19V5\"></path></svg>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var2 string return nil
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs("Listening · " + metadata.Title) })
if templ_7745c5c3_Err != nil { }
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 41, Col: 44}
func audioStepNextIcon() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
if templ_7745c5c3_Var2 == nil {
templ_7745c5c3_Var2 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M5 4l10 8-10 8V4z\"></path> <path d=\"M19 5v14\"></path></svg>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " · Bookhoard</title><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"><script type=\"module\" src=\"/static/main.js\"></script><style>\n\t\t\t\t/* Layered scrubber (plan §1): the native range stays for\n\t\t\t\t a11y (focus, arrow keys, aria) but its track is\n\t\t\t\t transparent so the tick/buffer/fill layers beneath show\n\t\t\t\t through. Static page CSS — templ owns the look. */\n\t\t\t\t#audio-scrub { -webkit-appearance: none; appearance: none; background: transparent; cursor: pointer; }\n\t\t\t\t#audio-scrub::-webkit-slider-runnable-track { background: transparent; border: none; }\n\t\t\t\t#audio-scrub::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; height: 14px; width: 14px; border-radius: 9999px; background: var(--accent); border: 2px solid var(--bg-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.4); }\n\t\t\t\t#audio-scrub::-moz-range-track { background: transparent; border: none; }\n\t\t\t\t#audio-scrub::-moz-range-thumb { height: 14px; width: 14px; border-radius: 9999px; background: var(--accent); border: 2px solid var(--bg-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.4); }\n\t\t\t</style></head>") return nil
if templ_7745c5c3_Err != nil { })
return templ_7745c5c3_Err }
func AudioReader(user User, metadata ReaderMetadata) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
} }
var templ_7745c5c3_Var3 = []any{"theme-" + user.Theme + " h-screen overflow-hidden"} templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var3...) if !templ_7745c5c3_IsBuffer {
if templ_7745c5c3_Err != nil { defer func() {
return templ_7745c5c3_Err templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<body x-data=\"audioReader\" x-init=\"init()\" class=\"") ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var3 := templ.GetChildren(ctx)
if templ_7745c5c3_Var3 == nil {
templ_7745c5c3_Var3 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<!doctype html><html lang=\"en\"><head><title>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var3).String()) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs("Listening · " + metadata.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 58, Col: 44}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" style=\"background-color: var(--bg-primary);\" data-media-item-id=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " · Bookhoard</title><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"><script type=\"module\" src=\"/static/main.js\"></script><style>\n\t\t\t\t/* Layered scrubber (plan §1): the native range stays for\n\t\t\t\t a11y (focus, arrow keys, aria) but its track is\n\t\t\t\t transparent so the tick/buffer/fill layers beneath show\n\t\t\t\t through. Static page CSS — templ owns the look. */\n\t\t\t\t#audio-scrub { -webkit-appearance: none; appearance: none; background: transparent; cursor: pointer; }\n\t\t\t\t#audio-scrub::-webkit-slider-runnable-track { background: transparent; border: none; }\n\t\t\t\t#audio-scrub::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; height: 14px; width: 14px; border-radius: 9999px; background: var(--accent); border: 2px solid var(--bg-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.4); }\n\t\t\t\t#audio-scrub::-moz-range-track { background: transparent; border: none; }\n\t\t\t\t#audio-scrub::-moz-range-thumb { height: 14px; width: 14px; border-radius: 9999px; background: var(--accent); border: 2px solid var(--bg-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.4); }\n\t\t\t</style></head>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 = []any{"theme-" + user.Theme + " h-screen overflow-hidden"}
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.MediaItemID) templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 64, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" data-title=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<body x-data=\"audioReader\" x-init=\"init()\" class=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var5).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 65, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" data-cover=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" style=\"background-color: var(--bg-primary);\" data-media-item-id=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.MediaItemID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 66, Col: 39} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 81, Col: 44}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" data-file-url=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" data-title=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.FileURL) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 67, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 82, Col: 30}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" data-keep-awake=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" data-cover=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.KeepScreenAwake) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 68, Col: 45} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 83, Col: 39}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" data-json=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" data-file-url=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(AudioReaderConfigJSON(metadata)) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.FileURL)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 69, Col: 46} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 84, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\"><!-- §17 wind-down dim: the sleep ticker eases this toward 35%\n\t\t\t black across a countdown's final 30s; pointer-events-none\n\t\t\t keeps the page usable, stop/finish resets it. --><div class=\"fixed inset-0 pointer-events-none z-40\" :style=\"'opacity:' + sleepDim + '; background: #000; transition: opacity 1s linear;'\"></div><div class=\"h-full flex flex-col items-center justify-center px-4 gap-6 select-none\"><!-- Header: back to the detail page (reader-style exit) and the\n\t\t\t chapter launcher. Floating drawer, never in-flow (A2b). --><div class=\"absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3\"><span class=\"inline-flex items-center gap-1\"><button type=\"button\" @click=\"backToDetail()\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"Back to book details\"><svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 12H5\"></path> <path d=\"m12 19-7-7 7-7\"></path></svg></button><!-- Podcast sequence (D7): episode prev/next + \"Ep N of M\".\n\t\t\t\t\t Rendered only when the server supplied sequence context. -->") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" data-keep-awake=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.KeepScreenAwake)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 85, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" data-json=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(AudioReaderConfigJSON(metadata))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 86, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"><!-- §17 wind-down dim: the sleep ticker eases this toward 35%\n\t\t\t black across a countdown's final 30s; pointer-events-none\n\t\t\t keeps the page usable, stop/finish resets it. --><div class=\"fixed inset-0 pointer-events-none z-40\" :style=\"'opacity:' + sleepDim + '; background: #000; transition: opacity 1s linear;'\"></div><div class=\"h-full flex flex-col items-center justify-center px-4 gap-6 select-none\"><!-- Header: back to the detail page (reader-style exit) and the\n\t\t\t chapter launcher. Floating drawer, never in-flow (A2b). --><div class=\"absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3\"><span class=\"inline-flex items-center gap-1\"><button type=\"button\" @click=\"backToDetail()\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"Back to book details\"><svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 12H5\"></path> <path d=\"m12 19-7-7 7-7\"></path></svg></button><!-- Podcast sequence (D7 + round-3 user design): \"Ep N of M\"\n\t\t\t\t\t indicator. The episode prev/next live on the transport\n\t\t\t\t\t row's outer buttons (same buttons books use for chapter\n\t\t\t\t\t stepping) — not as separate icons here. -->")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if metadata.PodcastSequence != nil { if metadata.PodcastSequence != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<span class=\"text-xs tabular-nums ml-1\" style=\"color: var(--text-secondary)\">Ep ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<span class=\"text-xs tabular-nums ml-1\" style=\"color: var(--text-secondary)\">Ep ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", metadata.PodcastSequence.EpisodeIndex)) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", metadata.PodcastSequence.EpisodeIndex))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 98, Col: 68} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 117, Col: 68}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "/") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "/")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", metadata.PodcastSequence.IndexTotal)) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", metadata.PodcastSequence.IndexTotal))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 98, Col: 127} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 117, Col: 127}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</span> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</span>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if metadata.PodcastSequence.PrevURL != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 templ.SafeURL
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(metadata.PodcastSequence.PrevURL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 101, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"Previous episode\" title=\"Previous episode\"><svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\"><path d=\"M6 5h2v14H6zM20 5v14l-11-7z\"></path></svg></a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if metadata.PodcastSequence.NextURL != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 templ.SafeURL
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(metadata.PodcastSequence.NextURL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 106, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("Next episode: " + metadata.PodcastSequence.NextTitle)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 106, Col: 220}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" title=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("Next: " + metadata.PodcastSequence.NextTitle)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 106, Col: 276}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\"><svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\"><path d=\"M16 5h2v14h-2zM4 5v14l11-7z\"></path></svg></a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</span> <span class=\"inline-flex items-center gap-2\"><button type=\"button\" @click=\"bookmarksOpen = !bookmarksOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open bookmarks (' + bookmarks.length + ')'\" title=\"Bookmarks\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path></svg></button> <button type=\"button\" @click=\"chaptersOpen = !chaptersOpen\" class=\"text-sm font-semibold px-4 py-2 rounded-full hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open chapter list (currently chapter ' + (currentChapterIdx + 1) + ' of ' + (chapters.length || 1) + ')'\" title=\"Chapters\"><span>Chapter</span> <span class=\"tabular-nums\" x-text=\"(currentChapterIdx + 1) + '/' + (chapters.length || 1)\">1/1</span> <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button> <button type=\"button\" @click=\"helpOpen = !helpOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Shortcuts &amp; interactions\" title=\"Shortcuts &amp; interactions (?)\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"2\" y=\"4\" width=\"20\" height=\"16\" rx=\"2\"></rect> <path d=\"M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8\"></path></svg></button><div class=\"relative\" @click.outside=\"settingsOpen = false\"><button type=\"button\" @click=\"settingsOpen = !settingsOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" :class=\"settingsOpen ? 'opacity-100' : 'opacity-90'\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Player settings\" title=\"Settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z\"></path> <circle cx=\"12\" cy=\"12\" r=\"3\"></circle></svg></button><div x-show=\"settingsOpen\" x-cloak x-transition class=\"absolute right-0 top-full mt-2 w-72 rounded-xl shadow-2xl p-3 z-50\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border);\"><div class=\"flex items-center justify-between mb-2 px-1\"><span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\">Player settings</span> <button type=\"button\" @click=\"settingsOpen = false\" class=\"rounded-full p-1.5 hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" aria-label=\"Close settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M18 6 6 18\"></path> <path d=\"m6 6 12 12\"></path></svg></button></div><!-- Option rows: autoplay first, panel structured for more --><div class=\"flex items-center justify-between gap-3 px-1 py-2 rounded-lg\"><span class=\"text-sm\" style=\"color: var(--text-secondary);\">Autoplay on open</span> <button type=\"button\" role=\"switch\" :aria-checked=\"autoplayOn ? 'true' : 'false'\" @click=\"toggleAutoplay()\" class=\"relative h-5 w-9 rounded-full transition-colors shrink-0\" :style=\"autoplayOn ? 'background-color: var(--accent);' : 'background-color: var(--surface-hover); border: 1px solid var(--border);'\" aria-label=\"Autoplay on open\"><span class=\Line truncated templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</span> <span class=\"inline-flex items-center gap-2\"><button type=\"button\" @click=\"bookmarksOpen = !bookmarksOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open bookmarks (' + bookmarks.length + ')'\" title=\"Bookmarks\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path></svg></button> <button type=\"button\" @click=\"chaptersOpen = !chaptersOpen\" class=\"text-sm font-semibold px-4 py-2 rounded-full hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open chapter list (currently chapter ' + (currentChapterIdx + 1) + ' of ' + (chapters.length || 1) + ')'\" title=\"Chapters\"><span>Chapter</span> <span class=\"tabular-nums\" x-text=\"(currentChapterIdx + 1) + '/' + (chapters.length || 1)\">1/1</span> <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button> <button type=\"button\" @click=\"helpOpen = !helpOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Shortcuts &amp; interactions\" title=\"Shortcuts &amp; interactions (?)\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"2\" y=\"4\" width=\"20\" height=\"16\" rx=\"2\"></rect> <path d=\"M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8\"></path></svg></button><div class=\"relative\" @click.outside=\"settingsOpen = false\"><button type=\"button\" @click=\"settingsOpen = !settingsOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" :class=\"settingsOpen ? 'opacity-100' : 'opacity-90'\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Player settings\" title=\"Settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z\"></path> <circle cx=\"12\" cy=\"12\" r=\"3\"></circle></svg></button><div x-show=\"settingsOpen\" x-cloak x-transition class=\"absolute right-0 top-full mt-2 w-72 rounded-xl shadow-2xl p-3 z-50\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border);\"><div class=\"flex items-center justify-between mb-2 px-1\"><span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\">Player settings</span> <button type=\"button\" @click=\"settingsOpen = false\" class=\"rounded-full p-1.5 hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" aria-label=\"Close settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M18 6 6 18\"></path> <path d=\"m6 6 12 12\"></path></svg></button></div><!-- Option rows: autoplay first, panel structured for more --><div class=\"flex items-center justify-between gap-3 px-1 py-2 rounded-lg\"><span class=\"text-sm\" style=\"color: var(--text-secondary);\">Autoplay on open</span> <button type=\"button\" role=\"switch\" :aria-checked=\"autoplayOn ? 'true' : 'false'\" @click=\"toggleAutoplay()\" class=\"relative h-5 w-9 rounded-full transition-colors shrink-0\" :style=\"autoplayOn ? 'background-color: var(--accent);' : 'background-color: var(--surface-hover); border: 1px solid var(--border);'\" aria-label=\"Autoplay on open\"><span class=\Line truncated
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, rate := range SpeedOptions { for _, rate := range SpeedOptions {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<option value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%g", rate))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 220, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" :selected=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("isDefaultSpeed(%g)", rate))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 221, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var17 string var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%g", rate)) templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%g×", rate))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 211, Col: 41} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 222, Col: 37}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" :selected=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</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, 22, "<img src=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var18 string var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("isDefaultSpeed(%g)", rate)) templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 212, Col: 61} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 236, Col: 36}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" alt=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var19 string var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%g×", rate)) templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 213, Col: 37} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 237, Col: 27}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</option>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" class=\"w-full h-full object-cover\" data-fallback=\"hide\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</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, 27, "<img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 227, Col: 36}
}
_, 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, 28, "\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 228, Col: 27}
}
_, 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, 29, "\" class=\"w-full h-full object-cover\" data-fallback=\"hide\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<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, 25, "<span class=\"text-5xl flex items-center justify-center h-full\" style=\"color: var(--text-secondary);\">🎧</span>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</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, 26, "</div><div class=\"max-w-md px-4\"><h1 class=\"font-semibold text-lg leading-snug line-clamp-2\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var22 string var templ_7745c5c3_Var20 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title) templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 237, Col: 117} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 246, 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_Var20))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</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, 27, "</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, 28, "<button type=\"button\" @click=\"goToPrevEpisode()\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Previous episode\" title=\"Previous episode\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = audioStepPrevIcon().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<span class=\"rounded-full p-2.5 opacity-30 cursor-not-allowed\" aria-disabled=\"true\" title=\"No previous episode\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = audioStepPrevIcon().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<button type=\"button\" @click=\"stepChapter(-1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Previous chapter\" title=\"Previous chapter\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = audioStepPrevIcon().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<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, 35, "<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_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("Next episode: " + metadata.PodcastSequence.NextTitle)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 439, Col: 74}
}
_, 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, 36, "\" title=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("Next: " + metadata.PodcastSequence.NextTitle)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 440, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = audioStepNextIcon().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<span class=\"rounded-full p-2.5 opacity-30 cursor-not-allowed\" aria-disabled=\"true\" title=\"No next episode\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = audioStepNextIcon().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<button type=\"button\" @click=\"stepChapter(1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Next chapter\" title=\"Next chapter\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = audioStepNextIcon().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div></div><!-- Settings strip: Skip ▾ · Speed ▾ · Bookmark · Volume ▾ --><div class=\"w-full max-w-xl flex items-center justify-center gap-2\"><div class=\"relative\" @click.outside=\"skipOpen = false\"><button type=\"button\" @click=\"skipOpen = !skipOpen\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :aria-label=\"'Skip duration, currently ' + skipSeconds + ' seconds'\" style=\"color: var(--text-secondary);\"><span>Skip</span> <span class=\"font-semibold\" style=\"color: var(--text-primary);\" x-text=\"skipSeconds + 's'\">10s</span> <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button><div x-show=\"skipOpen\" x-cloak class=\"absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[7.5rem]\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;\"><template x-for=\"secs in skipChoices\" :key=\"'skip' + secs\"><button type=\"button\" @click=\"pickSkip(secs)\" class=\"w-full text-left px-3 py-1.5 rounded-md text-sm\" :class=\"isSkipActive(secs) ? 'font-semibold' : ''\" :style=\"isSkipActive(secs) ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'\" x-text=\"secs + ' seconds'\"></button></template></div></div><div class=\"relative\" @click.outside=\"speedOpen = false\"><button type=\"button\" @click=\"speedOpen = !speedOpen\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :aria-label=\"'Playback speed, currently ' + speed + ' times'\" style=\"color: var(--text-secondary);\"><span>Speed</span> <span class=\"font-semibold\" style=\"color: var(--text-primary);\" x-text=\"speed + '×'\">1×</span> <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button><div x-show=\"speedOpen\" x-cloak class=\"absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[7rem]\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;\"><template x-for=\"rate in speeds\" :key=\"'speed' + rate\"><button type=\"button\" @click=\"pickSpeed(rate)\" class=\"w-full text-left px-3 py-1.5 rounded-md text-sm\" :class=\"isSpeedActive(rate) ? 'font-semibold' : ''\" :style=\"isSpeedActive(rate) ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'\" x-text=\"rate + '×' + (isDefaultSpeed(rate) ? ' · default' : '')\"></button></template><!-- §8: pin the current rate as the user default —\n\t\t\t\t\t\t books without their own speed pick it up --><button type=\"button\" x-show=\"!isDefaultSpeed(speed)\" @click=\"pickDefaultSpeed(speed)\" class=\"w-full text-left px-3 py-1.5 mt-1 rounded-md text-xs border-t\" style=\"border-color: var(--border); color: var(--text-secondary);\" x-text=\"'Make ' + speed + '× my default'\"></button></div></div><button type=\"button\" @click=\"toggleBookmarkCapture()\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :class=\"bookmarkCaptureOpen ? 'opacity-100' : 'opacity-70'\" aria-label=\"Add bookmark at current position\" style=\"color: var(--text-secondary);\"><svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path> <path d=\"M12 7v6M9 10h6\"></path></svg> <span>Bookmark</span></button><div class=\"relative\" @click.outside=\"sleepOpen = false\"><button type=\"button\" @click=\"sleepOpen = !sleepOpen\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :aria-label=\"'Sleep timer, currently ' + sleepLabelStr\" style=\"color: var(--text-secondary);\"><svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z\"></path></svg> <span class=\"font-semibold tabular-nums\" style=\"color: var(--text-primary);\" x-text=\"sleepLabelStr === 'Off' ? 'Sleep' : sleepLabelStr\">Sleep</span> <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button><div xLine truncated
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+10 -10
View File
@@ -525,13 +525,13 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
</div> </div>
<div> <div>
<label class="flex items-center gap-2 text-sm" style="color: var(--text-primary);"> <label class="flex items-center gap-2 text-sm" style="color: var(--text-primary);">
<input type="checkbox" name="abridged" checked={ book.Abridged } class="h-4 w-4 accent-current"/> <input type="checkbox" name="abridged" checked?={ book.Abridged } class="h-4 w-4 accent-current"/>
<span>Abridged edition</span> <span>Abridged edition</span>
</label> </label>
</div> </div>
<div> <div>
<label class="flex items-center gap-2 text-sm" style="color: var(--text-primary);"> <label class="flex items-center gap-2 text-sm" style="color: var(--text-primary);">
<input type="checkbox" name="explicit" checked={ book.Explicit } class="h-4 w-4 accent-current"/> <input type="checkbox" name="explicit" checked?={ book.Explicit } class="h-4 w-4 accent-current"/>
<span>Explicit content</span> <span>Explicit content</span>
</label> </label>
</div> </div>
@@ -589,19 +589,19 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
<div> <div>
<label for="manga_type" class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Manga Type</label> <label for="manga_type" class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Manga Type</label>
<select id="manga_type" name="manga_type" class="input"> <select id="manga_type" name="manga_type" class="input">
<option value="unknown" selected={ textToString(book.MangaType) == "unknown" }>Unknown</option> <option value="unknown" selected?={ textToString(book.MangaType) == "unknown" }>Unknown</option>
<option value="no" selected={ textToString(book.MangaType) == "no" }>No</option> <option value="no" selected?={ textToString(book.MangaType) == "no" }>No</option>
<option value="yes" selected={ textToString(book.MangaType) == "yes" }>Yes</option> <option value="yes" selected?={ textToString(book.MangaType) == "yes" }>Yes</option>
<option value="yes_and_right_to_left" selected={ textToString(book.MangaType) == "yes_and_right_to_left" }>Yes (Right to Left)</option> <option value="yes_and_right_to_left" selected?={ textToString(book.MangaType) == "yes_and_right_to_left" }>Yes (Right to Left)</option>
</select> </select>
</div> </div>
<div> <div>
<label for="reading_direction" class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Reading Direction</label> <label for="reading_direction" class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Reading Direction</label>
<select id="reading_direction" name="reading_direction" class="input"> <select id="reading_direction" name="reading_direction" class="input">
<option value="auto" selected={ textToString(book.ReadingDirection) == "auto" }>Auto</option> <option value="auto" selected?={ textToString(book.ReadingDirection) == "auto" }>Auto</option>
<option value="ltr" selected={ textToString(book.ReadingDirection) == "ltr" }>Left to Right</option> <option value="ltr" selected?={ textToString(book.ReadingDirection) == "ltr" }>Left to Right</option>
<option value="rtl" selected={ textToString(book.ReadingDirection) == "rtl" }>Right to Left</option> <option value="rtl" selected?={ textToString(book.ReadingDirection) == "rtl" }>Right to Left</option>
<option value="vertical" selected={ textToString(book.ReadingDirection) == "vertical" }>Vertical</option> <option value="vertical" selected?={ textToString(book.ReadingDirection) == "vertical" }>Vertical</option>
</select> </select>
</div> </div>
<div> <div>
+186 -216
View File
@@ -1151,61 +1151,55 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "\" class=\"input\" placeholder=\"Comma-separated names\"></div><div><label class=\"flex items-center gap-2 text-sm\" style=\"color: var(--text-primary);\"><input type=\"checkbox\" name=\"abridged\" checked=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "\" class=\"input\" placeholder=\"Comma-separated names\"></div><div><label class=\"flex items-center gap-2 text-sm\" style=\"color: var(--text-primary);\"><input type=\"checkbox\" name=\"abridged\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var60 string if book.Abridged {
templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Abridged) templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, " checked")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 528, Col: 72} return templ_7745c5c3_Err
}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var60) templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, " class=\"h-4 w-4 accent-current\"> <span>Abridged edition</span></label></div><div><label class=\"flex items-center gap-2 text-sm\" style=\"color: var(--text-primary);\"><input type=\"checkbox\" name=\"explicit\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "\" class=\"h-4 w-4 accent-current\"> <span>Abridged edition</span></label></div><div><label class=\"flex items-center gap-2 text-sm\" style=\"color: var(--text-primary);\"><input type=\"checkbox\" name=\"explicit\" checked=\"") if book.Explicit {
if templ_7745c5c3_Err != nil { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, " checked")
return templ_7745c5c3_Err if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} }
var templ_7745c5c3_Var61 string templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, " class=\"h-4 w-4 accent-current\"> <span>Explicit content</span></label></div>")
templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Explicit)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 534, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var61)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "\" class=\"h-4 w-4 accent-current\"> <span>Explicit content</span></label></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if book.AudioDuration() > 0 { if book.AudioDuration() > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "<div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Duration (scanned)</label><p class=\"input opacity-60 cursor-default tabular-nums\" style=\"color: var(--text-primary);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "<div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Duration (scanned)</label><p class=\"input opacity-60 cursor-default tabular-nums\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var62 string var templ_7745c5c3_Var60 string
templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(book.AudioDurationLabel()) templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(book.AudioDurationLabel())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 541, Col: 129} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 541, Col: 129}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "</p></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "</p></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "</div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "</div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "<div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('publication')\"><span class=\"font-semibold\">Publication Details</span> <span class=\"inline-flex\"><span x-show=\"openSections.publication\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "<div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('publication')\"><span class=\"font-semibold\">Publication Details</span> <span class=\"inline-flex\"><span x-show=\"openSections.publication\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -1213,7 +1207,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "</span> <span x-show=\"!openSections.publication\" x-cloak>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "</span> <span x-show=\"!openSections.publication\" x-cloak>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -1221,64 +1215,64 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "</span></span></button><div x-show=\"openSections.publication\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Age Rating</label> <input type=\"text\" name=\"age_rating\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "</span></span></button><div x-show=\"openSections.publication\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Age Rating</label> <input type=\"text\" name=\"age_rating\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var61 string
templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.AgeRating))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 560, Col: 81}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var61)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Imprint</label> <input type=\"text\" name=\"imprint\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var62 string
templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Imprint))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 564, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var62)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Scan Information</label> <input type=\"text\" name=\"scan_information\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var63 string var templ_7745c5c3_Var63 string
templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.AgeRating)) templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ScanInformation))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 560, Col: 81} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 568, Col: 93}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var63) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var63)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Imprint</label> <input type=\"text\" name=\"imprint\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Metadata Notes</label> <textarea name=\"metadata_notes\" rows=\"2\" class=\"input\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var64 string var templ_7745c5c3_Var64 string
templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Imprint)) templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(textToString(book.MetadataNotes))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 564, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Scan Information</label> <input type=\"text\" name=\"scan_information\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var65 string
templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ScanInformation))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 568, Col: 93}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var65)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Metadata Notes</label> <textarea name=\"metadata_notes\" rows=\"2\" class=\"input\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var66 string
templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(textToString(book.MetadataNotes))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 573, Col: 43} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 573, Col: 43}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "</textarea></div></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "</textarea></div></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if book.ShowComicMetadata() { if book.ShowComicMetadata() {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "<div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('comic')\"><span class=\"font-semibold\">Comic/Manga</span> <span class=\"inline-flex\"><span x-show=\"openSections.comic\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('comic')\"><span class=\"font-semibold\">Comic/Manga</span> <span class=\"inline-flex\"><span x-show=\"openSections.comic\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -1286,7 +1280,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "</span> <span x-show=\"!openSections.comic\" x-cloak>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "</span> <span x-show=\"!openSections.comic\" x-cloak>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -1294,139 +1288,115 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "</span></span></button><div x-show=\"openSections.comic\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label for=\"manga_type\" class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Manga Type</label> <select id=\"manga_type\" name=\"manga_type\" class=\"input\"><option value=\"unknown\" selected=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "</span></span></button><div x-show=\"openSections.comic\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label for=\"manga_type\" class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Manga Type</label> <select id=\"manga_type\" name=\"manga_type\" class=\"input\"><option value=\"unknown\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var67 string if textToString(book.MangaType) == "unknown" {
templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "unknown") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, " selected")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 592, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "\">Unknown</option> <option value=\"no\" selected=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var68 string
templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "no")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 593, Col: 75}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var68)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "\">No</option> <option value=\"yes\" selected=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var69 string
templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "yes")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 594, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "\">Yes</option> <option value=\"yes_and_right_to_left\" selected=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var70 string
templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "yes_and_right_to_left")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 595, Col: 113}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var70)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "\">Yes (Right to Left)</option></select></div><div><label for=\"reading_direction\" class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Reading Direction</label> <select id=\"reading_direction\" name=\"reading_direction\" class=\"input\"><option value=\"auto\" selected=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var71 string
templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "auto")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 601, Col: 86}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var71)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "\">Auto</option> <option value=\"ltr\" selected=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var72 string
templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "ltr")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 602, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var72)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "\">Left to Right</option> <option value=\"rtl\" selected=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var73 string
templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "rtl")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 603, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var73)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "\">Right to Left</option> <option value=\"vertical\" selected=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var74 string
templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "vertical")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 604, Col: 94}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var74)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "\">Vertical</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Story Arc</label> <input type=\"text\" name=\"story_arc\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var75 string
templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.StoryArc))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 609, Col: 79}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var75)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "\" class=\"input\"></div><div class=\"flex items-center gap-2\"><input type=\"checkbox\" name=\"is_black_and_white\" id=\"is_black_and_white\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if book.IsBlackAndWhite.Bool {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, " checked")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, " class=\"rounded\"> <label for=\"is_black_and_white\" class=\"text-sm\" style=\"color: var(--text-secondary);\">Black & White</label></div></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, ">Unknown</option> <option value=\"no\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if textToString(book.MangaType) == "no" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, ">No</option> <option value=\"yes\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if textToString(book.MangaType) == "yes" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, ">Yes</option> <option value=\"yes_and_right_to_left\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if textToString(book.MangaType) == "yes_and_right_to_left" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, ">Yes (Right to Left)</option></select></div><div><label for=\"reading_direction\" class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Reading Direction</label> <select id=\"reading_direction\" name=\"reading_direction\" class=\"input\"><option value=\"auto\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if textToString(book.ReadingDirection) == "auto" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, ">Auto</option> <option value=\"ltr\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if textToString(book.ReadingDirection) == "ltr" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, ">Left to Right</option> <option value=\"rtl\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if textToString(book.ReadingDirection) == "rtl" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, ">Right to Left</option> <option value=\"vertical\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if textToString(book.ReadingDirection) == "vertical" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, ">Vertical</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Story Arc</label> <input type=\"text\" name=\"story_arc\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var65 string
templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.StoryArc))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 609, Col: 79}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var65)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "\" class=\"input\"></div><div class=\"flex items-center gap-2\"><input type=\"checkbox\" name=\"is_black_and_white\" id=\"is_black_and_white\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if book.IsBlackAndWhite.Bool {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, " checked")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, " class=\"rounded\"> <label for=\"is_black_and_white\" class=\"text-sm\" style=\"color: var(--text-secondary);\">Black & White</label></div></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "<div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('technical')\"><span class=\"font-semibold\">Technical</span> <span class=\"inline-flex\"><span x-show=\"openSections.technical\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "<div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('technical')\"><span class=\"font-semibold\">Technical</span> <span class=\"inline-flex\"><span x-show=\"openSections.technical\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -1434,7 +1404,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "</span> <span x-show=\"!openSections.technical\" x-cloak>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "</span> <span x-show=\"!openSections.technical\" x-cloak>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -1442,72 +1412,72 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "</span></span></button><div x-show=\"openSections.technical\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Page Count</label> <input type=\"number\" name=\"page_count\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "</span></span></button><div x-show=\"openSections.technical\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Page Count</label> <input type=\"number\" name=\"page_count\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var76 string var templ_7745c5c3_Var66 string
templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.PageCount.Int32)) templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.PageCount.Int32))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 634, Col: 56} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 634, Col: 56}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var76) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var66)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Contributors (comma-separated)</label> <input type=\"text\" name=\"contributors\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Contributors (comma-separated)</label> <input type=\"text\" name=\"contributors\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var77 string var templ_7745c5c3_Var67 string
templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.ResolveAttributeValue(stringSliceToString(book.Contributors)) templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue(stringSliceToString(book.Contributors))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 639, Col: 93} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 639, Col: 93}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var77) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "\" class=\"input\"></div><div class=\"grid grid-cols-2 gap-3\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Format</label> <input type=\"text\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "\" class=\"input\"></div><div class=\"grid grid-cols-2 gap-3\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Format</label> <input type=\"text\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var78 string var templ_7745c5c3_Var68 string
templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MimeType)) templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MimeType))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 644, Col: 62} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 644, Col: 62}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var78) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var68)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "\" readonly class=\"input opacity-60\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">File Size</label> <input type=\"text\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "\" readonly class=\"input opacity-60\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">File Size</label> <input type=\"text\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var79 string var templ_7745c5c3_Var69 string
templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%.1f MB", float64(book.FileSize.Int64)/1024/1024)) templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%.1f MB", float64(book.FileSize.Int64)/1024/1024))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 649, Col: 97} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 649, Col: 97}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var79) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "\" readonly class=\"input opacity-60\"></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">File Path</label> <input type=\"text\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "\" readonly class=\"input opacity-60\"></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">File Path</label> <input type=\"text\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var80 string var templ_7745c5c3_Var70 string
templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.FileLocation) templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.FileLocation)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 655, Col: 51} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 655, Col: 51}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var70)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "\" 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 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\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -1515,7 +1485,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "<span>Rescan</span></span> <svg x-show=\"rescanning\" class=\"animate-spin inline-block h-5 w-5\" viewBox=\"0 0 24 24\" fill=\"none\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg></button> <button @click=\"resetMetadata()\" :disabled=\"resetting\" class=\"btn btn-secondary\" title=\"Discard all manual edits and restore scanned metadata\"><span x-show=\"!resetting\" class=\"inline-flex items-center gap-2\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "<span>Rescan</span></span> <svg x-show=\"rescanning\" class=\"animate-spin inline-block h-5 w-5\" viewBox=\"0 0 24 24\" fill=\"none\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg></button> <button @click=\"resetMetadata()\" :disabled=\"resetting\" class=\"btn btn-secondary\" title=\"Discard all manual edits and restore scanned metadata\"><span x-show=\"!resetting\" class=\"inline-flex items-center gap-2\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -1523,7 +1493,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "<span>Reset to Scanned</span></span> <svg x-show=\"resetting\" class=\"animate-spin inline-block h-5 w-5\" viewBox=\"0 0 24 24\" fill=\"none\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg></button> <button @click=\"writeMetadataToFile()\" :disabled=\"writingFile\" class=\"btn btn-secondary\" :title=\"metadataWriteHint()\"><span x-show=\"!writingFile\" class=\"inline-flex items-center gap-2\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "<span>Reset to Scanned</span></span> <svg x-show=\"resetting\" class=\"animate-spin inline-block h-5 w-5\" viewBox=\"0 0 24 24\" fill=\"none\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg></button> <button @click=\"writeMetadataToFile()\" :disabled=\"writingFile\" class=\"btn btn-secondary\" :title=\"metadataWriteHint()\"><span x-show=\"!writingFile\" class=\"inline-flex items-center gap-2\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -1531,7 +1501,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "<span>Write to file</span></span> <svg x-show=\"writingFile\" class=\"animate-spin inline-block h-5 w-5\" viewBox=\"0 0 24 24\" fill=\"none\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg></button></div><div class=\"flex space-x-3\"><button @click=\"hideMetadataEditor()\" class=\"btn btn-secondary\">Cancel</button> <button @click=\"saveMetadata()\" class=\"btn btn-primary\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "<span>Write to file</span></span> <svg x-show=\"writingFile\" class=\"animate-spin inline-block h-5 w-5\" viewBox=\"0 0 24 24\" fill=\"none\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg></button></div><div class=\"flex space-x-3\"><button @click=\"hideMetadataEditor()\" class=\"btn btn-secondary\">Cancel</button> <button @click=\"saveMetadata()\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -1539,7 +1509,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "Save</button></div></div></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "Save</button></div></div></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -1567,12 +1537,12 @@ func WriteToFileConfirmDialog(book handlers.MediaDetail) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var81 := templ.GetChildren(ctx) templ_7745c5c3_Var71 := templ.GetChildren(ctx)
if templ_7745c5c3_Var81 == nil { if templ_7745c5c3_Var71 == nil {
templ_7745c5c3_Var81 = templ.NopComponent templ_7745c5c3_Var71 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "<div id=\"write-confirm-dialog\" x-show=\"writeConfirmOpen\" x-cloak x-transition.opacity class=\"fixed inset-0 z-[60] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card w-full max-w-md p-6\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex items-center gap-3 mb-4\"><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, 161, "<div id=\"write-confirm-dialog\" x-show=\"writeConfirmOpen\" x-cloak x-transition.opacity class=\"fixed inset-0 z-[60] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card w-full max-w-md p-6\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex items-center gap-3 mb-4\"><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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -1580,7 +1550,7 @@ func WriteToFileConfirmDialog(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "</span><h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Write metadata to file?</h3></div><p class=\"text-sm mb-2\" style=\"color: var(--text-secondary)\" x-text=\"writeConfirmMessage()\"></p><p class=\"text-xs mb-6\" style=\"color: var(--text-secondary)\">Your pending edits are saved first, then the file is rewritten and re-scanned.</p><div class=\"flex justify-end gap-3\"><button type=\"button\" @click=\"writeConfirmOpen = false\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"performWriteToFile()\" :disabled=\"writingFile\" class=\"btn btn-primary\"><span x-show=\"!writingFile\" class=\"inline-flex items-center gap-2\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "</span><h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Write metadata to file?</h3></div><p class=\"text-sm mb-2\" style=\"color: var(--text-secondary)\" x-text=\"writeConfirmMessage()\"></p><p class=\"text-xs mb-6\" style=\"color: var(--text-secondary)\">Your pending edits are saved first, then the file is rewritten and re-scanned.</p><div class=\"flex justify-end gap-3\"><button type=\"button\" @click=\"writeConfirmOpen = false\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"performWriteToFile()\" :disabled=\"writingFile\" class=\"btn btn-primary\"><span x-show=\"!writingFile\" class=\"inline-flex items-center gap-2\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -1588,7 +1558,7 @@ func WriteToFileConfirmDialog(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "<span>Save &amp; write</span></span> <svg x-show=\"writingFile\" class=\"animate-spin inline-block h-5 w-5\" viewBox=\"0 0 24 24\" fill=\"none\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg></button></div></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "<span>Save &amp; write</span></span> <svg x-show=\"writingFile\" class=\"animate-spin inline-block h-5 w-5\" viewBox=\"0 0 24 24\" fill=\"none\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg></button></div></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+2 -2
View File
@@ -75,12 +75,12 @@ templ CollectionModal(collection CollectionData) {
<div class="mb-4 space-y-2"> <div class="mb-4 space-y-2">
<label class="flex items-center gap-2 text-sm" style="color: var(--text-primary);"> <label class="flex items-center gap-2 text-sm" style="color: var(--text-primary);">
<input type="hidden" name="hide_finished" value="false"/> <input type="hidden" name="hide_finished" value="false"/>
<input type="checkbox" name="hide_finished" value="true" checked={ collection.HideFinished } class="h-4 w-4 accent-current"/> <input type="checkbox" name="hide_finished" value="true" checked?={ collection.HideFinished } class="h-4 w-4 accent-current"/>
<span>Hide finished books (reading-queue mode)</span> <span>Hide finished books (reading-queue mode)</span>
</label> </label>
<label class="flex items-center gap-2 text-sm" style="color: var(--text-primary);"> <label class="flex items-center gap-2 text-sm" style="color: var(--text-primary);">
<input type="hidden" name="show_up_next" value="false"/> <input type="hidden" name="show_up_next" value="false"/>
<input type="checkbox" name="show_up_next" value="true" checked={ collection.ShowUpNext } class="h-4 w-4 accent-current"/> <input type="checkbox" name="show_up_next" value="true" checked?={ collection.ShowUpNext } class="h-4 w-4 accent-current"/>
<span>Show Up Next strip</span> <span>Show Up Next strip</span>
</label> </label>
</div> </div>
+30 -36
View File
@@ -143,56 +143,50 @@ func CollectionModal(collection CollectionData) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"><div class=\"mb-4 space-y-2\"><label class=\"flex items-center gap-2 text-sm\" style=\"color: var(--text-primary);\"><input type=\"hidden\" name=\"hide_finished\" value=\"false\"> <input type=\"checkbox\" name=\"hide_finished\" value=\"true\" checked=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"><div class=\"mb-4 space-y-2\"><label class=\"flex items-center gap-2 text-sm\" style=\"color: var(--text-primary);\"><input type=\"hidden\" name=\"hide_finished\" value=\"false\"> <input type=\"checkbox\" name=\"hide_finished\" value=\"true\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if collection.HideFinished {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " checked")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " class=\"h-4 w-4 accent-current\"> <span>Hide finished books (reading-queue mode)</span></label> <label class=\"flex items-center gap-2 text-sm\" style=\"color: var(--text-primary);\"><input type=\"hidden\" name=\"show_up_next\" value=\"false\"> <input type=\"checkbox\" name=\"show_up_next\" value=\"true\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if collection.ShowUpNext {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " checked")
if templ_7745c5c3_Err != nil {
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()\" 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
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<form hx-post=\"/api/collections\" hx-redirect=\"/collections\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Type</label><div class=\"grid grid-cols-2 gap-2\"><label class=\"relative cursor-pointer\"><input type=\"radio\" name=\"collection_type\" value=\"static\" checked class=\"peer sr-only\"><div class=\"p-3 rounded-lg border text-center peer-checked:border-blue-500\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><span class=\"font-semibold text-sm\" style=\"color: var(--text-primary)\">Static</span><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Pick books by hand</p></div></label> <label class=\"relative cursor-pointer\"><input type=\"radio\" name=\"collection_type\" value=\"smart\" class=\"peer sr-only\"><div class=\"p-3 rounded-lg border text-center peer-checked:border-blue-500 peer-checked:ring-2 peer-checked:ring-blue-400\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><span class=\"font-semibold text-sm\" style=\"color: var(--text-primary)\">⚡ Smart</span><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Match books by rules — always live</p></div></label></div></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Name</label> <input type=\"text\" name=\"name\" required class=\"input\" placeholder=\"My Reading List\"></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" class=\"input\" placeholder=\"Optional description\" rows=\"3\"></textarea></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Icon</label> <input type=\"text\" id=\"icon-search\" class=\"input mb-2\" placeholder=\"Search or type emoji...\" maxlength=\"4\" @input=\"filterIcons($event.target.value)\" @focus=\"showAllIcons()\"> <input type=\"hidden\" name=\"icon\" id=\"collection-icon\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.HideFinished) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 78, Col: 98} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 147, Col: 83}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" class=\"h-4 w-4 accent-current\"> <span>Hide finished books (reading-queue mode)</span></label> <label class=\"flex items-center gap-2 text-sm\" style=\"color: var(--text-primary);\"><input type=\"hidden\" name=\"show_up_next\" value=\"false\"> <input type=\"checkbox\" name=\"show_up_next\" value=\"true\" checked=\"") 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()\" 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
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ShowUpNext)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 83, Col: 95}
}
_, 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, 15, "\" 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()\" 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
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<form hx-post=\"/api/collections\" hx-redirect=\"/collections\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Type</label><div class=\"grid grid-cols-2 gap-2\"><label class=\"relative cursor-pointer\"><input type=\"radio\" name=\"collection_type\" value=\"static\" checked class=\"peer sr-only\"><div class=\"p-3 rounded-lg border text-center peer-checked:border-blue-500\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><span class=\"font-semibold text-sm\" style=\"color: var(--text-primary)\">Static</span><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Pick books by hand</p></div></label> <label class=\"relative cursor-pointer\"><input type=\"radio\" name=\"collection_type\" value=\"smart\" class=\"peer sr-only\"><div class=\"p-3 rounded-lg border text-center peer-checked:border-blue-500 peer-checked:ring-2 peer-checked:ring-blue-400\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><span class=\"font-semibold text-sm\" style=\"color: var(--text-primary)\">⚡ Smart</span><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Match books by rules — always live</p></div></label></div></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Name</label> <input type=\"text\" name=\"name\" required class=\"input\" placeholder=\"My Reading List\"></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" class=\"input\" placeholder=\"Optional description\" rows=\"3\"></textarea></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Icon</label> <input type=\"text\" id=\"icon-search\" class=\"input mb-2\" placeholder=\"Search or type emoji...\" maxlength=\"4\" @input=\"filterIcons($event.target.value)\" @focus=\"showAllIcons()\"> <input type=\"hidden\" name=\"icon\" id=\"collection-icon\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 147, Col: 83}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\"><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()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Create Collection</button></div></form>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+26 -20
View File
@@ -208,6 +208,10 @@ templ Header(user User, currentPath string) {
@Icon("library", "h-5 w-5 shrink-0") @Icon("library", "h-5 w-5 shrink-0")
<span>Libraries</span> <span>Libraries</span>
</a> </a>
<a href="/admin/podcasts" class={ activeClass(currentPath, "/admin/podcasts") }>
@Icon("podcast", "h-5 w-5 shrink-0")
<span>Podcasts</span>
</a>
<a href="/admin/hash-conflicts" class={ activeClass(currentPath, "/admin/hash-conflicts") }> <a href="/admin/hash-conflicts" class={ activeClass(currentPath, "/admin/hash-conflicts") }>
@Icon("copy", "h-5 w-5 shrink-0") @Icon("copy", "h-5 w-5 shrink-0")
<span>Hash Conflicts</span> <span>Hash Conflicts</span>
@@ -225,6 +229,28 @@ templ Header(user User, currentPath string) {
} }
</div> </div>
</aside> </aside>
<!-- Global confirm modal (issue #5): ONE styled dialog answers every
hx-confirm sitewide via the htmx:confirm interceptor in
web/src/confirm.ts, and exposes the confirmDialog() promise for
legacy JS call sites. Inert until shown. MUST render on every
page (it previously lived inside SidebarSignIn — signed-in
pages had no #confirm-modal, so confirmDialog degraded to the
native window.confirm; fixed 2026-10-30, user report). -->
<div
id="confirm-modal"
class="hidden fixed inset-0 z-[60] flex items-center justify-center p-4"
style="background-color: var(--surface-overlay);"
>
<div class="card w-full max-w-sm" style="box-shadow: var(--shadow-pop);">
<div class="p-5 border-b" style="border-color: var(--border);">
<p id="confirm-modal-message" class="text-sm font-medium" style="color: var(--text-primary)"></p>
</div>
<div class="p-4 flex justify-end gap-2">
<button id="confirm-modal-cancel" class="btn btn-secondary">Cancel</button>
<button id="confirm-modal-accept" class="btn btn-primary">Confirm</button>
</div>
</div>
</div>
<!-- Topbar --> <!-- Topbar -->
<header class="app-topbar"> <header class="app-topbar">
<div class="flex items-center gap-3 px-4 sm:px-6 h-full"> <div class="flex items-center gap-3 px-4 sm:px-6 h-full">
@@ -352,24 +378,4 @@ templ SidebarSignIn(currentPath string) {
} }
</div> </div>
</div> </div>
<!-- Global confirm modal (issue #5): ONE styled dialog answers every
hx-confirm sitewide via the htmx:confirm interceptor in
web/src/confirm.ts, and exposes the confirmDialog() promise for
legacy JS call sites. Inert until shown. -->
<div
id="confirm-modal"
class="hidden fixed inset-0 z-[60] flex items-center justify-center p-4"
style="background-color: var(--surface-overlay);"
>
<div class="card w-full max-w-sm" style="box-shadow: var(--shadow-pop);">
<div class="p-5 border-b" style="border-color: var(--border);">
<p id="confirm-modal-message" class="text-sm font-medium" style="color: var(--text-primary)"></p>
</div>
<div class="p-4 flex justify-end gap-2">
<button id="confirm-modal-cancel" class="btn btn-secondary">Cancel</button>
<button id="confirm-modal-accept" class="btn btn-primary">Confirm</button>
</div>
</div>
</div>
} }
+75 -45
View File
@@ -650,12 +650,12 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var38 = []any{activeClass(currentPath, "/admin/hash-conflicts")} var templ_7745c5c3_Var38 = []any{activeClass(currentPath, "/admin/podcasts")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var38...) templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var38...)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<a href=\"/admin/hash-conflicts\" class=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<a href=\"/admin/podcasts\" class=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -672,20 +672,20 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("copy", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("podcast", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<span>Hash Conflicts</span></a> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<span>Podcasts</span></a> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var40 = []any{activeClass(currentPath, "/admin/users")} var templ_7745c5c3_Var40 = []any{activeClass(currentPath, "/admin/hash-conflicts")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var40...) templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var40...)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<a href=\"/admin/users\" class=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<a href=\"/admin/hash-conflicts\" class=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -702,20 +702,20 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("users", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("copy", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<span>Users</span></a> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<span>Hash Conflicts</span></a> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var42 = []any{activeClass(currentPath, "/admin/settings")} var templ_7745c5c3_Var42 = []any{activeClass(currentPath, "/admin/users")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var42...) templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var42...)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<a href=\"/admin/settings\" class=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<a href=\"/admin/users\" class=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -732,16 +732,46 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = Icon("users", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<span>Users</span></a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var44 = []any{activeClass(currentPath, "/admin/settings")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var44...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<a href=\"/admin/settings\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var45 string
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var44).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("gear", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Icon("gear", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<span>Settings</span></a></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "<span>Settings</span></a></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</div></aside><!-- Topbar --><header class=\"app-topbar\"><div class=\"flex items-center gap-3 px-4 sm:px-6 h-full\"><button type=\"button\" class=\"app-mobile-only icon-btn\" @click=\"mobileMenuOpen = true\" aria-label=\"Open menu\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</div></aside><!-- Global confirm modal (issue #5): ONE styled dialog answers every\n\t\t hx-confirm sitewide via the htmx:confirm interceptor in\n\t\t web/src/confirm.ts, and exposes the confirmDialog() promise for\n\t\t legacy JS call sites. Inert until shown. MUST render on every\n\t\t page (it previously lived inside SidebarSignIn — signed-in\n\t\t pages had no #confirm-modal, so confirmDialog degraded to the\n\t\t native window.confirm; fixed 2026-10-30, user report). --><div id=\"confirm-modal\" class=\"hidden fixed inset-0 z-[60] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card w-full max-w-sm\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"p-5 border-b\" style=\"border-color: var(--border);\"><p id=\"confirm-modal-message\" class=\"text-sm font-medium\" style=\"color: var(--text-primary)\"></p></div><div class=\"p-4 flex justify-end gap-2\"><button id=\"confirm-modal-cancel\" class=\"btn btn-secondary\">Cancel</button> <button id=\"confirm-modal-accept\" class=\"btn btn-primary\">Confirm</button></div></div></div><!-- Topbar --><header class=\"app-topbar\"><div class=\"flex items-center gap-3 px-4 sm:px-6 h-full\"><button type=\"button\" class=\"app-mobile-only icon-btn\" @click=\"mobileMenuOpen = true\" aria-label=\"Open menu\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -749,7 +779,7 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</button><div class=\"relative flex-1 max-w-xl\" x-data=\"headerSearch\" @click.outside=\"close()\"><span class=\"absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none\" style=\"color: var(--text-secondary);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</button><div class=\"relative flex-1 max-w-xl\" x-data=\"headerSearch\" @click.outside=\"close()\"><span class=\"absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none\" style=\"color: var(--text-secondary);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -757,20 +787,20 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</span> <input type=\"text\" id=\"header-search\" name=\"q\" placeholder=\"Search your library…\" class=\"input pl-10\" autocomplete=\"off\" x-ref=\"input\" role=\"combobox\" :aria-expanded=\"open ? 'true' : 'false'\" aria-label=\"Search your library\" @input=\"onInput()\" @focus=\"onFocus()\" @keydown.arrow-down.prevent=\"moveSelection(1)\" @keydown.arrow-up.prevent=\"moveSelection(-1)\" @keydown.enter.prevent=\"openSelected()\" @keydown.escape=\"close()\" hx-get=\"/api/media-items/search?layout=dropdown\" hx-trigger=\"search\" hx-target=\"#search-results\" hx-swap=\"innerHTML\"><div id=\"search-results\" x-show=\"open\" x-cloak class=\"absolute z-50 w-full max-w-2xl mt-2 rounded-lg shadow-lg border overflow-hidden\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"></div></div></div></header><!-- module bundle; ?v=<content hash> busts stale intermediate\n\t\t builds across app restarts (browser caches are keyed by URL);\n\t\t the hash is computed at startup from the served file --><script type=\"module\" src=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</span> <input type=\"text\" id=\"header-search\" name=\"q\" placeholder=\"Search your library…\" class=\"input pl-10\" autocomplete=\"off\" x-ref=\"input\" role=\"combobox\" :aria-expanded=\"open ? 'true' : 'false'\" aria-label=\"Search your library\" @input=\"onInput()\" @focus=\"onFocus()\" @keydown.arrow-down.prevent=\"moveSelection(1)\" @keydown.arrow-up.prevent=\"moveSelection(-1)\" @keydown.enter.prevent=\"openSelected()\" @keydown.escape=\"close()\" hx-get=\"/api/media-items/search?layout=dropdown\" hx-trigger=\"search\" hx-target=\"#search-results\" hx-swap=\"innerHTML\"><div id=\"search-results\" x-show=\"open\" x-cloak class=\"absolute z-50 w-full max-w-2xl mt-2 rounded-lg shadow-lg border overflow-hidden\" style=\"background-color: var(--bg-secondary); border-color: var(--border)\"></div></div></div></header><!-- module bundle; ?v=<content hash> busts stale intermediate\n\t\t builds across app restarts (browser caches are keyed by URL);\n\t\t the hash is computed at startup from the served file --><script type=\"module\" src=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var44 string var templ_7745c5c3_Var46 string
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue("/static/main.js?v=" + MainJSVersion()) templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue("/static/main.js?v=" + MainJSVersion())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 278, Col: 68} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 304, Col: 68}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "\"></script></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "\"></script></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -795,12 +825,12 @@ func SidebarUserMenu(user User) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var45 := templ.GetChildren(ctx) templ_7745c5c3_Var47 := templ.GetChildren(ctx)
if templ_7745c5c3_Var45 == nil { if templ_7745c5c3_Var47 == nil {
templ_7745c5c3_Var45 = templ.NopComponent templ_7745c5c3_Var47 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'user' ? null : 'user'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\"><span class=\"grid place-items-center h-7 w-7 rounded-full shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'user' ? null : 'user'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\"><span class=\"grid place-items-center h-7 w-7 rounded-full shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -808,20 +838,20 @@ func SidebarUserMenu(user User) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</span> <span class=\"flex-1 text-left truncate\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</span> <span class=\"flex-1 text-left truncate\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var46 string var templ_7745c5c3_Var48 string
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username) templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 294, Col: 58} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 320, Col: 58}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</span> <span class=\"h-4 w-4 shrink-0 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'user' }\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</span> <span class=\"h-4 w-4 shrink-0 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'user' }\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -829,7 +859,7 @@ func SidebarUserMenu(user User) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</span></button><div x-show=\"openPanel === 'user'\" x-cloak x-transition class=\"mt-1 space-y-0.5 pl-1\"><a href=\"/profile\" class=\"flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</span></button><div x-show=\"openPanel === 'user'\" x-cloak x-transition class=\"mt-1 space-y-0.5 pl-1\"><a href=\"/profile\" class=\"flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -837,7 +867,7 @@ func SidebarUserMenu(user User) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<span>Profile</span></a> <button type=\"button\" @click=\"logout()\" class=\"w-full flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<span>Profile</span></a> <button type=\"button\" @click=\"logout()\" class=\"w-full flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors hover:bg-surface-hover\" style=\"color: var(--text-secondary);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -845,7 +875,7 @@ func SidebarUserMenu(user User) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<span>Logout</span></button></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<span>Logout</span></button></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -871,12 +901,12 @@ func SidebarSignIn(currentPath string) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var47 := templ.GetChildren(ctx) templ_7745c5c3_Var49 := templ.GetChildren(ctx)
if templ_7745c5c3_Var47 == nil { if templ_7745c5c3_Var49 == nil {
templ_7745c5c3_Var47 = templ.NopComponent templ_7745c5c3_Var49 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'signin' ? null : 'signin'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\"><span class=\"grid place-items-center h-7 w-7 rounded-full shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<div class=\"sidebar-panel\"><button type=\"button\" @click=\"openPanel = openPanel === 'signin' ? null : 'signin'\" class=\"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors hover:bg-surface-hover\" style=\"color: var(--text-primary);\"><span class=\"grid place-items-center h-7 w-7 rounded-full shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -884,7 +914,7 @@ func SidebarSignIn(currentPath string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</span> <span class=\"flex-1 text-left\">Sign in</span> <span class=\"h-4 w-4 shrink-0 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'signin' }\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</span> <span class=\"flex-1 text-left\">Sign in</span> <span class=\"h-4 w-4 shrink-0 transition-transform\" :class=\"{ 'rotate-180': openPanel === 'signin' }\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -892,30 +922,30 @@ func SidebarSignIn(currentPath string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</span></button><div x-show=\"openPanel === 'signin'\" x-cloak x-transition class=\"mt-1 px-1\"><form hx-post=\"/api/auth/login\" hx-target=\"#login-result\" hx-swap=\"innerHTML\" class=\"space-y-2\"><input type=\"hidden\" name=\"redirect\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</span></button><div x-show=\"openPanel === 'signin'\" x-cloak x-transition class=\"mt-1 px-1\"><form hx-post=\"/api/auth/login\" hx-target=\"#login-result\" hx-swap=\"innerHTML\" class=\"space-y-2\"><input type=\"hidden\" name=\"redirect\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var48 string var templ_7745c5c3_Var50 string
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath) templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 342, Col: 60} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 368, Col: 60}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var50)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\"> <input type=\"text\" name=\"login\" class=\"input py-1.5 text-sm\" placeholder=\"Email or username\" required> <input type=\"password\" name=\"password\" class=\"input py-1.5 text-sm\" placeholder=\"Password\" required> <button type=\"submit\" class=\"btn btn-primary w-full py-1.5 text-sm\">Sign In</button></form><div id=\"login-result\"></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\"> <input type=\"text\" name=\"login\" class=\"input py-1.5 text-sm\" placeholder=\"Email or username\" required> <input type=\"password\" name=\"password\" class=\"input py-1.5 text-sm\" placeholder=\"Password\" required> <button type=\"submit\" class=\"btn btn-primary w-full py-1.5 text-sm\">Sign In</button></form><div id=\"login-result\"></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if SignupVisible() { if SignupVisible() {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<a href=\"/register\" class=\"block text-center text-xs mt-2 hover:underline\" style=\"color: var(--text-secondary);\">Create an account</a>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<a href=\"/register\" class=\"block text-center text-xs mt-2 hover:underline\" style=\"color: var(--text-secondary);\">Create an account</a>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</div></div><!-- Global confirm modal (issue #5): ONE styled dialog answers every\n\t hx-confirm sitewide via the htmx:confirm interceptor in\n\t web/src/confirm.ts, and exposes the confirmDialog() promise for\n\t legacy JS call sites. Inert until shown. --><div id=\"confirm-modal\" class=\"hidden fixed inset-0 z-[60] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card w-full max-w-sm\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"p-5 border-b\" style=\"border-color: var(--border);\"><p id=\"confirm-modal-message\" class=\"text-sm font-medium\" style=\"color: var(--text-primary)\"></p></div><div class=\"p-4 flex justify-end gap-2\"><button id=\"confirm-modal-cancel\" class=\"btn btn-secondary\">Cancel</button> <button id=\"confirm-modal-accept\" class=\"btn btn-primary\">Confirm</button></div></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+143 -22
View File
@@ -9,6 +9,21 @@ package templates
// the polling attributes render only then). // the polling attributes render only then).
import "fmt" import "fmt"
import "strings"
// boolAttr renders a boolean data attribute value.
func boolAttr(b bool) string {
if b {
return "true"
}
return "false"
}
// podcastPct renders a listen percentage for the row's data attribute
// (one decimal; the client-side hide-played filter reads it).
func podcastPct(p float64) string {
return fmt.Sprintf("%.1f", p)
}
templ PodcastDetailPage(user User, data PodcastDetailData) { templ PodcastDetailPage(user User, data PodcastDetailData) {
<!DOCTYPE html> <!DOCTYPE html>
@@ -20,7 +35,7 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head> </head>
<body x-data="podcastDetail" class={ "theme-" + user.Theme }> <body x-data="podcastDetail" x-init="initPodcastFilterPersistence()" class={ "theme-" + user.Theme }>
@Header(user, "/podcasts") @Header(user, "/podcasts")
<main class="mx-auto container px-4 sm:px-6 lg:px-8 py-8"> <main class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
<a href="/podcasts" class="inline-flex items-center gap-1 text-sm mb-4 hover:underline" style="color: var(--text-secondary)"> <a href="/podcasts" class="inline-flex items-center gap-1 text-sm mb-4 hover:underline" style="color: var(--text-secondary)">
@@ -115,22 +130,25 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
hx-target="#episode-list" hx-target="#episode-list"
hx-swap="outerHTML" hx-swap="outerHTML"
> >
<option value="native" selected={ data.Order == "native" }>Feed order</option> <option value="native" selected?={ data.Order == "native" }>Feed order</option>
<option value="oldest" selected={ data.Order == "oldest" }>Oldest first</option> <option value="reverse" selected?={ data.Order == "reverse" }>Feed order (reversed)</option>
<option value="newest" selected={ data.Order == "newest" }>Newest first</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> </select>
<!-- Serial suggestion (D5): one-click, never silent --> <!-- Serial suggestion (D5, §0 item 3 rename): one-click,
never silent. "reverse" sorts by FEED POSITION DESC —
no pub dates involved, so messy dates can't break it. -->
if data.IsSerial && data.Order == "native" { if data.IsSerial && data.Order == "native" {
<button <button
class="btn btn-secondary text-sm" class="btn btn-secondary text-sm"
hx-put={ "/api/podcasts/feeds/" + data.Feed.ID + "/order" } hx-put={ "/api/podcasts/feeds/" + data.Feed.ID + "/order" }
hx-vals='{"order": "oldest"}' hx-vals='{"order": "reverse"}'
hx-target="#episode-list" hx-target="#episode-list"
hx-swap="outerHTML" hx-swap="outerHTML"
title="Serial feed: publishers list newest first — listen oldest-first" title="Serial feed: publishers list newest first — reverse the feed order to start at chapter 1"
> >
<span>Serial? Listen oldest-first</span> <span>Reverse feed order</span>
</button> </button>
} }
@@ -203,8 +221,64 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
</div> </div>
} }
<!-- Episode list (self-refreshing htmx partial) --> <!-- Episode-list filters (§0 item 6): pure client-local
@PodcastEpisodeList(data) presentation state (Alpine; survives the list's htmx
swaps because this toolbar lives OUTSIDE #episode-list).
Hidden rows re-apply after every swap via the component's
htmx:afterSwap listener. -->
<div class="flex flex-wrap items-center gap-2 mb-2">
<input
type="search"
class="input w-auto py-1.5 text-sm flex-1 min-w-[150px]"
placeholder="Filter episodes…"
aria-label="Filter episodes by title or description"
x-ref="episodeFilterInput"
@input="applyEpisodeFilter($event)"
/>
<label class="flex items-center gap-1.5 text-sm cursor-pointer select-none" style="color: var(--text-secondary)">
<input type="checkbox" class="cursor-pointer" @change="applyEpisodeFilter($event)" x-ref="hideFinished"/>
Hide played/downloaded
</label>
<span class="text-xs hidden" x-ref="filterEmptyNote" style="color: var(--text-secondary)">No episodes match the filter.</span>
<!-- Admin bulk file deletion (§0 item 8 follow-up): the
transport for the per-row checkboxes inside the episode
list. Lives OUTSIDE #episode-list so the button survives
the list's htmx swaps; hx-include gathers the CHECKED
boxes into the post body. State (count/ids) lives in the
podcastDetail component — CSP-safe method bindings only. -->
if data.IsAdmin && data.Feed.DownloadedN > 0 {
<div class="ml-auto flex items-center gap-2">
<span class="text-xs tabular-nums" style="color: var(--text-secondary)" x-text="bulkSelectionLabel()"></span>
<button
id="episode-bulk-delete"
type="button"
class="btn btn-secondary text-xs"
:class="bulkSelectionClass()"
hx-post={ "/api/podcasts/feeds/" + data.Feed.ID + "/episodes/delete-files" }
hx-target="#episode-list"
hx-swap="outerHTML"
hx-include="input[name='episode-file-select']:checked"
hx-confirm="Delete the selected episode files? Rows reset to pending (a feed refresh will not re-materialize them); episodes re-download on demand."
style="color: var(--status-warning, #d97706);"
>
Delete selected files
</button>
</div>
}
</div>
<!-- Episode list (self-refreshing htmx partial). Pick the
polling variant at page-render time when downloads are
active — otherwise a fresh page load mid-download
renders the non-polling container and never updates
(the polling variant was previously only reachable via
the /episodes partial route). -->
if HasActiveDownloads(data.Episodes) {
@PodcastEpisodeListPolling(data)
} else {
@PodcastEpisodeList(data)
}
</main> </main>
@PodcastUnsubscribeModal(data) @PodcastUnsubscribeModal(data)
</body> </body>
@@ -229,7 +303,7 @@ templ PodcastEpisodeList(data PodcastDetailData) {
} else { } else {
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
for _, ep := range data.Episodes { for _, ep := range data.Episodes {
@PodcastEpisodeRowView(ep) @PodcastEpisodeRowView(ep, data.IsAdmin)
} }
</div> </div>
} }
@@ -249,14 +323,40 @@ templ PodcastEpisodeListPolling(data PodcastDetailData) {
<h2 class="text-lg font-bold mb-3" style="color: var(--text-primary)">Episodes</h2> <h2 class="text-lg font-bold mb-3" style="color: var(--text-primary)">Episodes</h2>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
for _, ep := range data.Episodes { for _, ep := range data.Episodes {
@PodcastEpisodeRowView(ep) @PodcastEpisodeRowView(ep, data.IsAdmin)
} }
</div> </div>
</div> </div>
} }
templ PodcastEpisodeRowView(ep PodcastEpisodeRow) { templ PodcastEpisodeRowView(ep PodcastEpisodeRow, isAdmin bool) {
<div class="card p-3 flex items-center gap-3"> <div
class="card p-3 flex items-center gap-3"
data-episode-row="true"
data-downloaded={ boolAttr(ep.Downloaded) }
data-listen-pct={ podcastPct(ep.ListenPct) }
data-search-text={ strings.ToLower(ep.Title + " " + ep.Description) }
>
<!-- Admin bulk-select (§0 item 8 follow-up): multi-select
downloaded episodes for file deletion. Rendered ONLY for
admins + DOWNLOADED rows — a checkbox on a file-less episode
would be an invitation to confusion. State survives the
list's htmx swaps via the podcastDetail component
(reapplied on afterSwap). -->
if isAdmin {
if ep.Downloaded {
<input
type="checkbox"
name="episode-file-select"
value={ ep.ID }
class="cursor-pointer shrink-0"
aria-label={ "Select " + ep.Title + " for file deletion" }
@change="toggleBulkSelection($event.target)"
/>
} else {
<span class="w-4 shrink-0"></span>
}
}
<span class="text-xs font-mono w-10 shrink-0 text-right" style="color: var(--text-secondary)"> <span class="text-xs font-mono w-10 shrink-0 text-right" style="color: var(--text-secondary)">
{ podcastInt(ep.Position) } { podcastInt(ep.Position) }
</span> </span>
@@ -284,8 +384,8 @@ templ PodcastEpisodeRowView(ep PodcastEpisodeRow) {
{ FormatEpisodeSize(ep.ProgressReceived) + " / " + FormatEpisodeSize(ep.ProgressTotal) } { FormatEpisodeSize(ep.ProgressReceived) + " / " + FormatEpisodeSize(ep.ProgressTotal) }
</p> </p>
} else { } else {
<div class="h-1.5 w-full rounded-full overflow-hidden animate-pulse" style="background-color: var(--surface-secondary);"> <div class="podcast-progress-indeterminate h-1.5 w-full rounded-full overflow-hidden" style="background-color: var(--surface-secondary);">
<div class="h-full w-1/3 rounded-full" style="background-color: var(--accent);"></div> <div class="h-full rounded-full" style="background-color: var(--accent);"></div>
</div> </div>
<p class="text-xs mt-0.5 tabular-nums" style="color: var(--text-secondary)">{ FormatEpisodeSize(ep.ProgressReceived) }</p> <p class="text-xs mt-0.5 tabular-nums" style="color: var(--text-secondary)">{ FormatEpisodeSize(ep.ProgressReceived) }</p>
} }
@@ -367,9 +467,30 @@ templ PodcastAutoDownloadSelect(feed PodcastFeedCard) {
hx-target="#episode-list" hx-target="#episode-list"
hx-swap="outerHTML" hx-swap="outerHTML"
> >
<option value="off" selected={ feed.AutoDownload == "off" || feed.AutoDownload == "" }>Off</option> <option value="off" selected?={ feed.AutoDownload == "off" || feed.AutoDownload == "" }>Off</option>
<option value="all" selected={ feed.AutoDownload == "all" }>All episodes</option> <option value="all" selected?={ feed.AutoDownload == "all" }>All episodes</option>
<option value="newest" selected={ feed.AutoDownload == "newest" }>Newest</option> <option value="newest" selected?={ feed.AutoDownload == "newest" }>Newest</option>
</select>
}
// PodcastOrderSelectOOB is the out-of-band variant of the order
// dropdown: same element id + hx-swap-oob so htmx replaces the
// action-row select in place (§0 item 3 — the "Reverse feed order"
// button flips the order server-side; the dropdown must show it).
templ PodcastOrderSelectOOB(feedID string, order string) {
<select
id="podcast-order-select"
name="order"
hx-swap-oob="true"
class="input w-auto py-1.5 cursor-pointer"
hx-put={ "/api/podcasts/feeds/" + feedID + "/order" }
hx-target="#episode-list"
hx-swap="outerHTML"
>
<option value="native" selected?={ order == "native" }>Feed order</option>
<option value="reverse" selected?={ order == "reverse" }>Feed order (reversed)</option>
<option value="oldest" selected?={ order == "oldest" }>Oldest first (pub date)</option>
<option value="newest" selected?={ order == "newest" }>Newest first (pub date)</option>
</select> </select>
} }
@@ -385,9 +506,9 @@ templ PodcastAutoDownloadSelectOOB(feed PodcastFeedCard) {
hx-target="#episode-list" hx-target="#episode-list"
hx-swap="outerHTML" hx-swap="outerHTML"
> >
<option value="off" selected={ feed.AutoDownload == "off" || feed.AutoDownload == "" }>Off</option> <option value="off" selected?={ feed.AutoDownload == "off" || feed.AutoDownload == "" }>Off</option>
<option value="all" selected={ feed.AutoDownload == "all" }>All episodes</option> <option value="all" selected?={ feed.AutoDownload == "all" }>All episodes</option>
<option value="newest" selected={ feed.AutoDownload == "newest" }>Newest</option> <option value="newest" selected?={ feed.AutoDownload == "newest" }>Newest</option>
</select> </select>
} }
File diff suppressed because it is too large. Load diff
+11 -3
View File
@@ -15,8 +15,15 @@ import (
// PodcastFeedCard is one grid card on /podcasts (or the hero on the // PodcastFeedCard is one grid card on /podcasts (or the hero on the
// detail page). // detail page).
type PodcastFeedCard struct { // FacetValue is one facet-bar chip: the value plus how many subscribed
ID string // feeds carry it (§0 follow-up, 2026-10-30 — at scale the bar caps per
// group and overflows into a +N more expander, frequency-sorted).
type FacetValue struct {
Value string
Count int
}
type PodcastFeedCard struct { ID string
LibraryID string LibraryID string
Title string Title string
DisplayName string // subscription-owned; renders instead of Title when set DisplayName string // subscription-owned; renders instead of Title when set
@@ -86,6 +93,7 @@ type PodcastEpisodeRow struct {
Title string Title string
Description string Description string
Date string // display-formatted (user tz) Date string // display-formatted (user tz)
SortDate string // lexical-comparable pub-date key (2006-01-02) for ordering
Position int Position int
Duration string // human ("58:02" / "1:02:10") Duration string // human ("58:02" / "1:02:10")
Size string // human MB/GB when known Size string // human MB/GB when known
@@ -171,7 +179,7 @@ type PodcastsPageData struct {
ContinueFeeds []PodcastFeedCard ContinueFeeds []PodcastFeedCard
Feeds []PodcastFeedCard Feeds []PodcastFeedCard
Libraries []LibraryData // podcasts-type libraries (add-feed targets) Libraries []LibraryData // podcasts-type libraries (add-feed targets)
Facets map[string][]string Facets map[string][]FacetValue
Query string Query string
ErrorMsg string ErrorMsg string
HasLibrary bool HasLibrary bool
+59 -18
View File
@@ -2,11 +2,16 @@ package templates
import "net/url" import "net/url"
// PodcastFacetLink links a facet value back into the podcasts page's // PodcastFacetChip is the facet-bar chip: value + feed count, linking
// server-side filter (the /bookshelf FacetLink targets the wrong // back into the podcasts page's server-side filter (the /bookshelf
// surface for podcasts). // FacetLink targets the wrong surface for podcasts).
templ PodcastFacetLink(facet string, value string) { templ PodcastFacetChip(facet string, v FacetValue) {
<a href={ "/podcasts?" + facet + "=" + url.QueryEscape(value) } class="badge hover:opacity-80 transition-opacity" style="background-color: var(--surface-secondary); color: var(--text-secondary); text-decoration: none;">{ value }</a> <a href={ "/podcasts?" + facet + "=" + url.QueryEscape(v.Value) } class="badge hover:opacity-80 transition-opacity" style="background-color: var(--surface-secondary); color: var(--text-secondary); text-decoration: none;">
{ v.Value }
if v.Count > 1 {
<span class="opacity-70 ml-1">{ podcastInt(v.Count) }</span>
}
</a>
} }
// The /podcasts page (plan task 1.9, D13/D14/D18): Add Feed wizard, // The /podcasts page (plan task 1.9, D13/D14/D18): Add Feed wizard,
@@ -99,15 +104,9 @@ templ PodcastsFilterBar(data PodcastsPageData) {
if len(data.Facets["hosts"]) > 0 || len(data.Facets["tags"]) > 0 || len(data.Facets["language"]) > 0 { if len(data.Facets["hosts"]) > 0 || len(data.Facets["tags"]) > 0 || len(data.Facets["language"]) > 0 {
<div class="mb-6 flex flex-wrap items-center gap-2 text-sm"> <div class="mb-6 flex flex-wrap items-center gap-2 text-sm">
<span class="font-semibold mr-1" style="color: var(--text-secondary)">Filter:</span> <span class="font-semibold mr-1" style="color: var(--text-secondary)">Filter:</span>
for _, h := range data.Facets["hosts"] { @podcastFacetGroup("host", data.Facets["hosts"])
@PodcastFacetLink("host", h) @podcastFacetGroup("tag", data.Facets["tags"])
} @podcastFacetGroup("language", data.Facets["language"])
for _, t := range data.Facets["tags"] {
@PodcastFacetLink("tag", t)
}
for _, l := range data.Facets["language"] {
@PodcastFacetLink("language", l)
}
if data.Query != "" { if data.Query != "" {
<a href="/podcasts" class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">clear</a> <a href="/podcasts" class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">clear</a>
} }
@@ -115,6 +114,30 @@ templ PodcastsFilterBar(data PodcastsPageData) {
} }
} }
// podcastFacetBatchSize: how many facet chips render flat before the
// rest collapse into a "+N more" expander (native <details>, no JS).
// With hundreds of feeds the flat row would be a wall of chips; the
// frequency sort keeps the useful ones up front.
var podcastFacetBatchSize = 6
templ podcastFacetGroup(facet string, values []FacetValue) {
if len(values) > 0 {
for _, v := range values[:min(len(values), podcastFacetBatchSize)] {
@PodcastFacetChip(facet, v)
}
if len(values) > podcastFacetBatchSize {
<details class="inline-block">
<summary class="badge cursor-pointer select-none" style="background-color: var(--surface-secondary); color: var(--text-secondary)">+{ podcastInt(len(values) - podcastFacetBatchSize) } more</summary>
<span class="inline-flex flex-wrap gap-2 align-middle ml-2">
for _, v := range values[podcastFacetBatchSize:] {
@PodcastFacetChip(facet, v)
}
</span>
</details>
}
}
}
// PodcastsSections renders the Continue section (feeds with in-progress // PodcastsSections renders the Continue section (feeds with in-progress
// episodes, most recently listened first — plan 2.5) ahead of the // episodes, most recently listened first — plan 2.5) ahead of the
// alphabetical All Feeds grid. // alphabetical All Feeds grid.
@@ -238,10 +261,28 @@ templ PodcastAddFeedModal(libraries []LibraryData) {
} else if len(libraries) == 1 { } else if len(libraries) == 1 {
<input type="hidden" name="library_id" value={ libraries[0].ID }/> <input type="hidden" name="library_id" value={ libraries[0].ID }/>
} }
<label class="flex items-center gap-2 text-sm" style="color: var(--text-secondary)"> <div class="flex flex-wrap items-center gap-x-3 gap-y-2 text-sm">
<input type="checkbox" name="download_all" value="true" class="cursor-pointer"/> <input type="checkbox" id="podcast-download-all" name="download_all" value="true" class="cursor-pointer"/>
Download all episodes now <label for="podcast-download-all" style="color: var(--text-secondary)">Download all episodes now</label>
</label> <!-- Download order (§0 item 5c): revealed by pure CSS when
the checkbox is checked — #podcast-download-all:checked ~
.podcast-add-order. The checkbox and this row MUST stay
siblings under this wrapper for the selector to fire. -->
<div class="podcast-add-order flex items-center gap-2 pl-6">
<label for="podcast-download-order" style="color: var(--text-secondary)">Download order</label>
<select
id="podcast-download-order"
name="download_order"
class="input w-auto py-1.5 cursor-pointer"
title="Listen/download order for this feed — chapters land in this order"
>
<option value="feed" selected>Feed order</option>
<option value="reverse">Feed order (reversed)</option>
<option value="oldest">Oldest first (pub date)</option>
<option value="newest">Newest first (pub date)</option>
</select>
</div>
</div>
<div id="podcast-add-feed-error" class="text-sm hidden" style="color: var(--status-danger)"></div> <div id="podcast-add-feed-error" class="text-sm hidden" style="color: var(--status-danger)"></div>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button type="button" class="btn btn-secondary" @click="hideAddFeed()">Cancel</button> <button type="button" class="btn btn-secondary" @click="hideAddFeed()">Cancel</button>
+288 -199
View File
@@ -10,10 +10,10 @@ import templruntime "github.com/a-h/templ/runtime"
import "net/url" import "net/url"
// PodcastFacetLink links a facet value back into the podcasts page's // PodcastFacetChip is the facet-bar chip: value + feed count, linking
// server-side filter (the /bookshelf FacetLink targets the wrong // back into the podcasts page's server-side filter (the /bookshelf
// surface for podcasts). // FacetLink targets the wrong surface for podcasts).
func PodcastFacetLink(facet string, value string) templ.Component { func PodcastFacetChip(facet string, v FacetValue) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -39,9 +39,9 @@ func PodcastFacetLink(facet string, value string) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var2 templ.SafeURL var templ_7745c5c3_Var2 templ.SafeURL
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinURLErrs("/podcasts?" + facet + "=" + url.QueryEscape(value)) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinURLErrs("/podcasts?" + facet + "=" + url.QueryEscape(v.Value))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 9, Col: 62} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 9, Col: 64}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -52,15 +52,38 @@ func PodcastFacetLink(facet string, value string) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(value) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(v.Value)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 9, Col: 227} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 10, Col: 11}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</a>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if v.Count > 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<span class=\"opacity-70 ml-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(v.Count))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 12, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</a>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -88,34 +111,34 @@ func PodcastsPage(user User, data PodcastsPageData, currentPath string) templ.Co
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var4 := templ.GetChildren(ctx) templ_7745c5c3_Var5 := templ.GetChildren(ctx)
if templ_7745c5c3_Var4 == nil { if templ_7745c5c3_Var5 == nil {
templ_7745c5c3_Var4 = templ.NopComponent templ_7745c5c3_Var5 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Podcasts - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Podcasts - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var5 = []any{"theme-" + user.Theme} var templ_7745c5c3_Var6 = []any{"theme-" + user.Theme}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...) templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var6...)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<body x-data=\"podcastsPage\" class=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<body x-data=\"podcastsPage\" class=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var5).String()) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var6).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -123,7 +146,7 @@ func PodcastsPage(user User, data PodcastsPageData, currentPath string) templ.Co
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<main class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-6 flex flex-wrap items-center justify-between gap-3\"><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<main class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-6 flex flex-wrap items-center justify-between gap-3\"><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -131,12 +154,12 @@ func PodcastsPage(user User, data PodcastsPageData, currentPath string) templ.Co
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span><div><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Podcasts</h1><p class=\"text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Your subscriptions</p></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</span><div><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Podcasts</h1><p class=\"text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Your subscriptions</p></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if data.HasLibrary { if data.HasLibrary {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<button class=\"btn btn-primary\" @click=\"showAddFeed()\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<button class=\"btn btn-primary\" @click=\"showAddFeed()\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -144,36 +167,36 @@ func PodcastsPage(user User, data PodcastsPageData, currentPath string) templ.Co
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<span>Add Feed</span></button>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<span>Add Feed</span></button>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if data.ErrorMsg != "" { if data.ErrorMsg != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"mb-4 card p-4\" style=\"border-color: var(--status-danger); color: var(--status-danger);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div class=\"mb-4 card p-4\" style=\"border-color: var(--status-danger); color: var(--status-danger);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(data.ErrorMsg) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(data.ErrorMsg)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 53, Col: 21} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 58, Col: 21}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
if !data.HasLibrary { if !data.HasLibrary {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"card p-8 text-center\"><p class=\"mb-2 font-semibold\" style=\"color: var(--text-primary)\">No podcasts library yet</p><p class=\"text-sm\" style=\"color: var(--text-secondary)\">An admin creates a Podcasts library (with a download folder) first — then feeds can be added here.</p></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"card p-8 text-center\"><p class=\"mb-2 font-semibold\" style=\"color: var(--text-primary)\">No podcasts library yet</p><p class=\"text-sm\" style=\"color: var(--text-secondary)\">An admin creates a Podcasts library (with a download folder) first — then feeds can be added here.</p></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -182,12 +205,12 @@ func PodcastsPage(user User, data PodcastsPageData, currentPath string) templ.Co
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if len(data.Feeds) == 0 { if len(data.Feeds) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"card p-8 text-center\"><p class=\"mb-2 font-semibold\" style=\"color: var(--text-primary)\">No subscriptions</p><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Add an RSS feed to start listening — episodes land here and download on demand.</p></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"card p-8 text-center\"><p class=\"mb-2 font-semibold\" style=\"color: var(--text-primary)\">No subscriptions</p><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Add an RSS feed to start listening — episodes land here and download on demand.</p></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -198,7 +221,7 @@ func PodcastsPage(user User, data PodcastsPageData, currentPath string) templ.Co
} }
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</main>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</main>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -206,7 +229,7 @@ func PodcastsPage(user User, data PodcastsPageData, currentPath string) templ.Co
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</body></html>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</body></html>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -230,12 +253,12 @@ func PodcastsFilterBar(data PodcastsPageData) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var8 := templ.GetChildren(ctx) templ_7745c5c3_Var9 := templ.GetChildren(ctx)
if templ_7745c5c3_Var8 == nil { if templ_7745c5c3_Var9 == nil {
templ_7745c5c3_Var8 = templ.NopComponent templ_7745c5c3_Var9 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<form method=\"get\" action=\"/podcasts\" class=\"mb-6 flex flex-wrap items-center gap-3\"><div class=\"relative flex-1 min-w-48\"><span class=\"absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none\" style=\"color: var(--text-secondary)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<form method=\"get\" action=\"/podcasts\" class=\"mb-6 flex flex-wrap items-center gap-3\"><div class=\"relative flex-1 min-w-48\"><span class=\"absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -243,20 +266,20 @@ func PodcastsFilterBar(data PodcastsPageData) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span> <input type=\"text\" name=\"q\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</span> <input type=\"text\" name=\"q\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.Query) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(data.Query)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 88, Col: 22} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 93, Col: 22}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" placeholder=\"Search feeds, hosts, episodes…\" class=\"input w-full pl-9\" aria-label=\"Search your subscriptions\"></div><button type=\"submit\" class=\"btn btn-secondary\" aria-label=\"Search\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" placeholder=\"Search feeds, hosts, episodes…\" class=\"input w-full pl-9\" aria-label=\"Search your subscriptions\"></div><button type=\"submit\" class=\"btn btn-secondary\" aria-label=\"Search\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -264,40 +287,34 @@ func PodcastsFilterBar(data PodcastsPageData) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<span>Search</span></button></form>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<span>Search</span></button></form>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if len(data.Facets["hosts"]) > 0 || len(data.Facets["tags"]) > 0 || len(data.Facets["language"]) > 0 { if len(data.Facets["hosts"]) > 0 || len(data.Facets["tags"]) > 0 || len(data.Facets["language"]) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"mb-6 flex flex-wrap items-center gap-2 text-sm\"><span class=\"font-semibold mr-1\" style=\"color: var(--text-secondary)\">Filter:</span> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<div class=\"mb-6 flex flex-wrap items-center gap-2 text-sm\"><span class=\"font-semibold mr-1\" style=\"color: var(--text-secondary)\">Filter:</span>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, h := range data.Facets["hosts"] { templ_7745c5c3_Err = podcastFacetGroup("host", data.Facets["hosts"]).Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Err = PodcastFacetLink("host", h).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil {
if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err
return templ_7745c5c3_Err
}
} }
for _, t := range data.Facets["tags"] { templ_7745c5c3_Err = podcastFacetGroup("tag", data.Facets["tags"]).Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Err = PodcastFacetLink("tag", t).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil {
if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err
return templ_7745c5c3_Err
}
} }
for _, l := range data.Facets["language"] { templ_7745c5c3_Err = podcastFacetGroup("language", data.Facets["language"]).Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Err = PodcastFacetLink("language", l).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil {
if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err
return templ_7745c5c3_Err
}
} }
if data.Query != "" { if data.Query != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<a href=\"/podcasts\" class=\"badge\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary)\">clear</a>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<a href=\"/podcasts\" class=\"badge\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary)\">clear</a>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -306,6 +323,78 @@ func PodcastsFilterBar(data PodcastsPageData) templ.Component {
}) })
} }
// podcastFacetBatchSize: how many facet chips render flat before the
// rest collapse into a "+N more" expander (native <details>, no JS).
// With hundreds of feeds the flat row would be a wall of chips; the
// frequency sort keeps the useful ones up front.
var podcastFacetBatchSize = 6
func podcastFacetGroup(facet string, values []FacetValue) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var11 := templ.GetChildren(ctx)
if templ_7745c5c3_Var11 == nil {
templ_7745c5c3_Var11 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
if len(values) > 0 {
for _, v := range values[:min(len(values), podcastFacetBatchSize)] {
templ_7745c5c3_Err = PodcastFacetChip(facet, v).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(values) > podcastFacetBatchSize {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<details class=\"inline-block\"><summary class=\"badge cursor-pointer select-none\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary)\">+")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(len(values) - podcastFacetBatchSize))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 130, Col: 185}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " more</summary> <span class=\"inline-flex flex-wrap gap-2 align-middle ml-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, v := range values[podcastFacetBatchSize:] {
templ_7745c5c3_Err = PodcastFacetChip(facet, v).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</span></details>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
return nil
})
}
// PodcastsSections renders the Continue section (feeds with in-progress // PodcastsSections renders the Continue section (feeds with in-progress
// episodes, most recently listened first — plan 2.5) ahead of the // episodes, most recently listened first — plan 2.5) ahead of the
// alphabetical All Feeds grid. // alphabetical All Feeds grid.
@@ -325,13 +414,13 @@ func PodcastsSections(data PodcastsPageData) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var10 := templ.GetChildren(ctx) templ_7745c5c3_Var13 := templ.GetChildren(ctx)
if templ_7745c5c3_Var10 == nil { if templ_7745c5c3_Var13 == nil {
templ_7745c5c3_Var10 = templ.NopComponent templ_7745c5c3_Var13 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
if len(data.ContinueFeeds) > 0 { if len(data.ContinueFeeds) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<h2 class=\"text-lg font-bold mb-3 mt-2\" style=\"color: var(--text-primary)\">Continue</h2><div class=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mb-8\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<h2 class=\"text-lg font-bold mb-3 mt-2\" style=\"color: var(--text-primary)\">Continue</h2><div class=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mb-8\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -341,7 +430,7 @@ func PodcastsSections(data PodcastsPageData) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</div><h2 class=\"text-lg font-bold mb-3\" style=\"color: var(--text-primary)\">All Feeds</h2>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</div><h2 class=\"text-lg font-bold mb-3\" style=\"color: var(--text-primary)\">All Feeds</h2>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -370,12 +459,12 @@ func PodcastsGrid(feeds []PodcastFeedCard) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var11 := templ.GetChildren(ctx) templ_7745c5c3_Var14 := templ.GetChildren(ctx)
if templ_7745c5c3_Var11 == nil { if templ_7745c5c3_Var14 == nil {
templ_7745c5c3_Var11 = templ.NopComponent templ_7745c5c3_Var14 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<div class=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<div class=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -385,7 +474,7 @@ func PodcastsGrid(feeds []PodcastFeedCard) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -409,56 +498,56 @@ func PodcastFeedCardView(feed PodcastFeedCard) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var12 := templ.GetChildren(ctx) templ_7745c5c3_Var15 := templ.GetChildren(ctx)
if templ_7745c5c3_Var12 == nil { if templ_7745c5c3_Var15 == nil {
templ_7745c5c3_Var12 = templ.NopComponent templ_7745c5c3_Var15 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<a href=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<a href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var13 templ.SafeURL var templ_7745c5c3_Var16 templ.SafeURL
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs("/podcasts/" + feed.ID) templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs("/podcasts/" + feed.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 144, Col: 31} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 167, Col: 31}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" class=\"card p-4 flex flex-col gap-3 hover:-translate-y-0.5 transition-transform\"><div class=\"flex items-start gap-3\"><div class=\"h-16 w-16 rounded-lg shrink-0 overflow-hidden grid place-items-center\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\" class=\"card p-4 flex flex-col gap-3 hover:-translate-y-0.5 transition-transform\"><div class=\"flex items-start gap-3\"><div class=\"h-16 w-16 rounded-lg shrink-0 overflow-hidden grid place-items-center\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if feed.ArtworkPath != "" { if feed.ArtworkPath != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<img src=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<img src=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var17 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("/uploads/library-" + feed.LibraryID + "/" + feed.ArtworkPath) templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("/uploads/library-" + feed.LibraryID + "/" + feed.ArtworkPath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 151, Col: 73} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 174, Col: 73}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" alt=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" alt=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var18 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("Cover for " + feed.DisplayTitle()) templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("Cover for " + feed.DisplayTitle())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 152, Col: 46} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 175, Col: 46}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" class=\"h-full w-full object-cover\" data-image-fallback>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" class=\"h-full w-full object-cover\" data-image-fallback>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -468,53 +557,53 @@ func PodcastFeedCardView(feed PodcastFeedCard) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</div><div class=\"min-w-0\"><p class=\"font-semibold leading-snug line-clamp-2\" style=\"color: var(--text-primary)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</div><div class=\"min-w-0\"><p class=\"font-semibold leading-snug line-clamp-2\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var19 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(feed.DisplayTitle()) templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(feed.DisplayTitle())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 161, Col: 111} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 184, Col: 111}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</p><p class=\"text-xs mt-0.5\" style=\"color: var(--text-secondary)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</p><p class=\"text-xs mt-0.5\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var17 string var templ_7745c5c3_Var20 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(feed.TypeLabel()) templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(feed.TypeLabel())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 163, Col: 23} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 186, Col: 23}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if feed.Host != "" { if feed.Host != "" {
var templ_7745c5c3_Var18 string var templ_7745c5c3_Var21 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + feed.Host) templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + feed.Host)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 165, Col: 26} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 188, Col: 26}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</p></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</p></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if feed.HasContinue { if feed.HasContinue {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<p class=\"text-xs font-medium line-clamp-1\" style=\"color: var(--accent)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<p class=\"text-xs font-medium line-clamp-1\" style=\"color: var(--accent)\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -522,93 +611,93 @@ func PodcastFeedCardView(feed PodcastFeedCard) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(" " + podcastInt(feed.ContinueEpisode) + ". " + feed.ContinueTitle)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 173, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if feed.Description != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<p class=\"text-xs line-clamp-2\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(feed.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 176, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<div class=\"mt-auto flex flex-wrap items-center gap-1.5 text-xs\"><span class=\"badge\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(feed.DownloadedN) + " / " + podcastInt(feed.EpisodeTotal) + " downloaded")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 180, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if feed.EpisodeMatches > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(feed.EpisodeMatches) + " matching episode" + podcastPlural(feed.EpisodeMatches)) templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(" " + podcastInt(feed.ContinueEpisode) + ". " + feed.ContinueTitle)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 184, Col: 97} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 196, Col: 72}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</span> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if feed.Description != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<p class=\"text-xs line-clamp-2\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(feed.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 199, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<div class=\"mt-auto flex flex-wrap items-center gap-1.5 text-xs\"><span class=\"badge\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(feed.DownloadedN) + " / " + podcastInt(feed.EpisodeTotal) + " downloaded")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 203, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if feed.EpisodeMatches > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(feed.EpisodeMatches) + " matching episode" + podcastPlural(feed.EpisodeMatches))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 207, Col: 97}
}
_, 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, 53, "</span> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
if feed.NewEpisodes { if feed.NewEpisodes {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent)\">new episodes</span> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent)\">new episodes</span> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
if feed.IsComplete { if feed.IsComplete {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<span class=\"badge\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary)\">complete</span> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<span class=\"badge\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary)\">complete</span> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
if feed.Explicit { if feed.Explicit {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<span class=\"badge\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary)\">explicit</span>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<span class=\"badge\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary)\">explicit</span>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</div></a>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</div></a>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -632,12 +721,12 @@ func PodcastAddFeedModal(libraries []LibraryData) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var23 := templ.GetChildren(ctx) templ_7745c5c3_Var26 := templ.GetChildren(ctx)
if templ_7745c5c3_Var23 == nil { if templ_7745c5c3_Var26 == nil {
templ_7745c5c3_Var23 = templ.NopComponent templ_7745c5c3_Var26 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<div id=\"podcast-add-feed-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card w-full max-w-lg\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center p-6 border-b\" style=\"border-color: var(--border);\"><h2 class=\"text-lg font-bold\" style=\"color: var(--text-primary)\">Add Podcast Feed</h2><button class=\"icon-btn\" aria-label=\"Close\" @click=\"hideAddFeed()\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<div id=\"podcast-add-feed-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card w-full max-w-lg\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center p-6 border-b\" style=\"border-color: var(--border);\"><h2 class=\"text-lg font-bold\" style=\"color: var(--text-primary)\">Add Podcast Feed</h2><button class=\"icon-btn\" aria-label=\"Close\" @click=\"hideAddFeed()\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -645,71 +734,71 @@ func PodcastAddFeedModal(libraries []LibraryData) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</button></div><form hx-post=\"/api/podcasts/feeds\" hx-swap=\"none\" class=\"p-6 flex flex-col gap-4\"><div><label class=\"text-sm font-medium mb-1 block\" for=\"podcast-feed-url\" style=\"color: var(--text-secondary)\">RSS feed URL</label> <input type=\"url\" id=\"podcast-feed-url\" name=\"url\" placeholder=\"https://example.com/feed.xml\" class=\"input w-full\" required></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</button></div><form hx-post=\"/api/podcasts/feeds\" hx-swap=\"none\" class=\"p-6 flex flex-col gap-4\"><div><label class=\"text-sm font-medium mb-1 block\" for=\"podcast-feed-url\" style=\"color: var(--text-secondary)\">RSS feed URL</label> <input type=\"url\" id=\"podcast-feed-url\" name=\"url\" placeholder=\"https://example.com/feed.xml\" class=\"input w-full\" required></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if len(libraries) > 1 { if len(libraries) > 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<div><label class=\"text-sm font-medium mb-1 block\" for=\"podcast-feed-library\" style=\"color: var(--text-secondary)\">Library</label> <select id=\"podcast-feed-library\" name=\"library_id\" class=\"input w-full\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<div><label class=\"text-sm font-medium mb-1 block\" for=\"podcast-feed-library\" style=\"color: var(--text-secondary)\">Library</label> <select id=\"podcast-feed-library\" name=\"library_id\" class=\"input w-full\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, lib := range libraries { for _, lib := range libraries {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<option value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<option value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var24 string var templ_7745c5c3_Var27 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 234, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 257, Col: 30}
} }
_, 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var25 string var templ_7745c5c3_Var28 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 234, Col: 43} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 257, Col: 43}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</option>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</option>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</select></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</select></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else if len(libraries) == 1 { } else if len(libraries) == 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<input type=\"hidden\" name=\"library_id\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<input type=\"hidden\" name=\"library_id\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var26 string var templ_7745c5c3_Var29 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraries[0].ID) templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraries[0].ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 239, Col: 67} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 262, Col: 67}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\"> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<label class=\"flex items-center gap-2 text-sm\" style=\"color: var(--text-secondary)\"><input type=\"checkbox\" name=\"download_all\" value=\"true\" class=\"cursor-pointer\"> Download all episodes now</label><div id=\"podcast-add-feed-error\" class=\"text-sm hidden\" style=\"color: var(--status-danger)\"></div><div class=\"flex justify-end gap-2\"><button type=\"button\" class=\"btn btn-secondary\" @click=\"hideAddFeed()\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<div class=\"flex flex-wrap items-center gap-x-3 gap-y-2 text-sm\"><input type=\"checkbox\" id=\"podcast-download-all\" name=\"download_all\" value=\"true\" class=\"cursor-pointer\"> <label for=\"podcast-download-all\" style=\"color: var(--text-secondary)\">Download all episodes now</label><!-- Download order (§0 item 5c): revealed by pure CSS when\n\t\t\t\t\t the checkbox is checked — #podcast-download-all:checked ~\n\t\t\t\t\t .podcast-add-order. The checkbox and this row MUST stay\n\t\t\t\t\t siblings under this wrapper for the selector to fire. --><div class=\"podcast-add-order flex items-center gap-2 pl-6\"><label for=\"podcast-download-order\" style=\"color: var(--text-secondary)\">Download order</label> <select id=\"podcast-download-order\" name=\"download_order\" class=\"input w-auto py-1.5 cursor-pointer\" title=\"Listen/download order for this feed — chapters land in this order\"><option value=\"feed\" selected>Feed order</option> <option value=\"reverse\">Feed order (reversed)</option> <option value=\"oldest\">Oldest first (pub date)</option> <option value=\"newest\">Newest first (pub date)</option></select></div></div><div id=\"podcast-add-feed-error\" class=\"text-sm hidden\" style=\"color: var(--status-danger)\"></div><div class=\"flex justify-end gap-2\"><button type=\"button\" class=\"btn btn-secondary\" @click=\"hideAddFeed()\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -717,7 +806,7 @@ func PodcastAddFeedModal(libraries []LibraryData) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<span>Add &amp; Subscribe</span></button></div></form></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<span>Add &amp; Subscribe</span></button></div></form></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+13
View File
@@ -39,3 +39,16 @@ Gate: every line below executes for BOTH personas unless marked
lands; the page-level check is the 404 render for a foreign feed id. lands; the page-level check is the 404 render for a foreign feed id.
- Row 23 chains by page navigation — assert the URL becomes the next - Row 23 chains by page navigation — assert the URL becomes the next
episode's reader and progress carries over. episode's reader and progress carries over.
## 2026-10-30 — round-3 regression probes (live-server, env-gated)
Two specs added against a RUNNING dev server (skip unless
`BH_FEED=<feed uuid>` is set for the first):
- `confirm_probe.spec.ts` — every `hx-confirm` must open the GLOBAL
confirm modal, never a native dialog. Pins the htmx-2 detail-shape
fix (`detail.question`, not `detail.element`) and the modal's
presence on signed-in pages (it used to live inside SidebarSignIn).
- `add_order_probe.spec.ts` — the add-modal "Download order" select
reveals when "Download all episodes now" is checked (CSS sibling
selector; the checkbox must stay a sibling of `.podcast-add-order`).
+27
View File
@@ -0,0 +1,27 @@
// One-off probe: the add-modal "Download order" select must reveal when
// "Download all episodes now" is checked (pure-CSS sibling selector —
// the checkbox was nested, so the selector never fired; 2026-10-30).
import { test, expect } from "@playwright/test";
const BASE = process.env.BH_BASE ?? "http://localhost:8765";
test("download-order select reveals with the download-all checkbox", async ({ page }) => {
await page.request.post(`${BASE}/api/auth/login`, {
data: { login: "testuser", password: "Test@Pass123!" },
});
await page.goto(`${BASE}/podcasts`);
await page.getByRole("button", { name: "Add Feed" }).click();
const modal = page.locator("#podcast-add-feed-modal");
await expect(modal).toBeVisible();
const checkbox = page.locator("#podcast-download-all");
const orderRow = page.locator("#podcast-add-modal .podcast-add-order, form .podcast-add-order").first();
const orderSelect = page.locator("#podcast-download-order");
await expect(orderSelect).toBeAttached();
await expect(orderRow).toBeHidden(); // unchecked → hidden
await checkbox.check();
await expect(orderRow).toBeVisible(); // checked → revealed
await expect(orderSelect).toBeVisible();
});
+45
View File
@@ -0,0 +1,45 @@
// One-off probe: does clicking an hx-confirm button open the CUSTOM
// modal (#confirm-modal) or a native dialog? Clicks "Remove Feed" on a
// hard-coded feed, then CANCELS whichever appears (nothing is deleted).
import { test, expect } from "@playwright/test";
const BASE = process.env.BH_BASE ?? "http://localhost:8765";
const FEED = process.env.BH_FEED ?? "";
test.skip(!FEED, "set BH_FEED=<feed uuid> against a running dev server");
test("hx-confirm uses the custom modal", async ({ page }) => {
const nativeDialogs: string[] = [];
page.on("console", (m) => {
if (m.type() === "error" || m.type() === "warning") console.log("[console]", m.type(), m.text().slice(0, 300));
});
page.on("pageerror", (e) => console.log("[pageerror]", String(e).slice(0, 400)));
page.on("dialog", async (d) => {
nativeDialogs.push(d.message());
await d.dismiss(); // never accept
});
// Login via the API form (session cookie sticks to the context).
await page.request.post(`${BASE}/api/auth/login`, {
data: { login: "testuser", password: "Test@Pass123!" },
});
await page.goto(`${BASE}/podcasts/${FEED}`);
const modal = page.locator("#confirm-modal");
await expect(modal).toHaveCount(1); // the modal must exist on the page
await expect(modal).toBeHidden();
await page.getByRole("button", { name: "Remove Feed" }).click();
if (nativeDialogs.length > 0) {
throw new Error(
`NATIVE dialog opened (${nativeDialogs[0]}) — htmx:confirm interceptor did not preventDefault`,
);
}
await expect(modal).toBeVisible({ timeout: 3000 });
await expect(page.locator("#confirm-modal-message")).toContainText("remove this feed");
// Cancel — no request fires.
await page.locator("#confirm-modal-cancel").click();
await expect(modal).toBeHidden();
});
+4 -4
View File
@@ -86,7 +86,7 @@ export const KEYMAP: readonly KeyActionSpec[] = [
{ {
action: "prev-chapter", action: "prev-chapter",
label: "Previous chapter", label: "Previous chapter",
description: "Previous chapter", description: "Back — chapter, or episode on podcasts",
keys: [ keys: [
{ key: "k", label: "K" }, { key: "k", label: "K" },
{ key: "PageUp", label: "PgUp" }, { key: "PageUp", label: "PgUp" },
@@ -95,7 +95,7 @@ export const KEYMAP: readonly KeyActionSpec[] = [
{ {
action: "next-chapter", action: "next-chapter",
label: "Next chapter", label: "Next chapter",
description: "Next chapter", description: "Forward — chapter, or episode on podcasts",
keys: [ keys: [
{ key: "j", label: "J" }, { key: "j", label: "J" },
{ key: "PageDown", label: "PgDn" }, { key: "PageDown", label: "PgDn" },
@@ -195,14 +195,14 @@ export const KEYMAP: readonly KeyActionSpec[] = [
{ {
action: "media-next", action: "media-next",
label: "Media next key", label: "Media next key",
description: "Hardware media next (chapter step, like the notification)", description: "Hardware media next (chapter step; episode on podcasts)",
keys: [{ key: "MediaTrackNext", label: "Media ⏭" }], keys: [{ key: "MediaTrackNext", label: "Media ⏭" }],
hidden: true, hidden: true,
}, },
{ {
action: "media-prev", action: "media-prev",
label: "Media previous key", label: "Media previous key",
description: "Hardware media previous (chapter step, like the notification)", description: "Hardware media previous (chapter step; episode on podcasts)",
keys: [{ key: "MediaTrackPrevious", label: "Media ⏮" }], keys: [{ key: "MediaTrackPrevious", label: "Media ⏮" }],
hidden: true, hidden: true,
}, },
+10
View File
@@ -664,6 +664,16 @@ Alpine.data("audioReader", () => ({
}, },
stepChapter(delta: 1 | -1) { stepChapter(delta: 1 | -1) {
// Podcast sequence context: the chapter-step buttons AND every
// route that funnels through here (arrow-key/media-key shortcuts,
// hardware headset prev/next track) navigate EPISODES — one set of
// big buttons does the big navigation (round-3 user design,
// 2026-10-30). Edge episodes are server-gated (no url → no-op).
if (readConfig().podcastSequence) {
if (delta === -1) this.goToPrevEpisode();
else this.goToNextEpisode(false);
return;
}
const next = this.currentChapterIdx + delta; const next = this.currentChapterIdx + delta;
if (next < 0 || next >= this.chapters.length) return; if (next < 0 || next >= this.chapters.length) return;
this.jumpToChapter(next); this.jumpToChapter(next);
+11 -6
View File
@@ -63,15 +63,20 @@ function wireButtons(): void {
// htmx:confirm interceptor: every element with hx-confirm gets the // htmx:confirm interceptor: every element with hx-confirm gets the
// modal instead of the browser dialog; confirming issues the request. // modal instead of the browser dialog; confirming issues the request.
// (Canonical htmx pattern — see htmx docs, "confirming with a modal".) // (Canonical htmx pattern — see htmx docs, "confirming with a modal".)
//
// htmx 2 detail shape: { elt, question, issueRequest, ... } — question
// is the CLOSEST hx-confirm value htmx already resolved. (The original
// read detail.element, which htmx 2 does not provide; the handler
// bailed before preventDefault and EVERY hx-confirm fell through to
// the native dialog — user report 2026-10-30, headless-proven.)
document.body.addEventListener("htmx:confirm", function (evt) { document.body.addEventListener("htmx:confirm", function (evt) {
const custom = evt as CustomEvent<{ const custom = evt as CustomEvent<{
element: HTMLElement; elt?: HTMLElement;
issueRequest: (override?: boolean) => void; question?: string;
issueRequest: (skipConfirmation?: boolean) => void;
}>; }>;
const elt = custom.detail.element; const question = custom.detail.question;
if (!elt) return; if (!question) return; // not a confirm-gated request — normal flow
const question = elt.getAttribute("hx-confirm");
if (question === null) return;
evt.preventDefault(); evt.preventDefault();
wireButtons(); wireButtons();
confirmDialog(question).then((ok) => { confirmDialog(question).then((ok) => {
+107
View File
@@ -22,6 +22,113 @@ Alpine.data("podcastsPage", () => ({
// body declares (modal visibility) so CSP-bound Alpine stays happy. // body declares (modal visibility) so CSP-bound Alpine stays happy.
Alpine.data("podcastDetail", () => ({ Alpine.data("podcastDetail", () => ({
unsubOpen: false, unsubOpen: false,
filterListenerInstalled: false,
// Admin bulk file selection (§0 item 8 follow-up): ids of CHECKED
// episode rows. Survives the list's htmx swaps (rows re-render fresh
// and unchecked → reapplied on afterSwap); cleared after a successful
// bulk delete. Plain array — deliberately NOT reactive state beyond
// bulkCount, which the toolbar binds.
bulkIds: [] as string[],
bulkCount: 0,
bulkSelectionLabel(): string {
return this.bulkCount === 0 ? "" : this.bulkCount + " selected";
},
bulkSelectionClass(): string {
return this.bulkCount === 0 ? "opacity-40 pointer-events-none" : "";
},
toggleBulkSelection(target: HTMLInputElement): void {
const id = target.value;
if (!id) return;
if (target.checked) {
if (!this.bulkIds.includes(id)) this.bulkIds.push(id);
} else {
this.bulkIds = this.bulkIds.filter((v) => v !== id);
}
this.bulkCount = this.bulkIds.length;
},
// After each swap the list re-renders unchecked; restore ticks for
// ids still selected. Ids whose rows vanished (deleted / state
// flipped) silently drop out.
reapplyBulkSelection(): void {
if (this.bulkIds.length === 0) return;
document
.querySelectorAll<HTMLInputElement>("input[name='episode-file-select']")
.forEach((box) => {
box.checked = this.bulkIds.includes(box.value);
});
// Drop ids whose checkboxes no longer exist.
const present = new Set(
Array.from(
document.querySelectorAll<HTMLInputElement>("input[name='episode-file-select']"),
).map((b) => b.value),
);
this.bulkIds = this.bulkIds.filter((id) => present.has(id));
this.bulkCount = this.bulkIds.length;
},
clearBulkSelection(): void {
this.bulkIds = [];
this.bulkCount = 0;
document
.querySelectorAll<HTMLInputElement>("input[name='episode-file-select']:checked")
.forEach((box) => {
box.checked = false;
});
},
// Episode-list filters (§0 item 6, 2026-10-30): hide played/
// downloaded rows and/or substring-filter titles+descriptions. Pure
// presentation — the DOM IS the data (rows carry data-* attributes
// rendered by templ); no query changes, no server round trip.
// finishedThreshold 99.5% matches the server's finished rule.
applyEpisodeFilter(): void {
const input = (this as any).$refs.episodeFilterInput as HTMLInputElement | undefined;
const box = (this as any).$refs.hideFinished as HTMLInputElement | undefined;
const emptyNote = (this as any).$refs.filterEmptyNote as HTMLElement | undefined;
const q = (input?.value ?? "").trim().toLowerCase();
const hideFinished = box?.checked ?? false;
let visible = 0;
let total = 0;
document.querySelectorAll<HTMLElement>("#episode-list [data-episode-row]").forEach((row) => {
total++;
const downloaded = row.dataset.downloaded === "true";
const pct = parseFloat(row.dataset.listenPct ?? "0");
const text = row.dataset.searchText ?? "";
const hide =
(hideFinished && (downloaded || pct >= 99.5)) ||
(q !== "" && !text.toLowerCase().includes(q));
row.classList.toggle("hidden", hide);
if (!hide) visible++;
});
if (emptyNote) emptyNote.classList.toggle("hidden", visible > 0 || total === 0);
},
// Re-apply after every htmx swap of the list (polling + action
// responses swap #episode-list wholesale): fresh rows must honor the
// active filter AND restored bulk-selection ticks; a successful bulk
// delete clears the selection. Installed once per page.
initPodcastFilterPersistence(): void {
if (this.filterListenerInstalled) return;
this.filterListenerInstalled = true;
document.addEventListener("htmx:afterSwap", () => {
this.reapplyBulkSelection();
this.applyEpisodeFilter();
});
// Successful bulk delete → the list swaps fresh; drop the state.
document.addEventListener("htmx:afterRequest", (evt) => {
const custom = evt as CustomEvent<{ successful: boolean; elt: HTMLElement }>;
const elt = custom.detail.elt as HTMLElement | undefined;
if (custom.detail.successful && elt && elt.id === "episode-bulk-delete") {
this.clearBulkSelection();
}
});
},
})); }));
// Add-feed form: surface the server's error text inline (the JSON error // Add-feed form: surface the server's error text inline (the JSON error
+21 -1
View File
@@ -486,7 +486,7 @@
* which Alpine flips through `mobileMenuOpen`) and pinned on >= lg. * which Alpine flips through `mobileMenuOpen`) and pinned on >= lg.
*/ */
:root { :root {
--sidebar-w: 16rem; --sidebar-w: 14rem;
--topbar-h: 4rem; --topbar-h: 4rem;
} }
@@ -2127,3 +2127,23 @@
border-color: var(--status-danger) !important; border-color: var(--status-danger) !important;
} }
/* ---------- Podcast download indeterminate bar (sliding shimmer) ----------
Podcast feeds without Content-Length (e.g. Scribl) get an
indeterminate progress bar; animate-pulse fades in place and looks
frozen to the user, so a sliver slides across instead (2026-10-30,
§0 item 1). */
@keyframes podcast-indeterminate {
from { transform: translateX(-100%); }
to { transform: translateX(300%); }
}
.podcast-progress-indeterminate > div {
width: 33.33%;
animation: podcast-indeterminate 1.3s ease-in-out infinite;
}
/* ---------- Add-feed modal: order dropdown reveals with the
"Download all" checkbox (§0 item 5c) — pure CSS, no JS island. The
select is a sibling of the checkbox label's input. */
.podcast-add-order { display: none; }
#podcast-download-all:checked ~ .podcast-add-order { display: flex; }
+1 -1
View File
@@ -1,2 +1,2 @@
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } *,: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