feat(admin): all-podcasts view — disk-first maintenance for every feed

§0 item 8 (Phase-4 item 1 pulled forward, user directive: maintenance
tooling ships WITH the features it maintains):

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

Wrong-gotcha note for future agents: parseUUID on c.Param('id') inside
a /feeds/:id/episodes/:eid route resolves the FEED, not the episode.
This commit is contained in:
John O'Keefe committed 2026-10-30 13:54:37 -04:00
1 parent b293741eb1
commit 0741e98ea5
12 files changed
+1191 -47

No files matched your search

+172
View File
@@ -1240,3 +1240,175 @@ func TestPodcastAddModalDownloadOrder(t *testing.T) {
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")
// --- 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")
}
}
+17
View File
@@ -602,6 +602,12 @@ type Querier interface {
// Link unlinked book to media item
LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookParams) (UnlinkedBooks, 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)
ListConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListConflictsByUserRow, error)
// ============================================
@@ -721,6 +727,17 @@ type Querier interface {
ReorderCollectionItems(ctx context.Context, arg ReorderCollectionItemsParams) error
// Re-parent all child rows of p_source onto p_target (defined in schema.sql)
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
ResolveHashConflict(ctx context.Context, arg ResolveHashConflictParams) error
// Ledger self-heal (faceted plan Phase 11): a scan that processes the
+124
View File
@@ -12564,6 +12564,79 @@ func (q *Queries) ListAllConflictsByUserAndStatus(ctx context.Context, arg ListA
return items, nil
}
const ListAllPodcastFeedsAdmin = `-- name: ListAllPodcastFeedsAdmin :many
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
`
type ListAllPodcastFeedsAdminRow struct {
ID pgtype.UUID `db:"id" json:"id"`
Title string `db:"title" json:"title"`
Host pgtype.Text `db:"host" json:"host"`
LastFetchedAt pgtype.Timestamptz `db:"last_fetched_at" json:"last_fetched_at"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
LibraryName string `db:"library_name" json:"library_name"`
EpisodeTotal int64 `db:"episode_total" json:"episode_total"`
EpisodeDownloaded int64 `db:"episode_downloaded" json:"episode_downloaded"`
DiskBytes int64 `db:"disk_bytes" json:"disk_bytes"`
SubscriberCount int64 `db:"subscriber_count" json:"subscriber_count"`
SubscriberNames string `db:"subscriber_names" json:"subscriber_names"`
}
// 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.
func (q *Queries) ListAllPodcastFeedsAdmin(ctx context.Context) ([]ListAllPodcastFeedsAdminRow, error) {
rows, err := q.db.Query(ctx, ListAllPodcastFeedsAdmin)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListAllPodcastFeedsAdminRow{}
for rows.Next() {
var i ListAllPodcastFeedsAdminRow
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Host,
&i.LastFetchedAt,
&i.LibraryID,
&i.LibraryName,
&i.EpisodeTotal,
&i.EpisodeDownloaded,
&i.DiskBytes,
&i.SubscriberCount,
&i.SubscriberNames,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListAllSyncQueueItems = `-- name: ListAllSyncQueueItems :many
SELECT
sq.id, sq.device_id, sq.media_item_id, sq.sync_type, sq.sync_data, sq.priority, sq.attempts, sq.max_attempts, sq.status, sq.error_message, sq.created_at, sq.processed_at,
@@ -15924,6 +15997,57 @@ func (q *Queries) ReparentMediaItemChildren(ctx context.Context, arg ReparentMed
return err
}
const ResetPodcastEpisodeFileDeleted = `-- name: ResetPodcastEpisodeFileDeleted :exec
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'
`
// 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).
func (q *Queries) ResetPodcastEpisodeFileDeleted(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, ResetPodcastEpisodeFileDeleted, id)
return err
}
const ResetPodcastFeedFilesDeleted = `-- name: ResetPodcastFeedFilesDeleted :execrows
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'
`
// Same reset for EVERY downloaded episode of a feed (per-feed
// "Delete files" action on the admin view).
func (q *Queries) ResetPodcastFeedFilesDeleted(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error) {
result, err := q.db.Exec(ctx, ResetPodcastFeedFilesDeleted, sourceFeedID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const ResetSystemCollectionMetadata = `-- name: ResetSystemCollectionMetadata :exec
UPDATE collections
SET description = $3,
+62
View File
@@ -4698,6 +4698,68 @@ WHERE feed_id = $1;
-- name: CountPodcastFeedSubscribers :one
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
-- Poller worklist (admin identity): every feed with its validators and
-- the back-off flag (is_complete feeds poll rarely, never disappear).
+62
View File
@@ -58,6 +58,68 @@ func (h *PodcastHandler) hxEpisodeList(c *echo.Context, feedID string, includePo
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"})
}
// flipSubscriptionToAll implements issue #3: Download All (button or
// add-feed checkbox) sets the CALLER's subscription policy to 'all' —
// per-user, never global (files are shared D6, policies are not). The
+58 -2
View File
@@ -14,6 +14,7 @@ import (
"bookhoard/internal/config"
"bookhoard/internal/database"
"bookhoard/internal/rls"
"bookhoard/internal/handlers"
"bookhoard/internal/services"
"bookhoard/internal/utils"
@@ -1507,8 +1508,7 @@ func registerFrontendRoutes(cfg *Config) {
// Admin hash conflicts page: content-duplicate groups flagged during hash
// backfill or rescan, resolved by keeping all copies or merging into one.
frontendProtected.GET("/admin/hash-conflicts", handlers.AdminMiddleware(func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
frontendProtected.GET("/admin/hash-conflicts", handlers.AdminMiddleware(func(c *echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
@@ -1562,6 +1562,62 @@ func registerFrontendRoutes(cfg *Config) {
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
frontendProtected.GET("/admin/users", handlers.AdminMiddleware(func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
+6
View File
@@ -8,6 +8,7 @@ import (
"bytes"
"net/http"
"bookhoard/internal/handlers"
"bookhoard/templates"
"github.com/labstack/echo/v5"
@@ -73,4 +74,9 @@ func registerPodcastRoutes(cfg *Config) {
feeds.POST("/refresh", cfg.PodcastHandler.RefreshFeed)
feeds.POST("/episodes/:eid/download", cfg.PodcastHandler.DownloadEpisodes)
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/:eid/delete-file", handlers.AdminMiddleware(cfg.PodcastHandler.AdminDeleteEpisodeFile))
}
+60
View File
@@ -542,6 +542,66 @@ func (s *PodcastService) DeleteFeedFiles(ctx context.Context, feed *database.Pod
_ = os.Remove(filepath.Join(root, feedSlug(feed.Title, feed.ID)))
}
// 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
// (purpose='podcast') as an absolute filesystem path.
func (s *PodcastService) downloadRootAbs(ctx context.Context, feed *database.PodcastFeeds) (string, error) {
+127
View File
@@ -0,0 +1,127 @@
package templates
// Admin all-podcasts view (§0 item 8 / Phase-4 item 1): EVERY feed from
// EVERY user with its maintenance aggregates, sorted by disk usage.
// The maintenance surface for the podcast subsystem — files are the
// disk hogs, so the hogs surface first. Actions: Refresh (re-fetch),
// Delete files (rows → pending, disk freed), Remove feed (rows + files
// + subscriptions gone — the same admin delete the detail page offers).
// AdminPodcastFeedData is one row of the admin all-podcasts table.
type AdminPodcastFeedData struct {
ID string
Title string
Host string
LibraryName string
Subscribers int
SubscriberNames string
EpisodeTotal int
EpisodeDownloaded int
DiskBytes int64
LastFetched string
}
templ AdminPodcasts(user User, feeds []AdminPodcastFeedData, totalFeeds int, totalDownloaded int, totalEpisodes int, totalBytes int64) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>All Podcasts - Bookhoard</title>
<link href="/static/style.css" rel="stylesheet"/>
<script type="module" src={ "/static/main.js?v=" + MainJSVersion() }></script>
</head>
<body class={ "theme-" + user.Theme }>
@Header(user, "/admin/podcasts")
<main class="container mx-auto px-4 py-8">
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<h1 class="text-2xl font-bold" style="color: var(--text-primary)">All Podcasts</h1>
<div class="text-sm tabular-nums" style="color: var(--text-secondary)">
{ podcastInt(totalFeeds) } feeds · { podcastInt(totalDownloaded) }/{ podcastInt(totalEpisodes) } episodes downloaded · { FormatEpisodeSize(totalBytes) } on disk
</div>
</div>
<p class="text-sm mb-6" style="color: var(--text-secondary)">
Maintenance view: every feed from every user. "Delete files" frees the disk and
resets episodes to pending (they re-download on demand — a feed refresh will NOT
re-materialize them). "Remove feed" deletes rows, files and subscriptions for everyone.
</p>
if len(feeds) == 0 {
<div class="card p-6 text-center text-sm" style="color: var(--text-secondary)">No podcast feeds exist yet.</div>
} else {
<div class="oc-table card">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-xs uppercase tracking-wide" style="color: var(--text-secondary); border-bottom: 1px solid var(--border);">
<th class="px-4 py-3">Feed</th>
<th class="px-4 py-3">Library</th>
<th class="px-4 py-3">Subscribers</th>
<th class="px-4 py-3 text-right">Downloaded</th>
<th class="px-4 py-3 text-right">Disk</th>
<th class="px-4 py-3">Last fetch</th>
<th class="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
for _, f := range feeds {
<tr style="border-bottom: 1px solid var(--border);" id={ "admin-feed-" + f.ID }>
<td class="px-4 py-3">
<a href={ "/podcasts/" + f.ID } class="font-medium hover:underline" style="color: var(--text-primary)">{ f.Title }</a>
if f.Host != "" {
<div class="text-xs" style="color: var(--text-secondary)">{ f.Host }</div>
}
</td>
<td class="px-4 py-3" style="color: var(--text-secondary)">{ f.LibraryName }</td>
<td class="px-4 py-3">
if f.Subscribers > 0 {
<span title={ f.SubscriberNames } style="color: var(--text-primary)">{ podcastInt(f.Subscribers) }</span>
} else {
<span class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">orphaned</span>
}
</td>
<td class="px-4 py-3 text-right tabular-nums" style="color: var(--text-primary)">
{ podcastInt(f.EpisodeDownloaded) }/{ podcastInt(f.EpisodeTotal) }
</td>
<td class="px-4 py-3 text-right tabular-nums" style="color: var(--text-primary)">{ FormatEpisodeSize(f.DiskBytes) }</td>
<td class="px-4 py-3 text-xs" style="color: var(--text-secondary)">{ f.LastFetched }</td>
<td class="px-4 py-3">
<div class="flex justify-end gap-2">
<button
class="btn btn-secondary text-xs"
hx-post={ "/api/podcasts/feeds/" + f.ID + "/refresh" }
hx-swap="none"
title="Check the feed for new episodes now"
>
Refresh
</button>
if f.EpisodeDownloaded > 0 {
<button
class="btn btn-secondary text-xs"
hx-post={ "/api/podcasts/feeds/" + f.ID + "/delete-files" }
hx-confirm={ "Delete the " + podcastInt(f.EpisodeDownloaded) + " downloaded file(s) of \"" + f.Title + "\"? Episodes reset to pending; a feed refresh will not re-materialize them." }
hx-swap="none"
style="color: var(--status-warning, #d97706);"
>
Delete files
</button>
}
<button
class="btn btn-secondary text-xs"
hx-delete={ "/api/podcasts/feeds/" + f.ID }
hx-confirm={ "ADMIN: remove \"" + f.Title + "\" for everyone? Episode rows AND downloaded files are deleted." }
hx-swap="none"
style="color: var(--status-danger);"
>
Remove
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</main>
</body>
</html>
}
+424
View File
@@ -0,0 +1,424 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
// Admin all-podcasts view (§0 item 8 / Phase-4 item 1): EVERY feed from
// EVERY user with its maintenance aggregates, sorted by disk usage.
// The maintenance surface for the podcast subsystem — files are the
// disk hogs, so the hogs surface first. Actions: Refresh (re-fetch),
// Delete files (rows → pending, disk freed), Remove feed (rows + files
// + subscriptions gone — the same admin delete the detail page offers).
// AdminPodcastFeedData is one row of the admin all-podcasts table.
type AdminPodcastFeedData struct {
ID string
Title string
Host string
LibraryName string
Subscribers int
SubscriberNames string
EpisodeTotal int
EpisodeDownloaded int
DiskBytes int64
LastFetched string
}
func AdminPodcasts(user User, feeds []AdminPodcastFeedData, totalFeeds int, totalDownloaded int, totalEpisodes int, totalBytes int64) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>All Podcasts - Bookhoard</title><link href=\"/static/style.css\" rel=\"stylesheet\"><script type=\"module\" src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/static/main.js?v=" + MainJSVersion())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 32, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"></script></head>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 = []any{"theme-" + user.Theme}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var3...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<body class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var3).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Header(user, "/admin/podcasts").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<main class=\"container mx-auto px-4 py-8\"><div class=\"flex flex-wrap items-center justify-between gap-3 mb-4\"><h1 class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">All Podcasts</h1><div class=\"text-sm tabular-nums\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(totalFeeds))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 40, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " feeds · ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(totalDownloaded))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 40, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "/")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(totalEpisodes))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 40, Col: 101}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " episodes downloaded · ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatEpisodeSize(totalBytes))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 40, Col: 158}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " on disk</div></div><p class=\"text-sm mb-6\" style=\"color: var(--text-secondary)\">Maintenance view: every feed from every user. \"Delete files\" frees the disk and resets episodes to pending (they re-download on demand — a feed refresh will NOT re-materialize them). \"Remove feed\" deletes rows, files and subscriptions for everyone.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(feeds) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"card p-6 text-center text-sm\" style=\"color: var(--text-secondary)\">No podcast feeds exist yet.</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"oc-table card\"><table class=\"w-full text-sm\"><thead><tr class=\"text-left text-xs uppercase tracking-wide\" style=\"color: var(--text-secondary); border-bottom: 1px solid var(--border);\"><th class=\"px-4 py-3\">Feed</th><th class=\"px-4 py-3\">Library</th><th class=\"px-4 py-3\">Subscribers</th><th class=\"px-4 py-3 text-right\">Downloaded</th><th class=\"px-4 py-3 text-right\">Disk</th><th class=\"px-4 py-3\">Last fetch</th><th class=\"px-4 py-3 text-right\">Actions</th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, f := range feeds {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<tr style=\"border-bottom: 1px solid var(--border);\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("admin-feed-" + f.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 66, Col: 86}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"><td class=\"px-4 py-3\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 templ.SafeURL
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinURLErrs("/podcasts/" + f.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 68, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" class=\"font-medium hover:underline\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(f.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 68, Col: 123}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if f.Host != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"text-xs\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(f.Host)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 70, Col: 78}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</td><td class=\"px-4 py-3\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(f.LibraryName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 73, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</td><td class=\"px-4 py-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if f.Subscribers > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<span title=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.SubscriberNames)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 76, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(f.Subscribers))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 76, Col: 108}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<span class=\"badge\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary)\">orphaned</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</td><td class=\"px-4 py-3 text-right tabular-nums\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(f.EpisodeDownloaded))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 82, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "/")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(f.EpisodeTotal))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 82, Col: 75}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</td><td class=\"px-4 py-3 text-right tabular-nums\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(FormatEpisodeSize(f.DiskBytes))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 84, Col: 123}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</td><td class=\"px-4 py-3 text-xs\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(f.LastFetched)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 85, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</td><td class=\"px-4 py-3\"><div class=\"flex justify-end gap-2\"><button class=\"btn btn-secondary text-xs\" hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + f.ID + "/refresh")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 90, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" hx-swap=\"none\" title=\"Check the feed for new episodes now\">Refresh</button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if f.EpisodeDownloaded > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<button class=\"btn btn-secondary text-xs\" hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + f.ID + "/delete-files")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 99, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" hx-confirm=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("Delete the " + podcastInt(f.EpisodeDownloaded) + " downloaded file(s) of \"" + f.Title + "\"? Episodes reset to pending; a feed refresh will not re-materialize them.")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 100, Col: 194}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" hx-swap=\"none\" style=\"color: var(--status-warning, #d97706);\">Delete files</button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<button class=\"btn btn-secondary text-xs\" hx-delete=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/podcasts/feeds/" + f.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 109, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" hx-confirm=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue("ADMIN: remove \"" + f.Title + "\" for everyone? Episode rows AND downloaded files are deleted.")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 110, Col: 122}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" hx-swap=\"none\" style=\"color: var(--status-danger);\">Remove</button></div></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</tbody></table></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</main></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+4
View File
@@ -208,6 +208,10 @@ templ Header(user User, currentPath string) {
@Icon("library", "h-5 w-5 shrink-0")
<span>Libraries</span>
</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") }>
@Icon("copy", "h-5 w-5 shrink-0")
<span>Hash Conflicts</span>
+75 -45
View File
@@ -650,12 +650,12 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil {
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...)
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
@@ -672,20 +672,20 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil {
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 {
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 {
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...)
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
@@ -702,20 +702,20 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil {
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 {
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 {
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...)
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
@@ -732,16 +732,46 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil {
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)
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</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\">")
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 {
return templ_7745c5c3_Err
}
@@ -749,7 +779,7 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
@@ -757,20 +787,20 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var44 string
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue("/static/main.js?v=" + MainJSVersion())
var templ_7745c5c3_Var46 string
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue("/static/main.js?v=" + MainJSVersion())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 300, 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 {
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 {
return templ_7745c5c3_Err
}
@@ -795,12 +825,12 @@ func SidebarUserMenu(user User) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var45 := templ.GetChildren(ctx)
if templ_7745c5c3_Var45 == nil {
templ_7745c5c3_Var45 = templ.NopComponent
templ_7745c5c3_Var47 := templ.GetChildren(ctx)
if templ_7745c5c3_Var47 == nil {
templ_7745c5c3_Var47 = templ.NopComponent
}
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 {
return templ_7745c5c3_Err
}
@@ -808,20 +838,20 @@ func SidebarUserMenu(user User) templ.Component {
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var46 string
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
var templ_7745c5c3_Var48 string
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 316, 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 {
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 {
return templ_7745c5c3_Err
}
@@ -829,7 +859,7 @@ func SidebarUserMenu(user User) templ.Component {
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
@@ -837,7 +867,7 @@ func SidebarUserMenu(user User) templ.Component {
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
@@ -845,7 +875,7 @@ func SidebarUserMenu(user User) templ.Component {
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
@@ -871,12 +901,12 @@ func SidebarSignIn(currentPath string) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var47 := templ.GetChildren(ctx)
if templ_7745c5c3_Var47 == nil {
templ_7745c5c3_Var47 = templ.NopComponent
templ_7745c5c3_Var49 := templ.GetChildren(ctx)
if templ_7745c5c3_Var49 == nil {
templ_7745c5c3_Var49 = templ.NopComponent
}
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 {
return templ_7745c5c3_Err
}
@@ -884,7 +914,7 @@ func SidebarSignIn(currentPath string) templ.Component {
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
@@ -892,30 +922,30 @@ func SidebarSignIn(currentPath string) templ.Component {
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var48 string
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
var templ_7745c5c3_Var50 string
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 364, 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 {
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 {
return templ_7745c5c3_Err
}
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 {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}