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);