summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-05 11:14:40 +0300
committerPaul Buetow <paul@buetow.org>2026-05-05 11:14:40 +0300
commita7a346ef6018ec675c2256380f3f7758a24fc5ed (patch)
tree969be18ab6f6490bf6fd575abd4cd096ae3080f0
parenta78d332080c77a5dd0d8e20cd59ec4561c2b0dda (diff)
Add frontend podcast manager UI and episode renderer
-rw-r--r--web/js/app.js2
-rw-r--r--web/js/podcasts.js182
2 files changed, 103 insertions, 81 deletions
diff --git a/web/js/app.js b/web/js/app.js
index 4029825..fb6e787 100644
--- a/web/js/app.js
+++ b/web/js/app.js
@@ -7,6 +7,7 @@ import { initShuffle, toggle as toggleShuffle, isOn as isShuffle } from './shuff
import { initThemes } from './themes.js';
import { initNotes, open as openNotes } from './notes.js';
import { initAdmin } from './admin.js';
+import { initPodcasts } from './podcasts.js';
import { state, setMedia } from './state.js';
import { initPWA } from './pwa.js';
import { initLightbox, open as openLightbox, close as closeLightbox, isOpen as isLightboxOpen, next as lightboxNext, prev as lightboxPrev, zoomIn as lightboxZoomIn, zoomOut as lightboxZoomOut, toggleSlideshow as lightboxToggleSlideshow } from './lightbox.js';
@@ -174,6 +175,7 @@ async function initApp() {
});
initNotes(() => toast('Note saved'));
initAdmin();
+ initPodcasts();
initPWA();
initUpload();
initHelp();
diff --git a/web/js/podcasts.js b/web/js/podcasts.js
index c82d2eb..c683159 100644
--- a/web/js/podcasts.js
+++ b/web/js/podcasts.js
@@ -1,117 +1,137 @@
// podcastUI.js — Podcast feed manager + episode rendering.
+import { API } from './api.js';
-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);
+export function initPodcasts() {
+ const modal = document.getElementById('podcast-modal');
+ const closeBtn = document.getElementById('podcast-close');
+ const form = document.getElementById('podcast-form');
+ const urlInput = document.getElementById('podcast-url');
+ const nameInput = document.getElementById('podcast-name');
+ const listEl = document.getElementById('podcast-list');
+ const adminBtn = document.getElementById('admin-podcasts');
- 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');
+ if (!modal) return;
- 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>`;
- }
- }
+ adminBtn?.addEventListener('click', () => {
+ modal.classList.add('open');
+ refreshPodcasts();
+ });
+
+ closeBtn?.addEventListener('click', () => modal.classList.remove('open'));
+ modal?.addEventListener('click', (e) => { if (e.target === modal) modal.classList.remove('open'); });
- addBtn.addEventListener('click', async () => {
+ form?.addEventListener('submit', async (e) => {
+ e.preventDefault();
const url = urlInput.value.trim();
+ const name = nameInput.value.trim();
if (!url) return;
- addBtn.disabled = true;
try {
- await api.subscribePodcast(url, nameInput.value.trim());
+ await API.subscribePodcast(url, name);
+ toast('Subscribed to podcast');
urlInput.value = '';
nameInput.value = '';
- await refresh();
+ refreshPodcasts();
} catch (err) {
- alert('Failed to subscribe: ' + err.message);
+ toast(err.message || 'Subscribe failed', 'error');
}
- addBtn.disabled = false;
});
- closeBtn.addEventListener('click', () => el.classList.remove('open'));
-
- return {
- open() {
- el.classList.add('open');
- refresh();
- },
- close() {
- el.classList.remove('open');
+ async function refreshPodcasts() {
+ if (!listEl) return;
+ try {
+ const podcasts = await API.podcasts();
+ if (!podcasts || !podcasts.length) {
+ listEl.innerHTML = '<p class="text-muted text-sm">No podcasts subscribed.</p>';
+ return;
+ }
+ listEl.innerHTML = podcasts.map(p =>
+ `<div class="flex gap-2 align-center py-1 border-b">
+ <span class="flex-1">${escapeHtml(p.name)}</span>
+ <span class="text-xs text-muted">${escapeHtml(p.root_path)}</span>
+ </div>`
+ ).join('');
+ } catch (err) {
+ listEl.innerHTML = `<p class="error-message">${escapeHtml(err.message)}</p>`;
}
- };
+ }
}
-export function renderEpisodeCard(ep, onDownload, onToggleComplete) {
- const isDownloaded = ep.is_downloaded;
+export function insertPodcastEpisodes(state) {
+ if (!state.selectedSetId || state.selectedSetIds.length !== 1) return;
+ const set = state.sets.find(s => s.id === state.selectedSetId);
+ if (!set || !set.is_podcast) return;
+
+ const grid = document.getElementById('media-grid');
+ if (!grid) return;
+
+ API.podcastEpisodes(state.selectedSetId).then(episodes => {
+ if (!episodes || !episodes.length) return;
+ const divider = document.createElement('div');
+ divider.className = 'grid-divider';
+ divider.textContent = 'Podcast Episodes';
+ grid.appendChild(divider);
+
+ episodes.forEach(ep => {
+ const card = document.createElement('div');
+ card.className = 'media-card episode-card';
+ card.innerHTML = renderEpisodeHtml(ep);
+ grid.appendChild(card);
+
+ const downloadBtn = card.querySelector('.btn-download-episode');
+ const completeBtn = card.querySelector('.btn-complete');
+ downloadBtn?.addEventListener('click', async () => {
+ try {
+ await API.downloadEpisode(ep.id);
+ toast('Download started');
+ } catch (err) { toast(err.message || 'Download failed', 'error'); }
+ });
+ completeBtn?.addEventListener('click', async () => {
+ try {
+ await API.toggleEpisodeComplete(ep.id);
+ completeBtn.classList.toggle('active');
+ toast(ep.is_completed ? 'Marked unlistened' : 'Marked listened');
+ } catch (err) { toast(err.message || 'Toggle failed', 'error'); }
+ });
+ });
+ }).catch(() => {}); // silently ignore errors
+}
+
+function renderEpisodeHtml(ep) {
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) : '';
-
+ const duration = ep.duration_seconds ? fmtDuration(ep.duration_seconds) : '';
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="thumb-wrap">
+ <span class="placeholder">🎙️</span>
+ <span class="badge">${dateStr}${duration ? ' • ' + duration : ''}</span>
<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>
+ <button class="icon-btn btn-sm btn-download-episode" title="Download to server">⬇</button>
+ <button class="icon-btn btn-sm btn-complete${completed ? ' active' : ''}" title="Mark as listened">✓</button>
</div>
</div>
+ <div class="meta">
+ <div class="title">${escapeHtml(ep.title || 'Untitled')}</div>
+ <div class="subtitle">${escapeHtml(ep.description || 'Podcast episode')}</div>
+ </div>
`;
}
-function formatDuration(s) {
+function fmtDuration(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]));
}
+
+function toast(msg, type = 'info') {
+ const t = document.getElementById('toast');
+ if (!t) return;
+ t.textContent = msg;
+ t.className = 'toast show ' + type;
+ setTimeout(() => t.classList.remove('show'), 2800);
+}