diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-05 11:13:17 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-05 11:13:17 +0300 |
| commit | a78d332080c77a5dd0d8e20cd59ec4561c2b0dda (patch) | |
| tree | 922a22306d315ed1dc0155401373a368d836c618 | |
| parent | 48b02fbed5d519ca293537cd779127fec50735ce (diff) | |
Add frontend podcast support: API wrappers, episode cards, feed manager modal
| -rw-r--r-- | web/index.html | 27 | ||||
| -rw-r--r-- | web/js/api.js | 8 | ||||
| -rw-r--r-- | web/js/podcasts.js | 117 |
3 files changed, 152 insertions, 0 deletions
diff --git a/web/index.html b/web/index.html index 00300f0..f3e37a1 100644 --- a/web/index.html +++ b/web/index.html @@ -232,9 +232,36 @@ <div class="flex gap-2 mt-3"> <button id="admin-rescan" class="btn btn-primary btn-sm">Trigger rescan</button> <button id="admin-trash" class="btn btn-danger btn-sm">View trash</button> + <button id="admin-podcasts" class="btn btn-primary btn-sm">Podcasts</button> </div> </div> </div> + +<!-- Podcast Manager Modal --> +<div id="podcast-modal" class="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="podcast-title"> + <div class="modal modal-wide"> + <div class="modal-header"> + <h3 id="podcast-title">Podcast Feeds</h3> + <div class="spacer"></div> + <button id="podcast-close" class="icon-btn" aria-label="Close">✕</button> + </div> + <div class="flex gap-4 flex-wrap"> + <section class="flex-1-18"> + <h4 class="my-1 text-90">Subscribe</h4> + <form id="podcast-form" class="mt-2"> + <input id="podcast-url" type="url" placeholder="https://example.com/feed.xml" required autocomplete="off"> + <input id="podcast-name" type="text" placeholder="Folder name (optional)" autocomplete="off"> + <button type="submit" class="btn btn-primary btn-sm mt-1">Subscribe</button> + </form> + </section> + <section class="flex-1-18"> + <h4 class="my-1 text-90">Active Feeds</h4> + <div id="podcast-list"></div> + </section> + </div> + </div> +</div> + <!-- Shares Modal --> <div id="shares-modal" class="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="shares-title"> <div class="modal modal-wide"> diff --git a/web/js/api.js b/web/js/api.js index 3d2586b..deabf20 100644 --- a/web/js/api.js +++ b/web/js/api.js @@ -85,4 +85,12 @@ export const API = { setPermissions: (body) => api('/api/admin/permissions', { method: 'POST', body }), delPermissions: (body) => api('/api/admin/permissions', { method: 'DELETE', body }), rescan: () => api('/api/admin/rescan', { method: 'POST' }), + podcasts: () => api('/api/podcasts'), + podcastEpisodes: (setId, limit = 50, offset = 0) => { + const params = new URLSearchParams({ limit, offset }); + return api(`/api/podcasts/${setId}/episodes?${params}`); + }, + subscribePodcast: (feedUrl, setName) => api('/api/podcasts', { method: 'POST', body: { feed_url: feedUrl, set_name: setName } }), + downloadEpisode: (episodeId) => api(`/api/podcasts/episodes/${episodeId}/download`, { method: 'POST' }), + toggleEpisodeComplete: (episodeId) => api(`/api/podcasts/episodes/${episodeId}/complete`, { method: 'POST' }), }; diff --git a/web/js/podcasts.js b/web/js/podcasts.js new file mode 100644 index 0000000..c82d2eb --- /dev/null +++ b/web/js/podcasts.js @@ -0,0 +1,117 @@ +// podcastUI.js — Podcast feed manager + episode rendering. + +export function createPodcastManager(container, api, renderEpisodeList) { + const el = document.createElement('div'); + el.id = 'podcast-manager'; + el.className = 'modal'; + el.innerHTML = ` + <div class="modal-content"> + <h2>Podcast Feeds</h2> + <div class="podcast-add-form"> + <input id="pm-url" type="url" placeholder="https://example.com/feed.xml" /> + <input id="pm-name" type="text" placeholder="Folder name (optional)" /> + <button id="pm-add">Subscribe</button> + </div> + <div class="podcast-list" id="pm-list"></div> + <button class="btn-close">Close</button> + </div> + `; + container.appendChild(el); + + const urlInput = el.querySelector('#pm-url'); + const nameInput = el.querySelector('#pm-name'); + const addBtn = el.querySelector('#pm-add'); + const listEl = el.querySelector('#pm-list'); + const closeBtn = el.querySelector('.btn-close'); + + async function refresh() { + try { + const podcasts = await api.podcasts(); + listEl.innerHTML = podcasts.length === 0 + ? '<p>No podcasts subscribed yet.</p>' + : podcasts.map(p => ` + <div class="podcast-item" data-id="${p.id}"> + <strong>${p.name}</strong> + <span class="badge">${p.is_podcast ? 'Podcast' : 'Set'}</span> + </div> + `).join(''); + } catch (err) { + listEl.innerHTML = `<p class="error">Error: ${err.message}</p>`; + } + } + + addBtn.addEventListener('click', async () => { + const url = urlInput.value.trim(); + if (!url) return; + addBtn.disabled = true; + try { + await api.subscribePodcast(url, nameInput.value.trim()); + urlInput.value = ''; + nameInput.value = ''; + await refresh(); + } catch (err) { + alert('Failed to subscribe: ' + err.message); + } + addBtn.disabled = false; + }); + + closeBtn.addEventListener('click', () => el.classList.remove('open')); + + return { + open() { + el.classList.add('open'); + refresh(); + }, + close() { + el.classList.remove('open'); + } + }; +} + +export function renderEpisodeCard(ep, onDownload, onToggleComplete) { + const isDownloaded = ep.is_downloaded; + const completed = ep.is_completed; + const dateStr = ep.published_at ? new Date(ep.published_at).toLocaleDateString() : ''; + const duration = ep.duration_seconds ? formatDuration(ep.duration_seconds) : ''; + const size = ep.file_size ? formatBytes(ep.file_size) : ''; + + return ` + <div class="card episode-card ${completed ? 'completed' : ''}" data-episode-id="${ep.id}"> + <div class="card-info"> + <div class="title">${escapeHtml(ep.title)}</div> + <div class="meta"> + ${dateStr ? `<span class="date">${dateStr}</span>` : ''} + ${duration ? `<span class="duration">${duration}</span>` : ''} + ${size ? `<span class="size">${size}</span>` : ''} + </div> + </div> + <div class="card-actions"> + ${!isDownloaded + ? `<button class="btn-download-episode" title="Download to server">Download</button>` + : `<button class="btn-play" title="Play">Play</button>` + } + <button class="btn-complete ${completed ? 'active' : ''}" title="Mark as listened"> + ${completed ? '✓ Listened' : 'Mark listened'} + </button> + </div> + </div> + `; +} + +function formatDuration(s) { + const m = Math.floor(s / 60); + const h = Math.floor(m / 60); + if (h > 0) return `${h}h ${m % 60}m`; + return `${m}m`; +} + +function formatBytes(b) { + if (b < 1024) return `${b} B`; + if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`; + return `${(b / (1024 * 1024)).toFixed(1)} MB`; +} + +function escapeHtml(str) { + if (!str) return ''; + return str.replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); +} |
