Lay the Directory out as Apple's is, with the most subscribed in it (#124, #126, #128)

The Directory was one grid of every listed feed, 1,472 in production, under Podcasts and Blogs
tabs, a wall of about 30 category buttons and a sort menu, with a picked category's
subcategories appearing as a second row of buttons that looked like the first. It read as a
list to scroll, and the two rows were hard to tell apart.

It opens now on the ten most subscribed feeds, ranked, and the categories as tiles, each with
three of its shows' covers fanned in its corner on the tint its initials would get, so the
shows colour it and no theme's palette changes. A category has a page of its own: its most
subscribed, its subcategories with how many each holds, and its grid. See all is every feed,
as the grid was. Podcasts or Blogs holds across every page. Popular, the ten most subscribed,
had a place of its own in the feed list; it is the Directory's first section instead, and its
key, g p, is gone. /api/popular stays for scripts.

The search box finds a feed in the Directory by name (#126). It said "Search items…" there and
did nothing, since loadEntries returns early for a place that lists feeds.

A feed you do not subscribe to opens a page of its own (#128): its cover, category (a link to
that category's page), how many here subscribe, a subscribe button and its latest twenty items,
each with its title, linking to the post where it has one, its first lines, date and length.
Before, a click on it did nothing; only its + did. The items come from GET /api/directory/{id},
which answers only for a feed the Directory lists, so a guessed id reaches nothing private, and
carries no file or its address. The feed's own description would belong at the top, but the
database does not keep one.

Every tile and chart row takes the keyboard, as the feed list's rows do. Feed text made plain
keeps a space at a line break or a paragraph's end, which glued "2010)Recorded" together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 16:19:38 +00:00
parent 0b4f5443b7
commit 95acde3046
13 changed files with 426 additions and 181 deletions

View File

@@ -637,27 +637,86 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
/* Quiet buttons: a filled one on every row outshouted the row that is playing. */
#listening [data-a=play]{color:var(--accent)}
#listening [data-a=remove]{color:var(--faint)}
/* Directory's filters: .tabs for what a feed is, as the item filters are, and chips for what it
is about. A picked chip is underlined in --accent2, as the download bar and the now-playing EQ
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}
/* The Directory, laid out as Apple's is: a large title, then sections. Every page carries what
kind of feed (.tabs, as the item filters are) in its title row. */
.dhead{margin-bottom:6px}
.dback{display:inline-flex;align-items:center;gap:5px;margin:0 0 8px -5px;padding:3px 5px;border-radius:6px;font-size:13px;color:var(--accent)}
.dback .i{width:11px;height:11px}
.dback:hover{background:var(--raise)}
.dtitle{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px 18px}
.dtitle h2{margin:0;font-size:30px;line-height:1.15;font-weight:700;letter-spacing:-.02em;overflow-wrap:anywhere}
.dhead .sub{margin:5px 0 0;color:var(--dim);font-size:13px;font-variant-numeric:tabular-nums}
.dsec{margin-top:28px}
.dsech{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:10px}
.dsech h3{margin:0;font-size:19px;line-height:1.25;font-weight:650;letter-spacing:-.01em}
.dall{font-size:13px;color:var(--accent);flex:none}
.dall:hover{text-decoration:underline}
/* The most subscribed, numbered because they are ranked: down one column, then the next. */
.chart{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(var(--rows,5),auto);grid-auto-flow:column;column-gap:32px}
.rank{display:grid;grid-template-columns:22px 52px minmax(0,1fr) auto;align-items:center;gap:12px;padding:8px 0;border-bottom:1px solid var(--line)}
.rank .no{font-size:17px;font-weight:650;color:var(--faint);text-align:right;font-variant-numeric:tabular-nums}
.rank .art{width:52px;height:52px;font-size:16px}
.rank .txt b{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere;font-weight:500;font-size:14px;line-height:1.3}
.rank .txt small{display:block;margin-top:2px;color:var(--faint);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* A category: its name on the tint its initials would get, its three most subscribed shows'
covers fanned in the corner, so the shows are what colour it. The fan opens a little under the
pointer, the only thing on the page that moves. */
.cats{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:12px}
.cat{
position:relative;overflow:hidden;height:116px;padding:13px 15px;border-radius:12px;text-align:left;
display:flex;flex-direction:column;align-items:flex-start;
background:color-mix(in srgb,var(--tint) 15%,var(--panel));border:1px solid color-mix(in srgb,var(--tint) 28%,var(--line));
}
.cat:hover{border-color:var(--accent)}
/* The whole width: the fan sits below two lines of name, and a narrower name broke Government
in two in the wider faces, Classic's and Glass's. */
.cat b{font-size:16px;line-height:1.2;font-weight:650;color:var(--fg);overflow-wrap:break-word}
.cat small{margin-top:3px;font-size:12px;color:var(--dim);font-variant-numeric:tabular-nums}
.cat .fan{position:absolute;right:14px;bottom:-12px;width:112px;height:80px;pointer-events:none}
.cat .fan .art{position:absolute;bottom:0;width:62px;height:62px;font-size:16px;border-radius:8px;box-shadow:0 3px 10px rgba(0,0,0,.3);transition:transform .2s ease-out}
.cat .fan .art:nth-last-child(1){right:0;transform:rotate(8deg) translateY(-12px)}
.cat .fan .art:nth-last-child(2){right:25px;transform:rotate(-3deg) translateY(-4px)}
.cat .fan .art:nth-last-child(3){right:50px;transform:rotate(-13deg) translateY(4px)}
.cat:hover .fan .art:nth-last-child(1){transform:rotate(12deg) translate(4px,-16px)}
.cat:hover .fan .art:nth-last-child(3){transform:rotate(-18deg) translate(-6px,4px)}
/* A category's subcategories, each with how many it holds, then the order. A picked one is
drawn as a picked tab is. */
.dtools{display:flex;align-items:center;gap:10px 14px;margin-bottom:14px}
.pills{display:flex;flex-wrap:wrap;gap:6px;flex:1 1 0;min-width:0}
.pills button{flex:none;padding:4px 11px;border:1px solid var(--line);border-radius:999px;font-size:13px;color:var(--dim);white-space:nowrap}
.pills button small{margin-left:6px;color:var(--faint);font-size:11.5px;font-variant-numeric:tabular-nums}
.pills button:hover{border-color:var(--accent);color:var(--fg)}
.pills button.on{background:var(--raise);border-color:var(--raise);color:var(--fg);font-weight:500}
.dtools .grow{flex:1}
#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%}
.chips button{flex:none;padding:4px 6px;font-size:13px;color:var(--dim);white-space:nowrap;border-bottom:2px solid transparent}
.chips button:hover{color:var(--fg)}
.chips button[aria-pressed="true"]{color:var(--fg);border-bottom-color:var(--accent2)}
/* Square cover art, title underneath: podcast art is made to be known at a glance. The subscribe
button stays visible, since a button that only shows on hover cannot be reached on a phone. */
.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:18px 14px;margin-top:14px}
.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:18px 14px}
.tiles>.hint{grid-column:1/-1}
.tile{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 2px;align-items:start;cursor:pointer}
.tile{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 2px;align-items:start}
.tile .art{grid-column:1/-1;width:100%;height:auto;aspect-ratio:1;font-size:34px}
.tile .txt b{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere;font-weight:500;font-size:13px;line-height:1.3}
.tile .txt small{display:block;color:var(--faint);font-size:11.5px}
.tile:hover .txt b{color:var(--accent)}
/* Every one opens: yours its feed, the rest their page here. */
.tile,.rank{cursor:pointer}
.tile:hover .txt b,.rank:hover .txt b{color:var(--accent)}
/* A feed's page before you subscribe, as Apple's show page: the cover large, what it is and who
here reads it beside it, then what it has published lately. */
.show{display:flex;gap:22px;align-items:flex-end;margin-top:4px}
.show>.art{width:180px;height:180px;font-size:48px;border-radius:12px;box-shadow:var(--shadow)}
.show .meta{min-width:0;flex:1;display:flex;flex-direction:column;align-items:flex-start;gap:5px}
.show h2{margin:0 0 2px;font-size:30px;line-height:1.15;font-weight:700;letter-spacing:-.02em;overflow-wrap:anywhere}
.show .sub{margin:0;color:var(--dim);font-size:13px;font-variant-numeric:tabular-nums}
.show .acts{margin-top:8px}
.dlink{padding:0;font-size:13px;color:var(--accent);text-align:left}
.dlink:hover{text-decoration:underline}
.latest{max-width:760px}
.lep{padding:12px 0;border-bottom:1px solid var(--line)}
.lep b{display:block;font-size:15px;font-weight:600;line-height:1.35;overflow-wrap:anywhere}
.lep b a{color:var(--fg);text-decoration:none}
.lep b a:hover{color:var(--accent)}
.lep p{margin:4px 0 0;color:var(--dim);font-size:13px;line-height:1.5;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden}
.lep small{display:flex;align-items:center;gap:7px;margin-top:6px;color:var(--faint);font-size:12px;font-variant-numeric:tabular-nums}
/* Outlined, not filled: on --raise the warning and error colours fell short of AA in most light
themes, where on the row's own ground they clear it. */
.tag{
@@ -686,7 +745,7 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
/* A selected row is --raise too, and the zero count's pill vanished into it. */
.sel .badge.zero{background:var(--bg)}
/* Counts, sizes and times that change in place should not jiggle the text around them. */
.badge,.feed small,.childrow small,.tile small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums}
.badge,.feed small,.childrow small,.tile small,.rank small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums}
/* ---------- main ---------- */
#main{overflow:hidden;min-height:0;min-width:0;display:flex;flex-direction:column}
@@ -1061,9 +1120,22 @@ body:has(#player.on) #status{padding-bottom:3px}
#pnow .txt small,#pright #vol{display:none}
.wrap.plain{padding:14px}
.card{padding:16px}
/* The chips get a line of their own and scroll sideways rather than wrap, so they never push
the grid down. */
.chips{flex:1 1 100%;flex-wrap:nowrap;overflow-x:auto}
/* The subcategories get a line of their own and scroll sideways rather than wrap, so they
never push the grid down. */
.dtitle h2{font-size:25px}
.dsec{margin-top:22px}
.dsech h3{font-size:17px}
.chart{grid-template-columns:minmax(0,1fr);grid-template-rows:none;grid-auto-flow:row}
.cats{grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px}
.cat{height:100px;padding:11px 12px}
.cat b{font-size:15px}
.cat .fan{right:6px;bottom:-16px;transform:scale(.7);transform-origin:bottom right}
.pills{flex:1 1 100%;flex-wrap:nowrap;overflow-x:auto}
.dtools{flex-wrap:wrap}
.show{flex-direction:column;align-items:flex-start;gap:14px}
.show>.art{width:150px;height:150px;font-size:40px}
.show h2{font-size:24px}
#dirsort{margin-left:auto}
.tiles{grid-template-columns:repeat(auto-fill,minmax(104px,1fr));gap:14px 10px}
/* A log line has no room for four columns: keep time and message, wrap as prose. */

View File

@@ -34,114 +34,217 @@ $('#addFeed').onclick=()=>{
};
};
// What everyone here reads, you included, as a place to start. The rows carry an id, never a
// URL, so a key in someone's feed address never reaches this page.
// The rows carry an id, never a URL, so a key in someone's feed address never reaches this page.
const NONE_LISTED='<p class="hint">Nothing yet. Feeds people here subscribe to show up here.</p>';
async function listFeeds(url,box){
let rows=[];
try{ rows=await api(url)||[]; }catch{}
box.innerHTML=rows.length?'':NONE_LISTED;
for(const p of rows) box.appendChild(listedFeed(p,'childrow'));
return rows.length;
}
/// One listed feed: a row in Popular and the Add a feed dialog, a tile in Directory's grid. The
/// parts are the same either way; the class lays them out.
function listedFeed(p,cls){
/// One listed feed: a tile in the Directory's grid or, given its place, a row in its chart. The
/// parts are the same either way; the class lays them out. Yours opens its feed; any other, its
/// page in the Directory, to see what it is before you take it (#128).
function listedFeed(p,cls,rank?: number){
const el=document.createElement('div');
el.className=cls;
el.innerHTML=artHTML(p.image,p.title||p.id)+
`<div class="txt"><b>${esc(p.title||p.id)}</b>`+
`<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+
el.className=cls+(p.subscribed?' mine':'');
el.tabIndex=0; el.dataset.id=p.id;
// As the feed list's rows: Enter or Space opens it, and stops there, or Space would also
// play or pause.
el.onkeydown=ev=>{
if((ev.key==='Enter'||ev.key===' ')&&ev.target===el){ ev.preventDefault(); ev.stopPropagation(); el.click(); }
};
// A chart says what a show is about, as Apple's does; the grid says who here reads it, when
// anyone does. "0 subscribers" under most of the grid said nothing.
const meta=rank?(p.subcategory||p.category):p.subscribers?plural(p.subscribers,'subscriber'):'';
el.innerHTML=(rank?`<span class="no">${rank}</span>`:'')+artHTML(p.image,NAME(p))+
`<div class="txt"><b>${esc(NAME(p))}</b>${meta?`<small class="meta">${esc(meta)}</small>`:''}</div>`+
// Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one.
(p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>`
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button>`);
// Yours already: the row opens it instead.
if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; return el; }
$('[data-a="sub"]',el).onclick=async()=>{
try{
await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
closeModal(); toast(`Subscribed to ${p.title||p.id}`);
await loadFeeds(true); selectFeed(p.id);
}catch(e){ toast(e.message,true); }
};
if(p.subscribed){ el.onclick=()=>selectFeed(p.id); return el; }
el.onclick=()=>{ dirShow=p.id; $('#content').scrollTop=0; drawDirectory(undefined,'.dback'); };
// Its own click, not the tile's as well.
$('[data-a="sub"]',el).onclick=ev=>{ ev.stopPropagation(); subscribeListed(p); };
return el;
}
// 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, 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
/// category, as chips, then once one is picked its subcategories, as Apple's directory does).
/// They filter in place, without asking the server again.
async function renderDirectory(url,box){
let rows=[];
try{ rows=await api(url)||[]; }catch{}
if(!rows.length){ box.innerHTML=NONE_LISTED; return 0; }
const bar=$('#dirbar');
// Only a filter when the server has both kinds.
const both=rows.some(KINDS.Podcasts)&&rows.some(KINDS.Blogs);
const btn=(k,v,on)=>`<button type="button" data-${k}="${esc(v)}" class="${on?'on':''}" aria-pressed="${on}">${esc(v)}</button>`;
const draw=()=>{
if(!both) dirKind='All';
const ofKind=rows.filter(KINDS[dirKind]);
// No empty chips: only the categories among the feeds the kind lets through.
const cats=[...new Set(ofKind.map(p=>p.category).filter(Boolean))].sort();
if(!cats.includes(dirCat)) dirCat=null;
const ofCat=ofKind.filter(p=>!dirCat||p.category===dirCat);
const subs=dirCat?[...new Set(ofCat.map(p=>p.subcategory).filter(Boolean))].sort():[];
if(!subs.includes(dirSub)) dirSub=null;
bar.innerHTML=
(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>`:'')+
`<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>`:'');
// A picked chip lifts on a second press. Everything is redrawn, so the keyboard goes back to
// the button just pressed.
for(const b of $$('button',bar)) b.onclick=()=>{
const k=b.dataset.kind!=null?'kind':b.dataset.cat!=null?'cat':'sub', v=b.dataset[k];
if(k==='kind') dirKind=v;
else if(k==='cat'){ dirCat=dirCat===v?null:v; dirSub=null; }
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).sort(DIR_SORTS[dirSort][1])) box.appendChild(listedFeed(p,'tile'));
};
draw();
return rows.length;
async function subscribeListed(p){
try{
await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
toast(`Subscribed to ${NAME(p)}`);
await loadFeeds(true); selectFeed(p.id);
}catch(e){ toast(e.message,true); }
}
/// Directory and Popular open in the main pane, as the original's Directory did.
// Where you are in the Directory: what kind of feed, which category and subcategory, in what
// order. Out here because a search redraws the page, and the kind and order hold across visits.
let dirKind='All', dirCat=null, dirSub=null, dirSort='az', dirRows=[];
// The feed whose page is open over all that, by id; going back leaves the rest as it was.
let dirShow=null;
// The two pages that are not a category: every feed, and the feeds that name none. A category is
// Apple's or one an admin typed, neither of which starts with a colon.
const EVERY=':every', OTHER=':other';
const NAME=p=>(p.title||p.id);
const byName=(a,b)=>NAME(a).localeCompare(NAME(b),undefined,{sensitivity:'base',numeric:true});
const bySubs=(a,b)=>b.subscribers-a.subscribers||byName(a,b);
/// The grid's orders. It opens A to Z, as the server sends it.
const DIR_SORTS={
subscribers:['Most subscribers',bySubs],
az:['Name, A to Z',byName],
za:['Name, Z to A',(a,b)=>byName(b,a)],
};
const KINDS={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast};
const KIND_WORD={All:'feed',Podcasts:'podcast',Blogs:'blog'};
/// The Directory, as Apple's is laid out: it opens on the most subscribed feeds and the
/// categories, and a category has a page of its own, its subcategories along the top of its
/// grid. What a feed is (Podcasts, anything with audio or video, or Blogs, the rest) holds across
/// every page, and the search box looks through the lot. One fetch draws all of it.
async function renderDirectory(url,box){
// Each visit starts at the front, as going back to a tab does.
dirCat=null; dirSub=null; dirShow=null;
try{ dirRows=await api(url)||[]; }catch{ dirRows=[]; }
drawDirectory(box);
return dirRows.length;
}
/// `focus` is the control to put the keyboard back on, since the whole page is redrawn.
function drawDirectory(box=$('#directory'), focus?: string){
if(!box) return;
if(!dirRows.length){ box.innerHTML=NONE_LISTED; return; }
// Only a choice when the server has both kinds.
const both=dirRows.some(KINDS.Podcasts)&&dirRows.some(KINDS.Blogs);
if(!both) dirKind='All';
const rows=dirRows.filter(KINDS[dirKind]), word=KIND_WORD[dirKind];
const count=n=>`${n.toLocaleString()} ${word}${n===1?'':'s'}`;
const of=c=>p=>c===EVERY||(c===OTHER?!p.category:p.category===c);
// A kind can leave a category with nothing in it; that goes back to the front.
if(dirCat&&!rows.some(of(dirCat))) dirCat=null;
const catName=c=>c===EVERY?`All ${word}s`:c===OTHER?'Other':c;
const top=list=>list.filter(p=>p.subscribers).sort(bySubs).slice(0,10);
const btn=(k,v,label,on)=>`<button type="button" data-${k}="${esc(v)}" aria-pressed="${on}"${on?' class="on"':''}>${label}</button>`;
// back: where its button goes, '' for the front; none on the front itself.
const head=(title,sub,back?)=>`<div class="dhead">${back==null?'':
`<button type="button" class="dback" data-go="${esc(back)}">${ICON.left}${esc(back?catName(back):'Directory')}</button>`}
<div class="dtitle"><h2>${esc(title)}</h2>${both?`<div class="tabs" role="group" aria-label="Kind">${
Object.keys(KINDS).map(k=>btn('kind',k,k,k===dirKind)).join('')}</div>`:''}</div>
<p class="sub">${esc(sub)}</p></div>`;
const sec=(title,body,extra='')=>`<section class="dsec">${title?`<div class="dsech"><h3>${esc(title)}</h3>${extra}</div>`:''}${body}</section>`;
const tools=(pills='')=>`<div class="dtools">${pills||'<span class="grow"></span>'}<select id="dirsort" aria-label="Sort">${
Object.entries(DIR_SORTS).map(([k,[label]])=>`<option value="${k}"${k===dirSort?' selected':''}>${label}</option>`).join('')}</select></div>`;
const q=S.q.trim(), grid='<div class="tiles" id="dtiles"></div>', board='<div class="chart" id="dchart"></div>';
let html, chart=[], tiles=null;
const show=dirShow&&dirRows.find(p=>p.id===dirShow);
if(show){
const n=show.subscribers;
html=`<div class="dhead"><button type="button" class="dback" data-back>${ICON.left}${esc(q?'Results':dirCat?catName(dirCat):'Directory')}</button></div>
<div class="show">${artHTML(show.image,NAME(show))}<div class="meta"><h2>${esc(NAME(show))}</h2>${
// Its category's page is one click away from here.
show.category?`<button type="button" class="dlink" data-go="${esc(show.category)}">${esc(show.subcategory||show.category)}</button>`:''}
<p class="sub">${n?`${n} ${n===1?'person here subscribes':'people here subscribe'}`:'Nobody here subscribes yet'}</p>
<div class="acts"><button class="btn ico primary" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button></div></div></div>`
+sec(show.podcast?'Latest episodes':'Latest posts','<div class="latest" id="dshow"><p class="hint">Loading…</p></div>');
}else if(q){
// Names only: a category is a tap away, and "news" matching every News feed buried the
// one called that.
tiles=rows.filter(p=>NAME(p).toLocaleLowerCase().includes(q.toLocaleLowerCase()));
html=head(`Results for “${q}”`,tiles.length?count(tiles.length):`No ${word} here has that in its name.`,dirCat||'')+tools()+grid;
}else if(!dirCat){
chart=top(rows);
const cats=new Map<string,any[]>();
for(const p of rows){ const c=p.category||OTHER; if(!cats.has(c)) cats.set(c,[]); cats.get(c).push(p); }
// Other, the feeds with no category, goes last.
const order=[...cats.keys()].sort((a,b)=>Number(a===OTHER)-Number(b===OTHER)||a.localeCompare(b));
html=head('Directory',`${count(rows.length)} on this server. Private feeds are never listed.`)
+(chart.length?sec('Most subscribed',board):'')
+sec('Categories',`<div class="cats">${order.map(c=>catTile(c,catName(c),count(cats.get(c).length),cats.get(c))).join('')}</div>`,
`<button type="button" class="dall" data-go="${EVERY}">See all ${rows.length.toLocaleString()}</button>`);
}else{
const inCat=rows.filter(of(dirCat));
const subs=dirCat===EVERY?[]:[...new Set(inCat.map(p=>p.subcategory).filter(Boolean))].sort();
if(!subs.includes(dirSub)) dirSub=null;
if(dirCat!==EVERY) chart=top(inCat);
tiles=inCat.filter(p=>!dirSub||p.subcategory===dirSub);
const pill=(v,label,n)=>btn('sub',v,`${esc(label)}<small>${n}</small>`,(dirSub||'')===v);
html=head(catName(dirCat),dirCat===OTHER?`${count(inCat.length)} that name no category`:count(inCat.length),'')
+(chart.length?sec('Most subscribed',board):'')
// Its own heading only when the chart is above it, to say where one list ends.
+sec(chart.length?`${word[0].toUpperCase()+word.slice(1)}s`:'',tools(subs.length?`<div class="pills" role="group" aria-label="Subcategory">${
pill('','All',inCat.length)+subs.map(s=>pill(s,s,inCat.filter(p=>p.subcategory===s).length)).join('')}</div>`:'')+grid);
}
box.innerHTML=html;
if(show){ $('.show [data-a="sub"]',box).onclick=()=>subscribeListed(show); fillShow($('#dshow',box),show); }
const c=$('#dchart',box);
if(c){ c.style.setProperty('--rows',String(Math.ceil(chart.length/2))); chart.forEach((p,i)=>c.appendChild(listedFeed(p,'rank',i+1))); }
const t=$('#dtiles',box);
if(t) for(const p of tiles.sort(DIR_SORTS[dirSort][1])) t.appendChild(listedFeed(p,'tile'));
box.onclick=ev=>{
const b=(ev.target as Element).closest<HTMLElement>('[data-go],[data-kind],[data-sub],[data-back]');
if(!b) return;
// Back from a feed's page, onto the tile or row it was opened from.
if(b.dataset.back!=null){ const id=dirShow; dirShow=null; return drawDirectory(box,`[data-id="${CSS.escape(id)}"]`); }
if(b.dataset.kind!=null){ dirKind=b.dataset.kind; return drawDirectory(box,`[data-kind="${CSS.escape(dirKind)}"]`); }
if(b.dataset.sub!=null){ dirSub=b.dataset.sub||null; return drawDirectory(box,`[data-sub="${CSS.escape(b.dataset.sub)}"]`); }
// Going anywhere ends a search.
if(S.q){ S.q=''; $('#epSearch').value=''; }
const from=dirCat;
dirCat=b.dataset.go||null; dirSub=null; dirShow=null;
$('#content').scrollTop=0;
// Into a page, onto its way back; back out, onto the tile you went in by.
drawDirectory(box,dirCat?'.dback':from?`[data-go="${CSS.escape(from)}"]:not(.dback)`:undefined);
};
const s=$('#dirsort',box);
if(s) s.onchange=()=>{ dirSort=s.value; drawDirectory(box,'#dirsort'); };
if(focus) $(focus,box)?.focus();
}
/// A listed feed's latest items, on its page: each one's title, opening its post on the
/// publisher's site where it has one, its first lines, and when and how long.
async function fillShow(list,p){
let items;
try{ items=await api(`/api/directory/${encodeURIComponent(p.id)}`); }
catch(e){ list.innerHTML=`<p class="hint">${esc(e.message)}</p>`; return; }
// Gone back, or on to another, while it loaded.
if(dirShow!==p.id||!list.isConnected) return;
list.innerHTML=items.length?items.map(e=>{
const name=entryName(e), text=name.derived?'':plainText(e.description).slice(0,500);
const when=[dateOf(e.published),e.duration&&clock(e.duration)].filter(Boolean);
return `<article class="lep"><b>${e.link?`<a href="${esc(e.link)}" target="_blank" rel="noopener">${esc(name.text)}</a>`:esc(name.text)}</b>`
+(text?`<p>${esc(text)}</p>`:'')
+(when.length?`<small>${when.map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}</small>`:'')+'</article>';
}).join(''):`<p class="hint">Nothing read from it yet. A feed nobody here subscribes to is checked once a day.</p>`;
}
/// A category as a tile: its name, how many feeds, and the covers of its three most subscribed
/// fanned in the corner, so each one is coloured by what is in it, as Apple's are.
function catTile(c,name,count,rows){
// Covers before initials, which are all Other had; the likeliest first, and first is in front:
// the fan is drawn back to front.
const art=[...rows].sort((a,b)=>Number(!a.image)-Number(!b.image)||bySubs(a,b)).slice(0,3).reverse();
return `<button type="button" class="cat" data-go="${esc(c)}" style="--tint:${tint(c)}"><b>${esc(name)}</b><small>${count}</small>`+
`<span class="fan" aria-hidden="true">${art.map(p=>artHTML(p.image,NAME(p))).join('')}</span></button>`;
}
/// The Directory and Currently Listening open in the main pane, as the original's Directory did.
async function renderListed(v){
const box=$('#content');
box.classList.add('plain');
$('#tbRemove').disabled=true;
syncTools(null);
if(v===VIEWS[':directory']){
$('#epSearch').placeholder='Find a feed…';
box.innerHTML='<div id="directory"><p class="hint">Loading…</p></div>';
$('#count').textContent=v.title;
const n=await renderDirectory(v.url,$('#directory'));
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,'feed')}`;
return;
}
$('#epSearch').placeholder='Search items…';
const listening=v===VIEWS[':listening'], grid=v===VIEWS[':directory'];
box.innerHTML=`
<div class="fhead slim">
<div class="art">${v.icon}</div>
<div class="meta"><h2>${v.title}</h2>
<div class="sub">${v.blurb}${listening?'':' Everyone counts, you included. Private feeds are never listed.'}</div></div>
<div class="meta"><h2>${v.title}</h2><div class="sub">${v.blurb}</div></div>
</div>
${grid?'<div class="dirbar" id="dirbar"></div>':''}
<div class="${grid?'tiles':'childlist'}" id="${listening?'listening':'popular'}"><p class="hint">Loading…</p></div>`;
<div class="childlist" id="listening"><p class="hint">Loading…</p></div>`;
$('#count').textContent=v.title;
const n=await (listening?renderListening:grid?renderDirectory:listFeeds)(v.url,$(listening?'#listening':'#popular',box));
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,listening?'episode':'feed')}`;
const n=await renderListening(v.url,$('#listening',box));
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,'episode')}`;
}
/// Currently Listening: episodes you started and have not finished, across every feed you
@@ -210,7 +313,9 @@ $('#tbRead').onclick=()=>{ const e=cur(); if(e) epAction('read',e,null); };
$('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); };
let searchT;
$('#epSearch').oninput=ev=>{ clearTimeout(searchT);
searchT=setTimeout(()=>{ S.q=ev.target.value; S.offset=0; loadEntries(); },250); };
// The Directory looks through what it already has; the rest ask the server (#126).
searchT=setTimeout(()=>{ S.q=ev.target.value; S.offset=0;
if(S.feed===':directory'){ dirShow=null; drawDirectory(); } else loadEntries(); },250); };
// Crossing the phone breakpoint moves the files between their pane and the text.
window.matchMedia?.('(max-width:820px)')?.addEventListener?.('change',()=>{ const e=cur(); if(e) showDetail(e); });

