Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7ba4d24d6 | ||
|
|
f47734dd7d | ||
|
|
5fee8bfb4a | ||
|
|
7d5bbd1177 | ||
|
|
0741e98ea5 | ||
|
|
b293741eb1 | ||
|
|
ace9e57d27 | ||
|
|
2fd0828c06 | ||
|
|
15f41e6fd4 | ||
|
|
0da8e0cdf9 | ||
|
|
b574df346a | ||
|
|
f0f1eb6e8f | ||
|
|
5c545534c2 | ||
|
|
0f3c4a8cec | ||
|
|
e3675536aa | ||
|
|
7c7c08748e | ||
|
|
cdf81f6cf4 |
No files matched your search
+4
-2
@@ -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()
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+373
-162
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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]) })
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 & interactions\" title=\"Shortcuts & interactions (?)\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"2\" y=\"4\" width=\"20\" height=\"16\" rx=\"2\"></rect> <path d=\"M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8\"></path></svg></button><div class=\"relative\" @click.outside=\"settingsOpen = false\"><button type=\"button\" @click=\"settingsOpen = !settingsOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" :class=\"settingsOpen ? 'opacity-100' : 'opacity-90'\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Player settings\" title=\"Settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z\"></path> <circle cx=\"12\" cy=\"12\" r=\"3\"></circle></svg></button><div x-show=\"settingsOpen\" x-cloak x-transition class=\"absolute right-0 top-full mt-2 w-72 rounded-xl shadow-2xl p-3 z-50\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border);\"><div class=\"flex items-center justify-between mb-2 px-1\"><span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\">Player settings</span> <button type=\"button\" @click=\"settingsOpen = false\" class=\"rounded-full p-1.5 hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" aria-label=\"Close settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M18 6 6 18\"></path> <path d=\"m6 6 12 12\"></path></svg></button></div><!-- Option rows: autoplay first, panel structured for more --><div class=\"flex items-center justify-between gap-3 px-1 py-2 rounded-lg\"><span class=\"text-sm\" style=\"color: var(--text-secondary);\">Autoplay on open</span> <button type=\"button\" role=\"switch\" :aria-checked=\"autoplayOn ? 'true' : 'false'\" @click=\"toggleAutoplay()\" class=\"relative h-5 w-9 rounded-full transition-colors shrink-0\" :style=\"autoplayOn ? 'background-color: var(--accent);' : 'background-color: var(--surface-hover); border: 1px solid var(--border);'\" aria-label=\"Autoplay on open\"><span class=\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 & interactions\" title=\"Shortcuts & interactions (?)\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"2\" y=\"4\" width=\"20\" height=\"16\" rx=\"2\"></rect> <path d=\"M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8\"></path></svg></button><div class=\"relative\" @click.outside=\"settingsOpen = false\"><button type=\"button\" @click=\"settingsOpen = !settingsOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" :class=\"settingsOpen ? 'opacity-100' : 'opacity-90'\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Player settings\" title=\"Settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z\"></path> <circle cx=\"12\" cy=\"12\" r=\"3\"></circle></svg></button><div x-show=\"settingsOpen\" x-cloak x-transition class=\"absolute right-0 top-full mt-2 w-72 rounded-xl shadow-2xl p-3 z-50\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border);\"><div class=\"flex items-center justify-between mb-2 px-1\"><span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\">Player settings</span> <button type=\"button\" @click=\"settingsOpen = false\" class=\"rounded-full p-1.5 hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" aria-label=\"Close settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M18 6 6 18\"></path> <path d=\"m6 6 12 12\"></path></svg></button></div><!-- Option rows: autoplay first, panel structured for more --><div class=\"flex items-center justify-between gap-3 px-1 py-2 rounded-lg\"><span class=\"text-sm\" style=\"color: var(--text-secondary);\">Autoplay on open</span> <button type=\"button\" role=\"switch\" :aria-checked=\"autoplayOn ? 'true' : 'false'\" @click=\"toggleAutoplay()\" class=\"relative h-5 w-9 rounded-full transition-colors shrink-0\" :style=\"autoplayOn ? 'background-color: var(--accent);' : 'background-color: var(--surface-hover); border: 1px solid var(--border);'\" aria-label=\"Autoplay on open\"><span class=\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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 & 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 & 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+571
-375
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
@@ -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 & Subscribe</span></button></div></form></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<span>Add & 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`).
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -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) => {
|
||||||
|
|||||||
@@ -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
@@ -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,2 +1,2 @@
|
|||||||
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
|
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
|
||||||
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
|
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
|
||||||
Reference in new issue
Block a user