From 0efc49519cc6db511310b38cde8ce8211552d593 Mon Sep 17 00:00:00 2001 From: rays Date: Fri, 11 Sep 2026 17:05:47 +0000 Subject: [PATCH] One icon per meaning across the UI; mark everything read in All Subscriptions - All Subscriptions' header checks every feed and marks everything read (POST /api/read-all, the same feeds the view lists); it asks first. - Minus unsubscribes everywhere (the feed header's x read as "close"), x only closes or cancels, plus adds/subscribes/imports, and a dialog's confirm carries its action's icon. Remaining word buttons, the player and the folder arrow are Font Awesome 7.3.1 icons. - Toolbar grouped by what it acts on (add, unsubscribe, scan | play, read, keep); read and keep show the selected item's state. - The OPML subscription page uses the same header as a feed. - Fixed: Escape ignored inside a dialog's text box (Add feed could not be closed with it), white password box in the dark theme, stray dot in an undated item's details. - Tests: one action one icon across toolbar, page and all 8 dialogs; All Subscriptions mark everything read. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0173mGu6rK18Ne7UGTwAaVJV --- CHANGELOG.md | 18 +++++ docs/architecture.md | 1 + src/web.rs | 13 ++++ tests/ui/app.spec.js | 63 +++++++++++++++++- web/index.html | 154 ++++++++++++++++++++++++++----------------- 5 files changed, 188 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ea5264..7edaea4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ The long form, with what was wrong before and how it was found, is in Subscriptions lists every item from every feed you subscribe to (`GET /api/entries`). - Items show as a table (unread, kept, item, feed, file, published) with a Files pane beside it, and a status bar with the totals. +- Mark everything read from All Subscriptions, across every feed you subscribe to + (`POST /api/read-all`). It asks first. All Subscriptions can also check every feed from its header. ### Changed @@ -38,10 +40,26 @@ The long form, with what was wrong before and how it was found, is in SVG: only the ones used, no font to download, and nothing fetched from anyone else. They replace font characters such as ⟳ ⤓ ↗, which came out thin and tiny and differed from font to font. Keep is a flag everywhere, as it was in the original, and mark unread is an envelope. +- One meaning per icon. Minus unsubscribes, x closes or cancels, plus adds or subscribes, and a + dialog's confirm button carries the icon of what it does. The feed header's unsubscribe was an x + and read as closing the page. The remaining word buttons are icons too: + - Log, Add feed, Users, Unsubscribe and OPML. + - Popular's Subscribe, Copy and Sign out. + - The player's back, play, forward and close, which were font characters, and the folder arrow. +- The toolbar's read and keep buttons show the selected item's state, with the same icons as the + item's own buttons. Play, read and keep sit together, and Scan sits with add and unsubscribe. +- An OPML subscription's page has the same header as a feed's, with its buttons in the same places. - A file's type is an icon (audio, video, image, PDF, torrent, other), green once it is downloaded and red when the download failed, with the details in its tooltip. One icon per row keeps the column lined up. The DOWNLOADED and PENDING labels are gone. +### Fixed + +- The password box in Manage users was white in the dark theme. +- An item with no date showed a stray dot in its details. +- Escape did not close a dialog while the cursor was in one of its boxes, so Add feed, which opens + in its URL box, could not be closed with Escape. + ### Security - Feeds from paid-feed services (Patreon, Supercast, Supporting Cast, Glow, Memberful) are never diff --git a/docs/architecture.md b/docs/architecture.md index 1818025..dcf813a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -107,6 +107,7 @@ else a `401`. | `GET /api/feeds/{id}/entries` | paged, filtered, searchable | | `GET /api/entries` | the same, across every feed you subscribe to (All Subscriptions) | | `POST /api/feeds/{id}/read-all`, `POST /api/feeds/{id}/download-latest` | | +| `POST /api/read-all` | everything read in every feed you subscribe to (All Subscriptions) | | `POST /api/entries/{feed}/{guid}/flags`, `…/position` | your read, starred, position | | `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning | | `POST /api/fetch` | | diff --git a/src/web.rs b/src/web.rs index 4a57f5a..59636cf 100644 --- a/src/web.rs +++ b/src/web.rs @@ -69,6 +69,7 @@ pub fn router(state: WebState) -> Router { .route("/api/entries/{feed_id}/{guid}/flags", post(set_flags)) .route("/api/entries/{feed_id}/{guid}/position", post(set_position)) .route("/api/feeds/{id}/read-all", post(read_all)) + .route("/api/read-all", post(read_all_mine)) .route("/api/feeds/{id}/download-latest", post(download_latest)) .route("/api/enclosures/{id}/download", post(download_now)) .route("/api/enclosures/{id}", delete(delete_file)) @@ -1260,6 +1261,18 @@ async fn read_all( Ok(Json(serde_json::json!({ "marked": n }))) } +/// Everything read in every feed you subscribe to: exactly what All Subscriptions lists, since +/// that view is scoped by the same subscriptions. +async fn read_all_mine( + State(state): State, + user: crate::db::User, +) -> Result, ApiError> { + let ids: Vec = + state.ctx.db.subscriptions_for(user.id)?.into_iter().map(|s| s.feed_id).collect(); + let n = state.ctx.db.mark_all_read(user.id, &ids)?; + Ok(Json(serde_json::json!({ "marked": n }))) +} + #[derive(Deserialize)] struct HowMany { #[serde(default = "five")] diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index bfd5ef7..1714045 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -455,7 +455,7 @@ test('Settings exports your OPML and imports a pasted one', async ({ page }) => await page.locator('#prefs').click(); const [dl] = await Promise.all([ page.waitForEvent('download'), - page.locator('#modalCard a', { hasText: 'Export OPML' }).click(), + page.locator('#modalCard a[title="Export OPML"]').click(), ]); expect(dl.suggestedFilename()).toBe('ipx-subscriptions.opml'); const out = require('fs').readFileSync(await dl.path(), 'utf8'); @@ -636,7 +636,7 @@ test('Popular lists what everyone here reads, but never a private feed', async ( (await (await piper.request.get('/api/popular')).json()).find(p => p.id === 'test-show'); const before = await row(); expect(before.subscribed).toBe(false); - await offered.filter({ hasText: 'Test Show' }).locator('button', { hasText: 'Subscribe' }).click(); + await offered.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]').click(); await expect(piper.locator('#feedlist .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 }); // Everyone counts, you included: it stays listed, marked as yours, with one more subscriber. @@ -679,3 +679,62 @@ test('a deleted file looks as if it was never downloaded', async ({ page }) => { await expect(page.locator('#files')).not.toContainText(/reaped/i); await expect(page.locator('#files button[title="Download to the server"]')).toBeVisible(); }); + +test('one action, one icon: the toolbar, the page and every dialog agree', async ({ page }) => { + const icon = loc => loc.locator('svg path').first().getAttribute('d'); + await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click(); + + // Unsubscribe is a minus in the toolbar and the feed header, never the x that closes things. + expect(await icon(page.locator('#content .acts [data-a="rm"]'))).toBe(await icon(page.locator('#tbRemove'))); + + // The toolbar's read and keep show the selected item's state, as its own buttons do, and follow + // a change made from the toolbar. + await page.locator('.ep').first().click(); + const pair = async a => [await icon(page.locator(a === 'read' ? '#tbRead' : '#tbFlag')), + await icon(page.locator(`#detail [data-a="${a}"]`))]; + for (const a of ['read', 'flag']) { const [tb, own] = await pair(a); expect(tb).toBe(own); } + const [kept] = await pair('flag'); + await page.locator('#tbFlag').click(); + await expect.poll(async () => { const [tb, own] = await pair('flag'); return tb === own && tb !== kept; }).toBe(true); + await page.locator('#tbFlag').click(); // leave it as it was + await expect.poll(async () => (await pair('flag'))[0]).toBe(kept); + + // Every button in every dialog is an icon with its words in the tooltip. + const dialogs = [ + () => page.locator('#addFeed').click(), + () => page.locator('#prefs').click(), + async () => { await page.locator('#prefs').click(); await page.locator('#gusers').click(); }, + async () => { await page.locator('#prefs').click(); await page.locator('#gopml').click(); }, + () => page.locator('#logs').click(), + () => page.locator('#content .acts [data-a="settings"]').click(), + () => page.locator('#content .acts [data-a="dl"]').click(), + () => page.locator('#content .acts [data-a="rm"]').click(), + ]; + for (const open of dialogs) { + await open(); + const btns = page.locator('#modalCard .btn'); + await expect(btns.first()).toBeVisible(); + for (const b of await btns.all()) { + await expect(b.locator('svg')).toHaveCount(1); + await expect(b).toHaveAttribute('title', /\S/); + } + await page.keyboard.press('Escape'); + await expect(page.locator('#modal.on')).toBeHidden(); + } +}); + +test('All Subscriptions marks everything read, across every feed', async ({ page }) => { + const all = page.locator('#feedlist .place', { hasText: 'All Subscriptions' }); + await all.click(); + // Earlier tests read things; make sure something is unread. Opening an item reads it, and + // its own button makes it unread again. + await page.locator('.ep').first().click(); + await page.locator('#detail [data-a="read"][title="Mark unread"]').click(); + await expect(all.locator('.badge')).not.toHaveText('0'); + + page.once('dialog', d => d.accept()); + await page.locator('#content .acts [data-a="readall"]').click(); + await expect(all.locator('.badge')).toHaveText('0'); + await page.locator('.tabs button', { hasText: 'Unread' }).click(); + await expect(page.locator('.ep')).toHaveCount(0); +}); diff --git a/web/index.html b/web/index.html index bb9acea..a3885ef 100644 --- a/web/index.html +++ b/web/index.html @@ -125,7 +125,7 @@ a{color:var(--accent)} .sidefoot button{display:inline-flex;align-items:center;justify-content:center;gap:6px} .sidefoot i{font-style:normal;opacity:.75} .searchwrap{padding:0 12px 8px} -input[type=search],input[type=text],input[type=number],select{ +input[type=search],input[type=text],input[type=password],input[type=number],select{ width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg); border-radius:8px;padding:7px 10px;font:inherit;font-size:13.5px; } @@ -515,20 +515,18 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
+
+
-
-
-
- -
@@ -545,7 +543,7 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
-
+
@@ -562,9 +560,9 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
- - - + + +
0:00 @@ -579,7 +577,7 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu - +
@@ -625,7 +623,17 @@ const ICON={ doc:fa('0 0 576 512',''), // solid/file-pdf torrent:fa('0 0 448 512',''), // solid/magnet file:fa('0 0 384 512',''), // solid/file + copy:fa('0 0 448 512',''), // solid/copy + users:fa('0 0 640 512',''), // solid/users + signout:fa('0 0 512 512',''), // solid/right-from-bracket + back:fa('0 0 512 512',''), // solid/rotate-left + fwd:fa('0 0 512 512',''), // solid/rotate-right + pause:fa('0 0 384 512',''), // solid/pause + caret:fa('0 0 256 512',''), // solid/caret-right + left:fa('0 0 512 512',''), // solid/arrow-left }; +// One meaning per icon: minus unsubscribes, x closes or cancels, plus adds or subscribes, and a +// dialog's confirm button carries the icon of what it does. Words go in the tooltip. // The page's own buttons name their icon; this draws it in, ahead of any label they carry. for(const b of $$('[data-icon]')) b.insertAdjacentHTML('afterbegin',ICON[b.dataset.icon]); @@ -640,9 +648,10 @@ async function api(url,opts){ async function copyText(text,btn){ const flash=ok=>{ if(!btn) return; - const was=btn.textContent; + // The button is an icon, so it is the markup that has to come back, not just its text. + const was=btn.innerHTML; btn.textContent=ok?'Copied':'Failed'; - setTimeout(()=>btn.textContent=was,1300); + setTimeout(()=>btn.innerHTML=was,1300); }; try{ if(navigator.clipboard&&window.isSecureContext){ @@ -763,7 +772,7 @@ function renderFeeds(){ el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':''); const open = kids && (expanded.has(f.id) || q); el.innerHTML = - `${kids?'▶':''}`+ + `${kids?ICON.caret:''}`+ artHTML(f.image,f.title||f.id)+ `
${esc(f.title||f.id)}`+ (mine.length?`${mine.length} feed${mine.length===1?'':'s'} · ${saved} saved` @@ -811,11 +820,11 @@ function renderFeed(){ ${f.group?`
From the OPML subscription ${esc(f.group)}
`:''}
- + - +
` : `
@@ -825,6 +834,10 @@ function renderFeed(){
Every item from the ${S.feeds.length} feed${S.feeds.length===1?'':'s'} you subscribe to, newest first · ${unreadAll} unread
+
+ + +
`) + `
@@ -844,7 +857,7 @@ function renderFeed(){ dragSplit(pane); showDetail(null); - if(f) $$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a,f)); + $$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a,f):allAction(b.dataset.a)); $$('#content .tabs button').forEach(b=>b.onclick=()=>{S.filter=b.dataset.f;S.offset=0;renderFeed();loadEntries()}); } @@ -855,23 +868,24 @@ function renderGroup(f,kids){ const saved=kids.reduce((n,c)=>n+c.downloaded,0); const gone=kids.filter(c=>c.orphaned).length; $('#count').textContent=`${f.title||f.id}: ${kids.length} feed${kids.length===1?'':'s'}, ${unread} unread`; + // The same header as a feed's, buttons in the same places: it is a feed underneath. $('#content').innerHTML = ` -
+
${artHTML(f.image,f.title||f.id)}

