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