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
This commit is contained in:
316
web/index.html
Normal file
316
web/index.html
Normal file
@@ -0,0 +1,316 @@
|
||||
<!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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 · ${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, '"')}, ${!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, '"');
|
||||
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>
|
||||
Reference in New Issue
Block a user