${esc(f.title||f.id)}

-
OPML subscription · ${kids.length} feed${kids.length===1?'':'s'} +
OPML subscription · ${kids.length} feed${kids.length===1?'':'s'} · ${unread} unread · ${saved} downloaded · checked ${ago(f.last_checked)} · every ${everyText(f.every_mins)}
${f.last_error?`
${esc(f.last_error)}
`:''} ${gone?`
${gone} feed${gone===1?' is':'s are'} no longer listed in this OPML but kept because ${gone===1?'it has':'they have'} downloads.
`:''} -
- - - - -
+
+
+ + + +
@@ -909,6 +923,20 @@ async function feedAction(a,f){ if(a==='settings') settingsModal(f); if(a==='dl') downloadLatestModal(f); } +/// All Subscriptions' own buttons: a feed's, across every feed you read. +async function allAction(a){ + if(a==='scanall') return scanAll(); + if(a==='readall'){ + const n=S.feeds.reduce((k,x)=>k+(x.unread||0),0); + if(!n){ toast('Nothing unread'); return; } + // One click across every feed is a lot to take back, so this one asks first. + if(!confirm(`Mark all ${n} unread item${n===1?'':'s'} read, in every feed you subscribe to?`)) return; + try{ + const r=await api('/api/read-all',{method:'POST'}); + toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); + }catch(e){ toast(e.message,true); } + } +} /* ---------------- items ---------------- */ async function loadEntries(append){ @@ -959,7 +987,7 @@ function epEl(e){ const left = e.position>10 && e.duration ? `${clock(e.duration-e.position)} left` : (e.duration?clock(e.duration):''); el.innerHTML=` + player.guid===e.guid?ICON.play:(e.read?'':'●')}
@@ -1084,6 +1112,9 @@ const cur=()=>S.entries.find(x=>x.guid===S.sel); function syncTools(e){ $('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable)); $('#tbRead').disabled=$('#tbFlag').disabled=!e; + // The same icons as the item's own buttons beside its title, so the two never disagree. + $('#tbRead').innerHTML=e&&e.read?ICON.unread:ICON.check; + $('#tbFlag').innerHTML=e&&e.flagged?ICON.flagOn:ICON.flag; if(e){ $('#tbRead').title=`Mark ${e.read?'unread':'read'}`; $('#tbFlag').title=e.flagged?'Stop keeping':'Keep, so it is never deleted'; @@ -1106,13 +1137,12 @@ function showDetail(e){ 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)}`:''} + ${/* Joined, so a missing date or number leaves no stray dot behind. */ + [f&&esc(f.title||f.id), num, dateOf(e.published), e.duration&&clock(e.duration)] + .filter(Boolean).map(s=>`${s}`).join('')} - + +

Loading…

Daemon I/O is the control protocol itself — every command in and @@ -1417,20 +1450,20 @@ $('#addFeed').onclick=()=>{ Only items matching a keyword are downloaded.
-
-
`); +
+
`); $('#nurl').focus(); listFeeds('/api/popular'); $('#nsave').onclick=async()=>{ const url=$('#nurl').value.trim(); if(!url) return; - $('#nsave').textContent='Adding…'; $('#nsave').disabled=true; + $('#nsave').disabled=true; $('#nsave').title='Adding…'; try{ const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({ url, folder:$('#nfolder').value.trim()||null, keywords:$('#nkw').value.split(',').map(s=>s.trim()).filter(Boolean)})}); closeModal(); toast(r.existing?`Already subscribed as ${r.id}`:`Added ${r.id}`); await loadFeeds(true); selectFeed(r.id); - }catch(e){ toast(e.message,true); $('#nsave').textContent='Add feed'; $('#nsave').disabled=false; } + }catch(e){ toast(e.message,true); $('#nsave').title='Add feed'; $('#nsave').disabled=false; } }; }; @@ -1447,7 +1480,8 @@ async function listFeeds(url){ el.innerHTML=artHTML(p.image,p.title||p.id)+ `
${esc(p.title||p.id)}`+ `${p.subscribers} subscriber${p.subscribers===1?'':'s'}
`+ - (p.subscribed?'Subscribed':''); + (p.subscribed?'Subscribed' + :``); // Yours already: the row opens it instead. if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; box.appendChild(el); continue; } $('[data-a="sub"]',el).onclick=async()=>{ @@ -1561,13 +1595,13 @@ async function prefsModal(){ ${esc(g.download_dir)}
- Export OPML - + ${ICON.save} +
- Export hands every subscription to another podcast app. Import adds the - feeds listed in an OPML you paste in.
+ Export saves your subscriptions as OPML for another podcast app. Import + subscribes you to every feed in one.
-
+
Add and remove the people who can sign in, and choose who is an admin.
`); @@ -1595,7 +1629,7 @@ async function usersModal(){ ${esc(u.name)} ${u.password?'':'proxy'} -
`).join('')} +
`).join('')}
@@ -1604,8 +1638,8 @@ async function usersModal(){ At least 8 characters. Leave the password empty for someone who signs in through the proxy. New people start with no feeds.
-
-
`); +
+
`); const change=async(u,opts)=>{ try{ await api(`/api/users/${u.id}`,opts); @@ -1655,7 +1689,7 @@ function settingsModal(f){
- +
${S.me&&S.me.admin ? `Shared with everyone reading this feed. Editing it keeps every item and download — @@ -1710,8 +1744,8 @@ function removeFeed(f){

Removes ${esc(f.title||f.id)} from your feeds. Anyone else reading it keeps it, along with their own read state. Downloaded files and history are kept, so re-adding it will not pull the back catalogue again.

-
-
`); +
+
`); $('#rgo').onclick=async()=>{ await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'DELETE'}); closeModal(); toast('Unsubscribed'); S.feed=null; @@ -1722,14 +1756,15 @@ function removeFeed(f){ function opmlModal(){ openModal(`

OPML

Move subscriptions between podcast apps.

-
- Export -
+
+
-
-
`); +
+
`); $('#oimp').onclick=async()=>{ // A chosen file is read here and sent as text, so the server never stores it. Clearing // the picker lets go of it on this side too, whether it was refused or imported. @@ -1753,7 +1788,8 @@ function on(sel,ev,fn){ if(typeof fn!=='function'){ console.error('ipx: handler for',sel,'is not a function'); return; } el[ev]=fn; } -$('#scanAll').onclick=async()=>{ toast('Scanning all feeds…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }; +async function scanAll(){ toast('Scanning all feeds…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); } +$('#scanAll').onclick=scanAll; on('#prefs','onclick',prefsModal); on('#signout','onclick',async()=>{ await api('/api/logout',{method:'POST'}); location.href='/login'; }); api('/api/me').then(u=>{