Directory: chips by kind and category over a grid of cover art
Feeds take their channel's first <itunes:category> into a new feeds.category column; the migration drops ETag and Last-Modified once so every feed re-reads on its normal schedule and picks one up. /api/popular and /api/directory carry category and podcast (any audio or video enclosure). Directory becomes a grid of cover-art tiles under a chip rail: All, Podcasts, Blogs, and a podcast's categories once Podcasts is picked. Popular and Add a feed keep their rows. Closes #4, closes #5, closes #6, closes #7. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
105
web/index.html
105
web/index.html
@@ -195,6 +195,21 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
||||
.childrow .art{width:32px;height:32px;font-size:12px}
|
||||
.childrow .txt{flex:1;min-width:0}
|
||||
.childrow .txt b{display:block;font-weight:500;font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
/* Directory's chips pick what its grid shows. The picked one is underlined in --accent2, as the
|
||||
download bar and the now-playing EQ are; a .badge's fill already means unread in the sidebar. */
|
||||
.chips{display:flex;flex-wrap:wrap;gap:2px 6px;margin-top:4px}
|
||||
.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>.hint{grid-column:1/-1}
|
||||
.tile{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 2px;align-items:start;cursor:pointer}
|
||||
.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)}
|
||||
.tag{
|
||||
font-size:11px;font-weight:600;
|
||||
padding:1px 5px;border-radius:4px;background:var(--raise);color:var(--warn);flex:none;
|
||||
@@ -219,7 +234,7 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
||||
}
|
||||
.badge.zero{background:var(--raise);color:var(--faint)}
|
||||
/* Counts, sizes and times that change in place should not jiggle the text around them. */
|
||||
.badge,.feed small,.childrow small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums}
|
||||
.badge,.feed small,.childrow small,.tile 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}
|
||||
@@ -515,6 +530,9 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
||||
#pnow .txt small,#pright #vol{display:none}
|
||||
.wrap.plain{padding:14px}
|
||||
.card{padding:16px}
|
||||
/* The chips scroll sideways rather than wrap, so they never push the grid down. */
|
||||
.chips{flex-wrap:nowrap;overflow-x: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. */
|
||||
#logbox{font-size:11.5px;padding:8px}
|
||||
@@ -1646,30 +1664,68 @@ $('#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 caller hands over the
|
||||
// box: looked up by id, the Add a feed dialog's list landed in the Directory pane behind it.
|
||||
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?'':'<p class="hint">Nothing yet. Feeds people here subscribe to show up here.</p>';
|
||||
for(const p of rows){
|
||||
const el=document.createElement('div');
|
||||
el.className='childrow';
|
||||
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>`+
|
||||
// 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.subbed}</button>`);
|
||||
// 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()=>{
|
||||
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); }
|
||||
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){
|
||||
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>`+
|
||||
// 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.subbed}</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); }
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
// The chip Directory has picked. Kept out here because a finished scan redraws the pane, which
|
||||
// would otherwise put it back to All.
|
||||
let dirPick='All';
|
||||
/// Directory: every listed feed as its cover art, filtered in place by the chips above it.
|
||||
/// Podcasts and Blogs lead because almost no blog carries an iTunes category, and a rail of
|
||||
/// categories alone left two thirds of the feeds under All. A podcast's categories open once
|
||||
/// Podcasts is picked.
|
||||
async function renderDirectory(url,box){
|
||||
let rows=[];
|
||||
try{ rows=await api(url)||[]; }catch{}
|
||||
const chips=$('#chips');
|
||||
if(!rows.length){ box.innerHTML=NONE_LISTED; return 0; }
|
||||
const cats=[...new Set(rows.filter(p=>p.podcast&&p.category).map(p=>p.category))].sort();
|
||||
const pass={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast};
|
||||
for(const c of cats) pass['c:'+c]=p=>p.podcast&&p.category===c;
|
||||
const draw=()=>{
|
||||
if(!pass[dirPick]) dirPick='All';
|
||||
const inPodcasts=dirPick==='Podcasts'||dirPick.startsWith('c:');
|
||||
// No empty chips: a server without a blog gets no Blogs.
|
||||
const keys=['All','Podcasts','Blogs',...(inPodcasts?cats.map(c=>'c:'+c):[])].filter(k=>rows.some(pass[k]));
|
||||
chips.innerHTML=keys.map(k=>
|
||||
`<button type="button" data-k="${esc(k)}" aria-pressed="${k===dirPick}">${esc(k.replace(/^c:/,''))}</button>`).join('');
|
||||
// The chips are redrawn too, so the keyboard goes back to the one just picked.
|
||||
for(const b of $$('button',chips)) b.onclick=()=>{
|
||||
dirPick=b.dataset.k; draw(); $(`[data-k="${CSS.escape(dirPick)}"]`,chips)?.focus();
|
||||
};
|
||||
box.appendChild(el);
|
||||
}
|
||||
box.innerHTML='';
|
||||
for(const p of rows.filter(pass[dirPick])) box.appendChild(listedFeed(p,'tile'));
|
||||
};
|
||||
draw();
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
@@ -1680,18 +1736,19 @@ async function renderListed(v){
|
||||
$('#tbRemove').disabled=true;
|
||||
syncTools(null);
|
||||
$('#epSearch').placeholder='Search items…';
|
||||
const listening=v===VIEWS[':popular'];
|
||||
const listening=v===VIEWS[':popular'], 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} Everyone counts, you included. Private feeds are never listed.</div></div>
|
||||
</div>
|
||||
<div class="childlist" id="popular"><p class="hint">Loading…</p></div>
|
||||
${grid?'<div class="chips" id="chips" role="group" aria-label="Show"></div>':''}
|
||||
<div class="${grid?'tiles':'childlist'}" id="popular"><p class="hint">Loading…</p></div>
|
||||
${listening?`<div class="sub" style="margin:18px 0 8px;font-weight:600;color:var(--fg)">Currently Listening</div>
|
||||
<div class="childlist" id="listening"><p class="hint">Loading…</p></div>`:''}`;
|
||||
$('#count').textContent=v.title;
|
||||
const n=await listFeeds(v.url,$('#popular',box));
|
||||
const n=await (grid?renderDirectory:listFeeds)(v.url,$('#popular',box));
|
||||
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${n} feed${n===1?'':'s'}`;
|
||||
if(listening) renderListening();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user