From ddb9dbb7e13fc88b513f00e75c424c8de401bffe Mon Sep 17 00:00:00 2001 From: rays Date: Thu, 10 Sep 2026 17:45:18 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh --- PROGRESS.md | 28 ++++++++ README.md | 7 ++ tests/ui/app.spec.js | 33 ++++++++- web/index.html | 167 +++++++++++++++++++++++++++++++++---------- 4 files changed, 195 insertions(+), 40 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 1bfbb89..2c4e72d 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 diff --git a/README.md b/README.md index 009c980..9809172 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index 52c1c7d..c5c3640 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -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 }) => { diff --git a/web/index.html b/web/index.html index ccbe9c6..3b1247c 100644 --- a/web/index.html +++ b/web/index.html @@ -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='

Add a feed to get started.

'; 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 = `
${artHTML(f.image,f.title||f.id)} @@ -495,7 +515,15 @@ function renderFeed(){
-
`; + `; + + const pane=document.createElement('div'); + pane.id='split'; + pane.innerHTML='
'; + $('#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){ ${has?``:''} - `; const art=$('.art',el); - if(art&&has) art.insertAdjacentHTML('beforeend',''); - if(art&&has) art.onclick=()=>play(e); - $('.t',el).onclick=()=>toggleNotes(e,el); + if(art&&has){ + art.insertAdjacentHTML('beforeend',''); + 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())||'No show notes.'; - if(e.link) d.insertAdjacentHTML('beforeend',`

Open original ↗

`); - $('.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='

Pick an episode to read it.

'; 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=` +

${esc(e.title||'(untitled)')}

+
+ ${f?`${esc(f.title||f.id)}`:''} + ${num?`${num}`:''} + ${dateOf(e.published)} + ${e.duration?`${clock(e.duration)}`:''} + + + ${e.link?`Open original \u2197`:''} +
+
${(e.description&&e.description.trim())||'No show notes.'}
+ ${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 `
+ + ${size} + Save + +
`; + } + return `
+ ${x.state==='skipped'?kindOf(x):esc(x.state)} + ${esc(kindOf(x))}${size?' \u00b7 '+size:''} + ${x.last_error?`${esc(x.last_error)}`:''} + +
`; +} + +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);