From cc17b1ddab5fbd71b120a4e044d387fe58d5dfc5 Mon Sep 17 00:00:00 2001 From: rays Date: Sun, 4 Oct 2026 22:54:32 +0000 Subject: [PATCH] Sort the Directory by name either way or by subscribers (#122) The Directory always listed A to Z, as the server sends it. A menu beside its filters now sorts it A to Z, Z to A or by most subscribers (then by name), in the page, without asking the server again, and the choice stays while the pane is redrawn, as the filters do. The blurb no longer says A to Z, since it may not be. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + tests/ui/app.spec.js | 10 ++++++++++ web/app.css | 1 + web/src/dialogs.ts | 14 ++++++++++++-- web/src/feeds.ts | 2 +- 5 files changed, 25 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8270144..5599b14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The Directory browses as Apple's does: a row of categories, and once one is picked, a row of its subcategories (Tech News under News, Video Games under Leisure), instead of one row mixing both. - A pinned item in a list wears the same disc as a pinned feed, in the theme's accent, instead of a plain solid pin. +- The Directory can be sorted by name, A to Z or Z to A, or by most subscribers. It still opens A to Z. - An item published without a title shows its opening words, in plain text rather than bold, instead of "(untitled)"; one with no text either shows its file's name, or its show and date. Opened, it starts with its text. - A feed that has moved for good (a permanent redirect) is followed to its new address, which iPX then reads from, and says so in the log as `feed_moved`. A temporary redirect changes nothing. - The daemon sleeps until the next feed is due, at most ten minutes, instead of looking every minute; refreshing or adding a feed still wakes it at once. diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index b93d95d..e2ca6b8 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -887,6 +887,16 @@ test('Popular lists what everyone here reads, but never a private feed', async ( await expect(tiles).toHaveCount(dir.filter(p => p.podcast).length); await pick('tabs', 'All').click(); await expect(tiles).toHaveCount(dir.length); + // Sorted by name either way, A to Z as it opens, or by subscribers (#122). + const byName = dir.map(p => p.title || p.id).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true })); + await piper.locator('#dirsort').selectOption('az'); + await expect(tiles.first()).toContainText(byName[0]); + await piper.locator('#dirsort').selectOption('za'); + await expect(tiles.first()).toContainText(byName[byName.length - 1]); + await expect(tiles).toHaveCount(dir.length); + await piper.locator('#dirsort').selectOption('subscribers'); + await expect(tiles).toHaveCount(dir.length); + await piper.locator('#dirsort').selectOption('az'); // Add a feed is for an address; Popular and Directory are where you browse (issue #30). await piper.locator('#addFeed').click(); diff --git a/web/app.css b/web/app.css index 5d0f404..9f65d7e 100644 --- a/web/app.css +++ b/web/app.css @@ -642,6 +642,7 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i, are; a .badge's fill already means unread in the sidebar. */ .dirbar{display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px;margin-top:4px} .dirbar .tabs{flex:none} +#dirsort{flex:none;width:auto;padding:4px 6px;font-size:13px} .chips{display:flex;flex-wrap:wrap;gap:2px 6px;flex:1 1 0;min-width:0} /* A category's subcategories go on a line of their own, under the category picked. */ .chips.subs{flex-basis:100%} diff --git a/web/src/dialogs.ts b/web/src/dialogs.ts index 461484f..09bb5cd 100644 --- a/web/src/dialogs.ts +++ b/web/src/dialogs.ts @@ -70,7 +70,14 @@ function listedFeed(p,cls){ // Directory's filters. Kept out here because a finished scan redraws the pane, which would // otherwise clear them. -let dirKind='All', dirCat=null, dirSub=null; +let dirKind='All', dirCat=null, dirSub=null, dirSort='az'; +/// The Directory's orders. It opens A to Z, as the server sends it. +const NAME=p=>(p.title||p.id); +const DIR_SORTS={ + subscribers:['Most subscribers',(a,b)=>b.subscribers-a.subscribers||NAME(a).localeCompare(NAME(b),undefined,{sensitivity:'base',numeric:true})], + az:['Name, A to Z',(a,b)=>NAME(a).localeCompare(NAME(b),undefined,{sensitivity:'base',numeric:true})], + za:['Name, Z to A',(a,b)=>NAME(b).localeCompare(NAME(a),undefined,{sensitivity:'base',numeric:true})], +}; const KINDS={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast}; /// Directory: every listed feed as its cover art, under two filters that combine: what a feed is /// (Podcasts, anything with audio or video, or Blogs, the rest) and what it is about (its iTunes @@ -96,6 +103,8 @@ async function renderDirectory(url,box){ bar.innerHTML= (both?`
${Object.keys(KINDS).map(k=>btn('kind',k,k===dirKind)).join('')}
`:'')+ (cats.length?`
${cats.map(c=>btn('cat',c,c===dirCat)).join('')}
`:'')+ + ``+ (subs.length?`
${subs.map(c=>btn('sub',c,c===dirSub)).join('')}
`:''); // A picked chip lifts on a second press. Everything is redrawn, so the keyboard goes back to // the button just pressed. @@ -106,8 +115,9 @@ async function renderDirectory(url,box){ else dirSub=dirSub===v?null:v; draw(); $(`[data-${k}="${CSS.escape(v)}"]`,bar)?.focus(); }; + $('#dirsort',bar).onchange=e=>{ dirSort=e.target.value; draw(); $('#dirsort',bar).focus(); }; box.innerHTML=''; - for(const p of ofCat.filter(p=>!dirSub||p.subcategory===dirSub)) box.appendChild(listedFeed(p,'tile')); + for(const p of ofCat.filter(p=>!dirSub||p.subcategory===dirSub).sort(DIR_SORTS[dirSort][1])) box.appendChild(listedFeed(p,'tile')); }; draw(); return rows.length; diff --git a/web/src/feeds.ts b/web/src/feeds.ts index aa026c6..6d6f9f1 100644 --- a/web/src/feeds.ts +++ b/web/src/feeds.ts @@ -29,7 +29,7 @@ const unreadFirst=(a,b)=>Number(b.unread>0)-Number(a.unread>0); // an id starting with ':' can never be a feed's, since feed ids are slugs. const VIEWS={ ':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory', - blurb:'Every feed anyone on this server subscribes to, A to Z. The feeds inside an OPML are listed one by one, not the OPML.'}, + blurb:'Every feed anyone on this server subscribes to. The feeds inside an OPML are listed one by one, not the OPML.'}, ':popular':{title:'Popular',icon:ICON.popular,url:'/api/popular', blurb:'The ten feeds with the most subscribers here. The feeds inside an OPML count one by one, not the OPML.'}, ':listening':{title:'Currently Listening',icon:ICON.audio,url:'/api/entries?filter=in_progress&limit=50',