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 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 22:54:32 +00:00
parent 119336a20d
commit cc17b1ddab
5 changed files with 25 additions and 3 deletions

View File

@@ -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. - 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. - 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. - 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. - 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. - 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.

View File

@@ -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 expect(tiles).toHaveCount(dir.filter(p => p.podcast).length);
await pick('tabs', 'All').click(); await pick('tabs', 'All').click();
await expect(tiles).toHaveCount(dir.length); 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). // Add a feed is for an address; Popular and Directory are where you browse (issue #30).
await piper.locator('#addFeed').click(); await piper.locator('#addFeed').click();

View File

@@ -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. */ 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{display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px;margin-top:4px}
.dirbar .tabs{flex:none} .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} .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. */ /* A category's subcategories go on a line of their own, under the category picked. */
.chips.subs{flex-basis:100%} .chips.subs{flex-basis:100%}

View File

@@ -70,7 +70,14 @@ function listedFeed(p,cls){
// Directory's filters. Kept out here because a finished scan redraws the pane, which would // Directory's filters. Kept out here because a finished scan redraws the pane, which would
// otherwise clear them. // 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}; 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 /// 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 /// (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= bar.innerHTML=
(both?`<div class="tabs" role="group" aria-label="Kind">${Object.keys(KINDS).map(k=>btn('kind',k,k===dirKind)).join('')}</div>`:'')+ (both?`<div class="tabs" role="group" aria-label="Kind">${Object.keys(KINDS).map(k=>btn('kind',k,k===dirKind)).join('')}</div>`:'')+
(cats.length?`<div class="chips" role="group" aria-label="Category">${cats.map(c=>btn('cat',c,c===dirCat)).join('')}</div>`:'')+ (cats.length?`<div class="chips" role="group" aria-label="Category">${cats.map(c=>btn('cat',c,c===dirCat)).join('')}</div>`:'')+
`<select id="dirsort" aria-label="Sort">${Object.entries(DIR_SORTS).map(([k,[label]])=>
`<option value="${k}"${k===dirSort?' selected':''}>${label}</option>`).join('')}</select>`+
(subs.length?`<div class="chips subs" role="group" aria-label="Subcategory">${subs.map(c=>btn('sub',c,c===dirSub)).join('')}</div>`:''); (subs.length?`<div class="chips subs" role="group" aria-label="Subcategory">${subs.map(c=>btn('sub',c,c===dirSub)).join('')}</div>`:'');
// A picked chip lifts on a second press. Everything is redrawn, so the keyboard goes back to // A picked chip lifts on a second press. Everything is redrawn, so the keyboard goes back to
// the button just pressed. // the button just pressed.
@@ -106,8 +115,9 @@ async function renderDirectory(url,box){
else dirSub=dirSub===v?null:v; else dirSub=dirSub===v?null:v;
draw(); $(`[data-${k}="${CSS.escape(v)}"]`,bar)?.focus(); draw(); $(`[data-${k}="${CSS.escape(v)}"]`,bar)?.focus();
}; };
$('#dirsort',bar).onchange=e=>{ dirSort=e.target.value; draw(); $('#dirsort',bar).focus(); };
box.innerHTML=''; 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(); draw();
return rows.length; return rows.length;

View File

@@ -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. // an id starting with ':' can never be a feed's, since feed ids are slugs.
const VIEWS={ const VIEWS={
':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory', ':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', ':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.'}, 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', ':listening':{title:'Currently Listening',icon:ICON.audio,url:'/api/entries?filter=in_progress&limit=50',