View File

@@ -28,10 +28,8 @@ const unreadFirst=(a,b)=>Number(b.unread>0)-Number(a.unread>0);
// The original's source list opened with these, above the feeds. They are places, not feeds:
// 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. 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.'},
// The Directory holds the most subscribed feeds too, which had a place of their own, Popular (#124).
':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory'},
':listening':{title:'Currently Listening',icon:ICON.audio,url:'/api/entries?filter=in_progress&limit=50',
blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'},
':all':{title:'All Subscriptions',icon:ICON.all},

View File

@@ -1,7 +1,7 @@
/* ---------------- items ---------------- */
let loadSeq=0;
async function loadEntries(append?: boolean){
// Directory and Popular list feeds, not items.
// The Directory lists feeds, and Currently Listening draws its own episodes.
if(!S.feed || VIEWS[S.feed]?.url) return;
const p=new URLSearchParams({offset:String(S.offset),limit:String(LIMIT),filter:S.filter,sort:S.sort.col,dir:S.sort.dir});
if(S.q) p.set('q',S.q);
@@ -141,12 +141,15 @@ const feedName=id=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id
/// and Scripting News titles almost none of its posts) the opening of its text, then its file's
/// name, then its feed and date: "(untitled)" fifty times down a list said nothing about any of
/// them. `derived` marks a name that is not a title, which the list sets as text, not heading.
/// Feed HTML as plain words. An inert document: nothing in it loads or runs, where a detached
/// element fetches its images. A line break or paragraph's end is a space, or "2010)<br>Recorded"
/// read "2010)Recorded".
const plainText=(html?: string)=>html
? (new DOMParser().parseFromString(html.replace(/<(br|\/p|\/div|\/li|\/h\d)\b[^>]*>/gi,' $&'),'text/html').body.textContent||'')
.replace(/\s+/g,' ').trim() : '';
function entryName(e): {text: string, derived: boolean}{
if(e.title&&e.title.trim()) return {text:e.title, derived:false};
// An inert document: nothing in it loads or runs, where a detached element fetches its images.
const words=e.description
? (new DOMParser().parseFromString(e.description,'text/html').body.textContent||'').replace(/\s+/g,' ').trim()
: '';
const words=plainText(e.description);
if(words){
const cut=words.length>120 ? words.slice(0,120).replace(/\s+\S*$/,'')+'…' : words;
return {text:cut, derived:true};

View File

@@ -135,7 +135,7 @@ document.addEventListener('keydown',ev=>{
// Feedly's keys, vim's j and k among them: a letter to move through items or feeds, g and a
// letter to go somewhere, ? to list them. None fire with Ctrl, Alt or Cmd held, so the browser's
// own shortcuts still work, or while a dialog is open.
const GO={a:':all',d:':directory',p:':popular',l:':listening'};
const GO={a:':all',d:':directory',l:':listening'};
let gAt=0;
function stepEntry(by){
if(VIEWS[S.feed]?.url||!S.entries.length) return;
@@ -176,10 +176,10 @@ function keysModal(){
const k=s=>`<kbd>${esc(s)}</kbd>`, g=c=>k('g')+' '+k(c);
const rows=[
['Go to'],
[g('a'),'All Subscriptions'],[g('d'),'Directory'],[g('p'),'Popular'],
[g('a'),'All Subscriptions'],[g('d'),'Directory'],
[g('l'),'Currently Listening'],[g('s'),'Settings'],
[k('Shift')+' '+k('J'),'Next feed'],[k('Shift')+' '+k('K'),'Previous feed'],
[k('/'),'Search items'],[k('r'),'Refresh'],[k('['),'Show or hide the feed list'],
[k('/'),'Search items, or the Directory'],[k('r'),'Refresh'],[k('['),'Show or hide the feed list'],
['Items'],
[k('j')+' or '+k('n'),'Next item'],[k('k')+' or '+k('p'),'Previous item'],
[k('Shift')+' '+k('A'),'Mark all read'],

View File

@@ -30,7 +30,6 @@ const ICON={
close:fa('0 0 384 512','<path fill="currentColor" d="M55.1 73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L147.2 256 9.9 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192.5 301.3 329.9 438.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.8 256 375.1 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192.5 210.7 55.1 73.4z"/>'), // solid/xmark
menu:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"/>'), // solid/bars
directory:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 60.7 28.7 32 64 32l320 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zm64 0l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 224l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 352l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64z"/>'), // solid/table-list
popular:fa('0 0 576 512','<path fill="currentColor" d="M309.5-18.9c-4.1-8-12.4-13.1-21.4-13.1s-17.3 5.1-21.4 13.1L193.1 125.3 33.2 150.7c-8.9 1.4-16.3 7.7-19.1 16.3s-.5 18 5.8 24.4l114.4 114.5-25.2 159.9c-1.4 8.9 2.3 17.9 9.6 23.2s16.9 6.1 25 2L288.1 417.6 432.4 491c8 4.1 17.7 3.3 25-2s11-14.2 9.6-23.2L441.7 305.9 556.1 191.4c6.4-6.4 8.6-15.8 5.8-24.4s-10.1-14.9-19.1-16.3L383 125.3 309.5-18.9z"/>'), // solid/star
all:fa('0 0 512 512','<path fill="currentColor" d="M232.5 5.2c14.9-6.9 32.1-6.9 47 0l218.6 101c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 149.8C5.4 145.8 0 137.3 0 128s5.4-17.9 13.9-21.8L232.5 5.2zM48.1 218.4l164.3 75.9c27.7 12.8 59.6 12.8 87.3 0l164.3-75.9 34.1 15.8c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 277.8C5.4 273.8 0 265.3 0 256s5.4-17.9 13.9-21.8l34.1-15.8zM13.9 362.2l34.1-15.8 164.3 75.9c27.7 12.8 59.6 12.8 87.3 0l164.3-75.9 34.1 15.8c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 405.8C5.4 401.8 0 393.3 0 384s5.4-17.9 13.9-21.8z"/>'), // solid/layer-group
unread:fa('0 0 512 512','<path fill="currentColor" d="M48 64c-26.5 0-48 21.5-48 48 0 15.1 7.1 29.3 19.2 38.4l208 156c17.1 12.8 40.5 12.8 57.6 0l208-156c12.1-9.1 19.2-23.3 19.2-38.4 0-26.5-21.5-48-48-48L48 64zM0 196L0 384c0 35.3 28.7 64 64 64l384 0c35.3 0 64-28.7 64-64l0-188-198.4 148.8c-34.1 25.6-81.1 25.6-115.2 0L0 196z"/>'), // solid/envelope: a closed letter, not a record button
audio:fa('0 0 448 512','<path fill="currentColor" d="M64 224c0-88.4 71.6-160 160-160s160 71.6 160 160l0 37.5c-10-3.5-20.8-5.5-32-5.5l-16 0c-26.5 0-48 21.5-48 48l0 128c0 26.5 21.5 48 48 48l16 0c53 0 96-43 96-96l0-160C448 100.3 347.7 0 224 0S0 100.3 0 224L0 384c0 53 43 96 96 96l16 0c26.5 0 48-21.5 48-48l0-128c0-26.5-21.5-48-48-48l-16 0c-11.2 0-22 1.9-32 5.5L64 224z"/>'), // solid/headphones