Three-pane layout: feeds, items, item text

Feeds beside, the feed's items above, and the selected item's text with
its enclosures below -- the shape iPodderX used. Selecting a row fills the
pane below instead of expanding inline; enclosures render there as a
player when the file is present and a labelled download when it is not.
The divider drags and its position is remembered.

The archived site kept no usable screenshot of the original window, only
marketing panels, so this follows the description rather than reference
art.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
This commit is contained in:
2026-09-10 17:45:18 +00:00
parent a83f62ccd3
commit ddb9dbb7e1
4 changed files with 195 additions and 40 deletions

View File

@@ -56,6 +56,34 @@ and until now nothing set them.
---
## 2026-09-10 — Three-pane layout
Ray asked for feeds beside, items above, and the selected item's text with its enclosures below —
the layout iPodderX used. Went looking for a screenshot to work from: the archived ipodderx.com kept
only marketing panels and 130px feature icons, no full window, and the review screenshots lived on
MacMerc which the archive query did not surface. Said so rather than pretending to have reference
art; the description is a precise spec on its own.
`#split` is a grid of list / divider / detail. Selecting a row no longer expands it inline — it
highlights and fills the pane below with the title, metadata, read/keep buttons, the sanitized notes
and one block per enclosure (a player when the file is here, otherwise what it is and a Download
button). The divider drags, and the split is remembered in localStorage. The OPML group page keeps
the old scrolling layout via a `plain` class.
**Process note, because it cost a dozen calls.** Four anchor-patches in a row failed on text I had
guessed rather than read: `return el` not `return div`, `open:new Set()` mid-line rather than
starting one. Each assert aborted before writing, so the file kept reverting to a half-applied
state, and one earlier patch did land while broken and left a syntax error. What worked was
reverting to the committed copy and then reading each block verbatim before touching it, with regex
for the whitespace-sensitive parts.
The suite paid for itself again: the new pane test failed on `#detail audio` because
`max_new_per_check = 1` plus newest-first means the daemon fetches *Second* Episode, so First has no
player. My assumption, not a bug — the same mistake as the earlier `S1E1` one. The test now finds
the downloaded row by its chip instead of assuming which episode it is. 9 browser tests pass.
---
## 2026-09-10 — Image enclosures were being treated as episodes
Reported as "Abort Retry Fail shows downloads but there are none". It had 20 enclosures, all

View File

