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:
1 parent
b293741eb1
commit
0741e98ea5
12 files changed
+1191
-47
No files matched your search
@@ -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(3), byID[tickets[0].ID], "PIN: reverse download order — position 3 claims first")
|
||||||
assert.Equal(t, int32(1), byID[tickets[2].ID])
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -602,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)
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -721,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
|
||||||
|
|||||||
@@ -12564,6 +12564,79 @@ func (q *Queries) ListAllConflictsByUserAndStatus(ctx context.Context, arg ListA
|
|||||||
return items, nil
|
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
|
const ListAllSyncQueueItems = `-- name: ListAllSyncQueueItems :many
|
||||||
SELECT
|
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,
|
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
|
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
|
const ResetSystemCollectionMetadata = `-- name: ResetSystemCollectionMetadata :exec
|
||||||
UPDATE collections
|
UPDATE collections
|
||||||
SET description = $3,
|
SET description = $3,
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -58,6 +58,68 @@ func (h *PodcastHandler) hxEpisodeList(c *echo.Context, feedID string, includePo
|
|||||||
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"})
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -73,4 +74,9 @@ 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/:eid/delete-file", handlers.AdminMiddleware(cfg.PodcastHandler.AdminDeleteEpisodeFile))
|
||||||
}
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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>
|
||||||
|
|||||||
+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><!-- 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 {
|
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: 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 {
|
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: 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 {
|
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: 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 {
|
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>")
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user