Files
ipodderx-rs/web/index.html
rays 74ec6e9281 Phase 2: web front end
axum served from inside the daemon so it reads SQLite and the event bus
directly: browse feeds, read show notes, play with seeking, download and
delete files, mark read/flag, and edit feed settings.

Config is now hot-reloadable (Ctx.cfg behind RwLock<Arc<Config>>), so UI
edits apply without a daemon restart. Access is a shared token minted from
/dev/urandom, carried in a cookie because an <audio> element cannot send
headers. Show notes are untrusted feed HTML and are sanitized with ammonia
server-side.

read/flagged finally have a writer, which retention has needed since it
started ordering by them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
2026-09-10 00:55:16 +00:00

317 lines
14 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ipx</title>
<style>
:root {
--bg: #14161a; --panel: #1c1f26; --panel2: #23272f; --line: #2e333d;
--fg: #e6e8ec; --dim: #9aa2b1; --accent: #6ea8fe; --good: #5fd08a;
--bad: #f4776a;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--fg);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
}
header {
display: flex; align-items: center; gap: 12px; padding: 10px 16px;
background: var(--panel); border-bottom: 1px solid var(--line);
position: sticky; top: 0; z-index: 5;
}
header h1 { font-size: 16px; margin: 0; letter-spacing: .06em; text-transform: uppercase; color: var(--dim); }
#status { margin-left: auto; color: var(--dim); font-size: 13px; min-height: 1em; }
button {
background: var(--panel2); color: var(--fg); border: 1px solid var(--line);
border-radius: 6px; padding: 5px 10px; cursor: pointer; font-size: 13px;
}
button:hover { border-color: var(--accent); }
button.primary { background: var(--accent); color: #10131a; border-color: var(--accent); font-weight: 600; }
button.danger:hover { border-color: var(--bad); color: var(--bad); }
main { display: grid; grid-template-columns: 300px 1fr; min-height: calc(100vh - 49px); }
#feeds { background: var(--panel); border-right: 1px solid var(--line); padding: 8px; }
.feed {
padding: 8px 10px; border-radius: 6px; cursor: pointer; margin-bottom: 2px;
}
.feed:hover { background: var(--panel2); }
.feed.sel { background: var(--panel2); box-shadow: inset 3px 0 0 var(--accent); }
.feed .name { display: flex; gap: 8px; align-items: baseline; }
.feed .name b { font-weight: 600; font-size: 14px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pill { background: var(--accent); color: #10131a; border-radius: 10px; padding: 0 7px; font-size: 11px; font-weight: 700; }
.feed small, .meta { color: var(--dim); font-size: 12px; }
.err { color: var(--bad); font-size: 12px; }
#content { padding: 16px 20px; max-width: 900px; }
.entry { border: 1px solid var(--line); border-radius: 8px; margin-bottom: 8px; background: var(--panel); }
.entry.unread { border-left: 3px solid var(--accent); }
.head { padding: 10px 12px; cursor: pointer; display: flex; gap: 10px; align-items: baseline; }
.head h3 { margin: 0; font-size: 15px; font-weight: 600; flex: 1; }
.body { padding: 0 12px 12px; border-top: 1px solid var(--line); }
.desc { color: #cfd4dd; font-size: 14px; overflow-wrap: anywhere; }
.desc img { max-width: 100%; height: auto; }
.desc a { color: var(--accent); }
audio { width: 100%; margin: 10px 0; }
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 8px; }
.bar { height: 4px; background: var(--panel2); border-radius: 2px; overflow: hidden; margin-top: 6px; }
.bar i { display: block; height: 100%; background: var(--accent); width: 0; transition: width .2s; }
.state { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--dim); }
.state.done { color: var(--good); }
.state.error { color: var(--bad); }
form.settings { display: grid; gap: 8px; margin-top: 10px; }
form.settings label { display: grid; gap: 3px; font-size: 12px; color: var(--dim); }
input[type=text], input[type=number] {
background: var(--bg); border: 1px solid var(--line); color: var(--fg);
border-radius: 6px; padding: 6px 8px; font-size: 14px; width: 100%;
}
.checks { display: flex; gap: 16px; font-size: 13px; color: var(--fg); }
.checks label { flex-direction: row; align-items: center; gap: 6px; color: var(--fg); }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 12px; margin-bottom: 12px; }
h2 { font-size: 17px; margin: 0 0 2px; }
.empty { color: var(--dim); padding: 24px 0; }
@media (max-width: 700px) {
main { grid-template-columns: 1fr; }
#feeds { border-right: 0; border-bottom: 1px solid var(--line); }
}
</style>
</head>
<body>
<header>
<h1>ipx</h1>
<button id="fetchAll">Scan all</button>
<button id="showAdd">+ Feed</button>
<span id="status"></span>
</header>
<main>
<nav id="feeds"></nav>
<section id="content"><p class="empty">Pick a feed.</p></section>
</main>
<script>
const $ = (s, r = document) => r.querySelector(s);
const api = async (url, opts) => {
const r = await fetch(url, { headers: { 'Content-Type': 'application/json' }, ...opts });
if (!r.ok) throw new Error((await r.text()) || r.status);
return r.status === 204 ? null : r.json().catch(() => null);
};
const esc = s => (s ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const when = t => t ? new Date(t * 1000).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) : '';
const mb = n => n ? (n / 1048576).toFixed(1) + ' MB' : '';
const say = (m) => { $('#status').textContent = m; };
let feeds = [], current = null, entries = [], open = new Set();
async function loadFeeds() {
feeds = await api('/api/feeds');
const nav = $('#feeds');
nav.innerHTML = '';
if (!feeds.length) { nav.innerHTML = '<p class="empty" style="padding:10px">No feeds yet.</p>'; return; }
for (const f of feeds) {
const el = document.createElement('div');
el.className = 'feed' + (current === f.id ? ' sel' : '');
el.innerHTML = `<div class="name"><b>${esc(f.title || f.id)}</b>` +
(f.unread ? `<span class="pill">${f.unread}</span>` : '') + `</div>` +
`<small>${f.entries} entries &middot; ${f.downloaded} downloaded</small>` +
(f.last_error ? `<div class="err">${esc(f.last_error)}</div>` : '');
el.onclick = () => selectFeed(f.id);
nav.appendChild(el);
}
}
async function selectFeed(id) {
current = id; open.clear();
await loadFeeds();
await loadEntries();
}
async function loadEntries() {
const f = feeds.find(x => x.id === current);
if (!f) return;
entries = await api(`/api/feeds/${encodeURIComponent(current)}/entries?limit=100`);
render(f);
}
function render(f) {
const c = $('#content');
c.innerHTML = `
<div class="panel">
<h2>${esc(f.title || f.id)}</h2>
<div class="meta">${esc(f.url)}</div>
<div class="row">
<button onclick="scan('${f.id}')">Scan now</button>
<button onclick="toggleSettings()">Settings</button>
<button class="danger" onclick="removeFeed('${f.id}')">Unsubscribe</button>
</div>
<div id="settings" hidden></div>
</div>
<div id="list"></div>`;
const list = $('#list');
if (!entries.length) { list.innerHTML = '<p class="empty">No entries yet — try Scan now.</p>'; return; }
for (const e of entries) list.appendChild(entryEl(f, e));
}
function entryEl(f, e) {
const div = document.createElement('div');
div.className = 'entry' + (e.read ? '' : ' unread');
div.dataset.guid = e.guid;
const isOpen = open.has(e.guid);
div.innerHTML = `
<div class="head">
<h3>${esc(e.title || '(untitled)')}</h3>
<span class="meta">${when(e.published)}</span>
<span title="Keep this episode" style="cursor:pointer">${e.flagged ? '★' : '☆'}</span>
</div>
<div class="body" ${isOpen ? '' : 'hidden'}></div>`;
const head = $('.head', div), body = $('.body', div);
head.querySelector('span[title]').onclick = ev => { ev.stopPropagation(); flag(f, e, !e.flagged); };
head.onclick = () => {
const nowOpen = body.hidden;
body.hidden = !nowOpen;
if (nowOpen) { open.add(e.guid); fillBody(body, f, e); } else { open.delete(e.guid); }
};
if (isOpen) fillBody(body, f, e);
return div;
}
function fillBody(body, f, e) {
// description is sanitized server-side with ammonia before it ever gets here
const encs = e.enclosures.map(x => encEl(f, e, x)).join('');
body.innerHTML = `<div class="desc">${e.description || '<em>No show notes.</em>'}</div>
${encs}
<div class="row">
<button onclick="markRead('${f.id}', ${JSON.stringify(e.guid).replace(/"/g, '&quot;')}, ${!e.read})">
Mark ${e.read ? 'unread' : 'read'}</button>
${e.link ? `<a class="meta" href="${esc(e.link)}" target="_blank" rel="noreferrer noopener">Open original</a>` : ''}
</div>`;
for (const x of e.enclosures) {
const audio = $(`#audio-${x.id}`, body);
// Playing something is the clearest signal it has been listened to, and retention
// deletes read episodes before unread ones.
if (audio) audio.onplay = () => { if (!e.read) markRead(f.id, e.guid, true, true); };
}
}
function encEl(f, e, x) {
const g = JSON.stringify(e.guid).replace(/"/g, '&quot;');
if (x.path) {
return `<div>
<audio id="audio-${x.id}" controls preload="none" src="/media/${x.id}"></audio>
<div class="row">
<span class="state done">downloaded</span><span class="meta">${mb(x.length)}</span>
<a class="meta" href="/media/${x.id}" download>Save file</a>
<button class="danger" onclick="delFile(${x.id})">Delete file</button>
</div></div>`;
}
return `<div>
<div class="row">
<span class="state ${esc(x.state)}">${esc(x.state)}</span>
<span class="meta">${mb(x.length)}</span>
<button onclick="dl(${x.id})">Download</button>
${x.last_error ? `<span class="err">${esc(x.last_error)}</span>` : ''}
</div>
<div class="bar" id="bar-${x.id}"><i></i></div>
</div>`;
}
async function markRead(feedId, guid, read, quiet) {
await api(`/api/entries/${encodeURIComponent(feedId)}/${encodeURIComponent(guid)}/flags`,
{ method: 'POST', body: JSON.stringify({ read }) });
const e = entries.find(x => x.guid === guid); if (e) e.read = read;
if (!quiet) { const f = feeds.find(x => x.id === current); render(f); }
loadFeeds();
}
async function flag(f, e, on) {
await api(`/api/entries/${encodeURIComponent(f.id)}/${encodeURIComponent(e.guid)}/flags`,
{ method: 'POST', body: JSON.stringify({ flagged: on }) });
e.flagged = on; render(f);
}
async function dl(id) { say('Queued…'); await api(`/api/enclosures/${id}/download`, { method: 'POST' }); }
async function delFile(id) {
if (!confirm('Delete this file? The episode stays listed and will not be re-downloaded.')) return;
await api(`/api/enclosures/${id}`, { method: 'DELETE' });
loadEntries(); loadFeeds();
}
async function scan(id) { say('Scanning…'); await api('/api/fetch', { method: 'POST', body: JSON.stringify({ feed: id, force: true }) }); }
async function removeFeed(id) {
if (!confirm(`Unsubscribe from ${id}? Downloads and history are kept.`)) return;
await api(`/api/feeds/${encodeURIComponent(id)}`, { method: 'DELETE' });
current = null; $('#content').innerHTML = '<p class="empty">Pick a feed.</p>'; loadFeeds();
}
function toggleSettings() {
const box = $('#settings'), f = feeds.find(x => x.id === current);
box.hidden = !box.hidden;
if (box.hidden) return;
box.innerHTML = `<form class="settings" onsubmit="return saveSettings(event)">
<label>Folder<input type="text" name="folder" value="${esc(f.folder || '')}" placeholder="${esc(f.title || f.id)}"></label>
<label>Keywords (comma separated; leave empty to take everything)
<input type="text" name="keywords" value="${esc(f.keywords.join(', '))}"></label>
<label>Max new downloads per scan (blank = no limit)
<input type="number" name="max" min="0" value="${f.max_new_per_check ?? ''}"></label>
<div class="checks">
<label><input type="checkbox" name="explicit" ${f.allow_explicit ? 'checked' : ''}> Allow explicit</label>
<label><input type="checkbox" name="auto" ${f.auto_download ? 'checked' : ''}> Auto download</label>
</div>
<div class="row"><button class="primary" type="submit">Save</button></div>
</form>`;
}
async function saveSettings(ev) {
ev.preventDefault();
const d = new FormData(ev.target);
const max = d.get('max');
await api(`/api/feeds/${encodeURIComponent(current)}`, {
method: 'PATCH',
body: JSON.stringify({
folder: d.get('folder').trim() || null,
keywords: d.get('keywords').split(',').map(s => s.trim()).filter(Boolean),
max_new_per_check: max === '' ? null : Number(max),
allow_explicit: d.get('explicit') === 'on',
auto_download: d.get('auto') === 'on',
}),
});
say('Saved — applies to the next scan, no restart needed.');
await loadFeeds();
const f = feeds.find(x => x.id === current);
render(f);
return false;
}
$('#fetchAll').onclick = () => { say('Scanning all feeds…'); api('/api/fetch', { method: 'POST', body: JSON.stringify({ force: true }) }); };
$('#showAdd').onclick = async () => {
const url = prompt('Feed URL');
if (!url) return;
say('Adding…');
try { const r = await api('/api/feeds', { method: 'POST', body: JSON.stringify({ url }) });
say(r.existing ? `Already subscribed as ${r.id}` : `Added ${r.id}`);
await loadFeeds(); selectFeed(r.id);
} catch (e) { say('Failed: ' + e.message); }
};
// Live events from the same broadcast bus the socket clients read.
const sse = new EventSource('/api/events');
sse.onmessage = m => {
const ev = JSON.parse(m.data);
if (ev.ev === 'progress') {
say(`${ev.file}: ${((ev.done / (ev.total || ev.done)) * 100).toFixed(0)}%`);
const bars = document.querySelectorAll('.bar i');
for (const b of bars) if (b.parentElement.id) b.style.width = ((ev.done / (ev.total || 1)) * 100) + '%';
} else if (ev.ev === 'download_done') {
say('Downloaded ' + ev.path.split('/').pop());
loadEntries(); loadFeeds();
} else if (ev.ev === 'feed_done') {
say(`${ev.feed}: ${ev.new} new, ${ev.downloaded} downloaded`);
loadEntries(); loadFeeds();
} else if (ev.ev === 'scan_done') {
say('Scan complete.'); loadFeeds();
} else if (ev.ev === 'feed_error' || ev.ev === 'download_error') {
say('Error: ' + ev.msg);
}
};
loadFeeds();
</script>
</body>
</html>