@@ -164,6 +164,13 @@ server-side before they reach the page.
clear — and a feed URL can itself contain a credential (Patreon's, for one, carries an auth token).
Put it behind a reverse proxy with TLS if that matters to you.
## Layout
Feeds down the side; the selected feed's episodes across the top; the selected episode's text and
its enclosures in the pane below, which is where you play, download or delete them. The divider
between the two panes drags and the position is remembered. An OPML subscription's page instead
lists the feeds inside it.
## Log view
The **Log** button in the sidebar shows the running daemon's output live, in four tabs:

View File

@@ -43,7 +43,7 @@ test('settings opens and saves the global schedule', async ({ page }) => {
await expect(page.locator('#gunit')).toHaveValue('h');
});
test('episodes show with their metadata, and notes expand', async ({ page }) => {
test('episodes show with their metadata, and the text opens below', async ({ page }) => {
await page.getByText('Test Show').click();
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
await expect(page.getByText('First Episode')).toBeVisible();
@@ -53,8 +53,35 @@ test('episodes show with their metadata, and notes expand', async ({ page }) =>
await expect(first).toContainText('30:30'); // itunes:duration 1830
await expect(page.locator('.ep', { hasText: 'Second Episode' })).toContainText('15:00');
await first.locator('.t').click();
await expect(page.locator('.notes').first()).toContainText('Show notes for the first one');
// Selecting an item shows its text in the pane below, not inline in the row.
await first.click();
await expect(first).toHaveClass(/sel/);
await expect(page.locator('#detail')).toContainText('Show notes for the first one');
await expect(page.locator('#detail .dt')).toHaveText('First Episode');
});
test('the three panes are there and the item text lands in the bottom one', async ({ page }) => {
await page.getByText('Test Show').click();
await expect(page.locator('#list')).toBeVisible();
await expect(page.locator('#grab')).toBeVisible(); // the draggable divider
await expect(page.locator('#detail')).toContainText('Pick an episode');
await page.locator('.ep', { hasText: 'First Episode' }).click();
await expect(page.locator('#detail .dt')).toHaveText('First Episode');
// The enclosure travels with the item, into the same pane.
await expect(page.locator('#detail .encbox')).toHaveCount(1);
// Only the downloaded one gets a player, and max_new_per_check is 1, so find it by
// its chip rather than assuming which episode the daemon happened to fetch.
const downloaded = page.locator('.ep', { hasText: 'downloaded' }).first();
await downloaded.click();
await expect(page.locator('#detail audio')).toBeVisible();
await expect(page.locator('#detail .encbox .btn', { hasText: 'Save' })).toBeVisible();
// Selecting another item replaces the pane rather than stacking.
await page.locator('.ep', { hasText: 'First Episode' }).click();
await expect(page.locator('#detail .dt')).toHaveText('First Episode');
await expect(page.locator('#detail audio')).toHaveCount(0);
});
test('the filter tabs change what is listed', async ({ page }) => {

View File

@@ -128,8 +128,31 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
.badge.zero{background:var(--raise);color:var(--faint)}
/* ---------- main ---------- */
#main{overflow-y:auto;min-height:0;scroll-behavior:smooth}
.wrap{max-width:1000px;margin:0 auto;padding:18px 22px 40px}
#main{overflow:hidden;min-height:0;display:flex;flex-direction:column}
.wrap{flex:1;min-height:0;display:flex;flex-direction:column}
.wrap.plain{overflow-y:auto;padding:18px 22px 40px;display:block}
#content{display:flex;flex-direction:column;min-height:0;flex:1}
#content>.fhead,#content>.toolbar{padding-left:20px;padding-right:20px;flex:none}
#content>.fhead{padding-top:16px}
/* Three panes, as the original had: feeds beside, items above, the item below. */
#split{display:grid;grid-template-rows:minmax(90px,var(--listh,40%)) 7px 1fr;flex:1;min-height:0}
#list{overflow-y:auto;padding:0 14px 10px}
#grab{cursor:row-resize;background:var(--line)}
#grab:hover{background:var(--accent)}
#detail{overflow-y:auto;padding:16px 20px 28px;background:var(--panel);border-top:1px solid var(--line)}
.dt{font-size:18px;font-weight:650;margin:0 0 5px;line-height:1.3}
.dmeta{color:var(--faint);font-size:12.5px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:14px}
.dmeta .btn{padding:3px 9px;font-size:12px}
.dbody{font-size:14.5px;line-height:1.65;overflow-wrap:anywhere;color:var(--fg)}
.dbody img{max-width:100%;height:auto;border-radius:6px}
.dbody a{color:var(--accent)}
.dbody pre{overflow-x:auto;background:var(--panel2);padding:10px;border-radius:8px}
.encbox{
display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:14px;
padding:10px 12px;border:1px solid var(--line);border-radius:9px;background:var(--panel2);
}
.encbox audio{flex:1;min-width:200px;margin:0}
.fhead{display:flex;gap:18px;margin-bottom:18px}
.fhead .art{width:118px;height:118px;font-size:34px;box-shadow:var(--shadow)}
.fhead .meta{min-width:0;flex:1;display:flex;flex-direction:column}
@@ -157,9 +180,11 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
/* ---------- episodes ---------- */
.ep{
display:flex;gap:12px;padding:11px;border-radius:var(--r);
border:1px solid transparent;margin-bottom:3px;position:relative;
display:flex;gap:11px;padding:8px 10px;border-radius:var(--r);align-items:center;
border:1px solid transparent;margin-bottom:2px;position:relative;cursor:pointer;
}
.ep.sel{background:var(--raise);border-color:var(--line)}
.ep .art{width:34px;height:34px;font-size:12px}
.ep:hover{background:var(--panel)}
.ep.playing{background:var(--panel);border-color:var(--accent)}
.ep .art{width:52px;height:52px;font-size:16px;cursor:pointer;position:relative}
@@ -183,12 +208,6 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
.chip.new{background:var(--accent);color:var(--ink)}
.ep .rowacts{display:flex;gap:2px;align-items:flex-start;opacity:0;transition:opacity .12s}
.ep:hover .rowacts,.ep.open .rowacts{opacity:1}
.notes{
margin-top:10px;padding:11px 13px;background:var(--panel2);border-radius:9px;
color:var(--dim);font-size:13.5px;overflow-wrap:anywhere;
}
.notes img{max-width:100%;height:auto;border-radius:6px}
.notes p:first-child{margin-top:0}.notes p:last-child{margin-bottom:0}
.dlbar{height:3px;background:transparent;border-radius:2px;overflow:hidden;margin-top:7px}
.dlbar.live{background:var(--raise)}
.dlbar i{display:block;height:100%;width:0;background:var(--accent);transition:width .25s}
@@ -409,7 +428,7 @@ function artHTML(url,name,cls){
/* ---------------- state ---------------- */
const S = {
feeds:[], feed:null, entries:[], total:0, offset:0, limit:50,
filter:'unread', q:'', open:new Set(), busy:new Set(),
filter:'unread', q:'', sel:null, busy:new Set(),
};
const LIMIT = 50;
@@ -457,7 +476,7 @@ function renderFeeds(){
}
}
function selectFeed(id){
S.feed=id; S.offset=0; S.open.clear(); S.q='';
S.feed=id; S.offset=0; S.sel=null; S.q='';
renderFeeds(); renderFeed(); loadEntries();
}
@@ -465,8 +484,9 @@ function selectFeed(id){
function renderFeed(){
const f=S.feeds.find(x=>x.id===S.feed);
if(!f){ $('#content').innerHTML='<p class="empty">Add a feed to get started.</p>'; return; }
$('#main .wrap').classList.remove('plain');
const kids=S.feeds.filter(c=>c.group===f.id);
if(kids.length){ renderGroup(f,kids); return; }
if(kids.length){ $('#main .wrap').classList.add('plain'); renderGroup(f,kids); return; }
$('#content').innerHTML = `
<div class="fhead">
${artHTML(f.image,f.title||f.id)}
@@ -495,7 +515,15 @@ function renderFeed(){
<input type="search" class="grow" id="epSearch" placeholder="Search episodes…" value="${esc(S.q)}">
<span style="color:var(--faint);font-size:12.5px" id="count"></span>
</div>
<div id="eps"></div>`;
`;
const pane=document.createElement('div');
pane.id='split';
pane.innerHTML='<div id="list"><div id="eps"></div></div><div id="grab"></div><div id="detail"></div>';
$('#content').appendChild(pane);
pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '40%');
dragSplit();
showDetail(null);
$$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a,f));
$$('#content .tabs button').forEach(b=>b.onclick=()=>{S.filter=b.dataset.f;S.offset=0;renderFeed();loadEntries()});
@@ -592,7 +620,7 @@ function epEl(e){
const enc=e.enclosures[0];
const has=!!(enc&&enc.path);
const el=document.createElement('div');
el.className='ep'+(e.read?' read':'')+(S.open.has(e.guid)?' open':'')+
el.className='ep'+(e.read?' read':'')+(S.sel===e.guid?' sel':'')+
(player.guid===e.guid?' playing':'');
el.dataset.guid=e.guid;
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
@@ -621,15 +649,15 @@ function epEl(e){
<button class="iconbtn" data-a="flag" title="${e.flagged?'Stop keeping':'Keep (never auto-delete)'}">${e.flagged?'★':'☆'}</button>
<button class="iconbtn" data-a="read" title="Mark ${e.read?'unread':'read'}">${e.read?'○':'●'}</button>
${has?`<button class="iconbtn" data-a="del" title="Delete file">🗑</button>`:''}
<button class="iconbtn" data-a="notes" title="Show notes">≡</button>
</div>`;
const art=$('.art',el);
if(art&&has) art.insertAdjacentHTML('beforeend','<span class="ovl">▶</span>');
if(art&&has) art.onclick=()=>play(e);
$('.t',el).onclick=()=>toggleNotes(e,el);
if(art&&has){
art.insertAdjacentHTML('beforeend','<span class="ovl">▶</span>');
art.onclick=ev=>{ ev.stopPropagation(); play(e); };
}
el.onclick=()=>selectEntry(e);
$$('.rowacts .iconbtn',el).forEach(b=>b.onclick=ev=>{ev.stopPropagation();epAction(b.dataset.a,e,el)});
if(S.open.has(e.guid)) showNotes(e,el);
return el;
}
/// What an enclosure is, for a row that is not an episode: "image", "pdf", "document".
@@ -646,28 +674,93 @@ function kindOf(enc){
function feedArt(){ const f=S.feeds.find(x=>x.id===S.feed); return f&&f.image; }
function toggleNotes(e,el){
if(S.open.has(e.guid)){ S.open.delete(e.guid); const n=$('.notes',el); if(n)n.remove(); el.classList.remove('open'); }
else { S.open.add(e.guid); showNotes(e,el); el.classList.add('open'); }
}
function showNotes(e,el){
if($('.notes',el)) return;
const d=document.createElement('div');
d.className='notes';
// Sanitized server-side with ammonia before it ever reaches the browser.
d.innerHTML=(e.description&&e.description.trim())||'<em>No show notes.</em>';
if(e.link) d.insertAdjacentHTML('beforeend',`<p><a href="${esc(e.link)}" target="_blank" rel="noopener noreferrer">Open original ↗</a></p>`);
$('.body',el).appendChild(d);
/// Selecting an item shows it in the pane below, rather than expanding the row.
function selectEntry(e){
S.sel=e.guid;
$$('#eps .ep').forEach(x=>x.classList.toggle('sel', x.dataset.guid===e.guid));
showDetail(e);
const d=$('#detail'); if(d) d.scrollTop=0;
}
async function epAction(a,e,el){
const enc=e.enclosures[0];
/// Drag the divider between the item list and the item text.
function dragSplit(){
const grab=$('#grab'), pane=$('#split');
if(!grab||!pane) return;
const move=ev=>{
const box=pane.getBoundingClientRect();
const pct=Math.min(80,Math.max(12,((ev.clientY-box.top)/box.height)*100));
pane.style.setProperty('--listh',pct.toFixed(1)+'%');
};
const stop=()=>{
document.removeEventListener('mousemove',move);
document.removeEventListener('mouseup',stop);
document.body.style.userSelect='';
try{ localStorage.setItem('ipx.listh', pane.style.getPropertyValue('--listh')); }catch{}
};
grab.onmousedown=ev=>{
ev.preventDefault();
document.body.style.userSelect='none';
document.addEventListener('mousemove',move);
document.addEventListener('mouseup',stop);
};
}
/// The item's text and its enclosures, in the pane below the list.
function showDetail(e){
const box=$('#detail'); if(!box) return;
if(!e){ box.innerHTML='<p class="empty">Pick an episode to read it.</p>'; return; }
const f=S.feeds.find(x=>x.id===e.feed_id);
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
box.innerHTML=`
<h3 class="dt">${esc(e.title||'(untitled)')}</h3>
<div class="dmeta">
${f?`<span>${esc(f.title||f.id)}</span><span class="dot"></span>`:''}
${num?`<span>${num}</span><span class="dot"></span>`:''}
<span>${dateOf(e.published)}</span>
${e.duration?`<span class="dot"></span><span>${clock(e.duration)}</span>`:''}
<button class="btn" data-a="read">Mark ${e.read?'unread':'read'}</button>
<button class="btn" data-a="flag">${e.flagged?'\u2605 Kept':'\u2606 Keep'}</button>
${e.link?`<a class="btn" href="${esc(e.link)}" target="_blank" rel="noopener noreferrer">Open original \u2197</a>`:''}
</div>
<div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>
${e.enclosures.map(encBox).join('')}`;
// description was sanitized server-side with ammonia before it ever reached here
$$('button[data-a]',box).forEach(b=>
b.onclick=()=>epAction(b.dataset.a,e,null,b.dataset.enc?Number(b.dataset.enc):null));
for(const x of e.enclosures){
const a=$(`#audio-${x.id}`,box);
if(a) a.onplay=()=>play(e);
}
}
/// One enclosure: a player when the file is here, otherwise what it is and a way to get it.
function encBox(x){
const size=x.length?mb(x.length):'';
if(x.path){
return `<div class="encbox">
<audio id="audio-${x.id}" controls preload="none" src="/media/${x.id}"></audio>
<span class="meta">${size}</span>
<a class="btn" href="/media/${x.id}" download>Save</a>
<button class="btn danger" data-a="del" data-enc="${x.id}">Delete file</button>
</div>`;
}
return `<div class="encbox">
<span class="chip ${esc(x.state)}">${x.state==='skipped'?kindOf(x):esc(x.state)}</span>
<span class="meta" style="flex:1">${esc(kindOf(x))}${size?' \u00b7 '+size:''}</span>
${x.last_error?`<span class="err">${esc(x.last_error)}</span>`:''}
<button class="btn" data-a="get" data-enc="${x.id}">Download</button>
</div>`;
}
async function epAction(a,e,el,encId){
// From a row we have the element to swap; from the detail pane we do not, so redraw.
const redraw=()=>{ if(el) redraw(); else renderEntries(); showDetail(e); };
const enc=(encId!=null && e.enclosures.find(x=>x.id===encId)) || e.enclosures[0];
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
try{
if(a==='play') play(e);
if(a==='notes') toggleNotes(e,el);
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); el.replaceWith(epEl(e)); }
if(a==='read'){ e.read=!e.read; await api(path+'/flags',{method:'POST',body:JSON.stringify({read:e.read})}); el.replaceWith(epEl(e)); loadFeeds(true); }
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); }
if(a==='read'){ e.read=!e.read; await api(path+'/flags',{method:'POST',body:JSON.stringify({read:e.read})}); redraw(); loadFeeds(true); }
if(a==='get'){
if(!enc) return;
S.busy.add(enc.id);