summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-05 11:13:17 +0300
committerPaul Buetow <paul@buetow.org>2026-05-05 11:13:17 +0300
commita78d332080c77a5dd0d8e20cd59ec4561c2b0dda (patch)
tree922a22306d315ed1dc0155401373a368d836c618
parent48b02fbed5d519ca293537cd779127fec50735ce (diff)
Add frontend podcast support: API wrappers, episode cards, feed manager modal
-rw-r--r--web/index.html27
-rw-r--r--web/js/api.js8
-rw-r--r--web/js/podcasts.js117
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 ? '&#10003; 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
+}