A feed's page in the Directory (#128) had its cover, category and latest items, but not the feed's own description, which Apple's show page leads with: iPX read every item's description and threw the channel's away, and the feeds table had nowhere to keep it. It is parsed now, RSS's <description>, or iTunes' summary when that is empty, or Atom's subtitle, kept in feeds.description (kept when a later read has none, as title and image are), and sent, sanitized, with the items from /api/directory/{id}, which is now an object, not a list. The page shows it as plain text under the header, three lines of it, with More when there is more. A description that only repeats the title is left out. feeds.description is the first column added to a table that already exists. create_missing looks for it with a SELECT and runs the ALTER only when it is missing, since it runs on every open, the healthcheck's included, and an ALTER's lock is what made that time out before. The same once-only step forgets every feed's ETag and Last-Modified: a feed is read whole only when it has changed, so its description would otherwise wait for its next item. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
592 lines
36 KiB
TypeScript
592 lines
36 KiB
TypeScript
/* ---------------- modals ---------------- */
|
|
function openModal(html: string, wide?: boolean){
|
|
$('#modalCard').innerHTML=html;
|
|
$('#modalCard').classList.toggle('wide',!!wide);
|
|
$('#modal').classList.add('on');
|
|
}
|
|
function closeModal(){
|
|
$('#modal').classList.remove('on');
|
|
}
|
|
|
|
$('#modal').onclick=e=>{ if(e.target.id==='modal') closeModal(); };
|
|
|
|
$('#addFeed').onclick=()=>{
|
|
openModal(`<h3>Add a feed</h3>
|
|
<div class="field"><label>Feed URL</label><input type="text" id="nurl" placeholder="https://example.com/rss">
|
|
<span class="hint">A Patreon token on its own adds every show from that creator.</span></div>
|
|
<div class="field"><label>Folder (optional)</label><input type="text" id="nfolder" placeholder="Defaults to the feed title"></div>
|
|
<div class="field"><label>Keywords (optional, comma separated)</label>
|
|
<input type="text" id="nkw"><span class="hint">Only items matching a keyword are downloaded.</span></div>
|
|
<label class="check"><input type="checkbox" id="nexp"> Allow items marked explicit</label>
|
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
|
<button class="btn ico primary" id="nsave" title="Add feed" aria-label="Add feed">${ICON.plus}</button></div>`);
|
|
$('#nurl').focus();
|
|
$('#nsave').onclick=async()=>{
|
|
const url=$('#nurl').value.trim(); if(!url) return;
|
|
$('#nsave').disabled=true; $('#nsave').title='Adding…';
|
|
try{
|
|
const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({
|
|
url, folder:$('#nfolder').value.trim()||null, allow_explicit:$('#nexp').checked,
|
|
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').title='Add feed'; $('#nsave').disabled=false; }
|
|
};
|
|
};
|
|
|
|
// 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>';
|
|
|
|
/// 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+(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>`);
|
|
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;
|
|
}
|
|
|
|
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); }
|
|
}
|
|
|
|
// 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>
|
|
<div class="about" id="dabout" hidden><p></p><button type="button" class="dlink" aria-expanded="false" hidden>More</button></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(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 page, filled in: what it says it is, its first lines unless you ask for the
|
|
/// rest, and its latest items, 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(box,p){
|
|
const list=$('#dshow',box), about=$('#dabout',box);
|
|
let got;
|
|
try{ got=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;
|
|
// Some feeds give their title again as their description, which says nothing new.
|
|
const said=plainText(got.description);
|
|
if(said&&said!==NAME(p)){
|
|
const text=$('p',about), more=$('button',about);
|
|
text.textContent=said; about.hidden=false;
|
|
// Only when there is more than the three lines shown.
|
|
more.hidden=text.scrollHeight<=text.clientHeight+1;
|
|
more.onclick=()=>{
|
|
const open=about.classList.toggle('open');
|
|
more.textContent=open?'Less':'More'; more.setAttribute('aria-expanded',String(open));
|
|
};
|
|
}
|
|
const items=got.items;
|
|
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)}"><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…';
|
|
box.innerHTML=`
|
|
<div class="fhead slim">
|
|
<div class="art">${v.icon}</div>
|
|
<div class="meta"><h2>${v.title}</h2><div class="sub">${v.blurb}</div></div>
|
|
</div>
|
|
<div class="childlist" id="listening"><p class="hint">Loading…</p></div>`;
|
|
$('#count').textContent=v.title;
|
|
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
|
|
/// subscribe to. A row resumes the episode in the player bar on click -- a shortcut back to
|
|
/// where you left off, not another way to browse. The one in the player pauses instead.
|
|
async function renderListening(url,box){
|
|
let rows=[];
|
|
try{ rows=(await api(url)).entries||[]; }catch{}
|
|
box.innerHTML=rows.length?'':'<p class="hint">Nothing in progress. Episodes you start and do not finish show up here.</p>';
|
|
for(const e of rows){
|
|
// Carries its episode, for paintListenRow to repaint as the player moves.
|
|
const el: HTMLDivElement & {entry?: any}=document.createElement('div');
|
|
el.className='childrow';
|
|
el.entry=e;
|
|
el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+
|
|
`<div class="txt"><b>${EQ}<span>${esc(entryName(e).text)}</span></b>`+
|
|
`<small><span class="fd">${esc(feedName(e.feed_id))}</span><span class="left"></span></small></div>`+
|
|
`<button class="iconbtn" data-a="play"></button>`+
|
|
`<button class="iconbtn" data-a="remove" title="Remove from Currently Listening" aria-label="Remove from Currently Listening">${ICON.close}</button>`+
|
|
`<div class="rail"><i></i></div>`;
|
|
el.onclick=ev=>(ev.target as Element).closest('[data-a=remove]')?forget(e)
|
|
:el.classList.contains('now')&&!audio.paused?audio.pause():play(e);
|
|
paintListenRow(el);
|
|
box.appendChild(el);
|
|
}
|
|
return rows.length;
|
|
}
|
|
|
|
/// One row's time left, progress and play button, taken from the player when it is the one in it.
|
|
function paintListenRow(el){
|
|
const e=el.entry, now=player.guid===e.guid&&player.feed===e.feed_id;
|
|
// Zero until the player has sought to where you left off; the saved position stands till then.
|
|
if(now&&audio.currentTime) e.position=Math.floor(audio.currentTime);
|
|
// The player's own length first: a feed's can be minutes out.
|
|
const d=(now&&isFinite(audio.duration)&&Math.floor(audio.duration))||e.duration;
|
|
el.classList.toggle('now',now);
|
|
$('.left',el).textContent=d?`${clock(d-e.position)} left`:`${clock(e.position)} in`;
|
|
// With no length there is nothing to show, and an empty rail reads as a heavy border.
|
|
const rail=$('.rail',el); rail.hidden=!d;
|
|
$('i',rail).style.width=`${d?Math.min(100,e.position/d*100):0}%`;
|
|
const b=$('[data-a=play]',el), label=now&&!audio.paused?'Pause':'Resume';
|
|
if(b.title!==label){ b.title=label; b.setAttribute('aria-label',label); b.innerHTML=label==='Pause'?ICON.pause:ICON.play; }
|
|
}
|
|
/// Keeps the list in step with the player. Only a row that is, or was, the one in it changes.
|
|
function syncListening(){
|
|
for(const el of $$('#listening .childrow'))
|
|
if(el.entry&&(el.classList.contains('now')||player.guid===el.entry.guid)) paintListenRow(el);
|
|
}
|
|
|
|
/// Takes an episode off Currently Listening by forgetting where you got to: the list is every
|
|
/// episode with a saved position short of the end, so the position is what has to go.
|
|
async function forget(e){
|
|
// Closed without saving first, or the player's next save would put it straight back.
|
|
if(player.guid===e.guid){ player.guid=null; $('#pclose').click(); }
|
|
try{
|
|
await api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/position`,
|
|
{method:'POST',body:JSON.stringify({secs:0})});
|
|
}catch(err){ toast(err.message,true); }
|
|
if(S.feed===':listening') renderListed(VIEWS[':listening']);
|
|
}
|
|
|
|
// The toolbar acts on whatever is selected: the feed on the left, the item in the table.
|
|
$('#tbRemove').onclick=()=>{ const f=S.feeds.find(x=>x.id===S.feed); if(f) removeFeed(f); };
|
|
$('#tbPlay').onclick=()=>{ const e=cur(); if(e) play(e); };
|
|
$('#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);
|
|
// 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); });
|
|
|
|
let expanded = new Set(JSON.parse(localStorage.getItem('ipx.expanded')||'[]'));
|
|
function toggleGroup(id){
|
|
expanded.has(id) ? expanded.delete(id) : expanded.add(id);
|
|
try{ localStorage.setItem('ipx.expanded', JSON.stringify([...expanded])); }catch{}
|
|
renderFeeds();
|
|
}
|
|
let globalMax = 3;
|
|
function due(ts){
|
|
const d = ts - Date.now()/1000;
|
|
if(d <= 0) return 'due now';
|
|
if(d < 3600) return 'in '+Math.max(1,Math.round(d/60))+'m';
|
|
if(d < 86400) return 'in '+Math.round(d/3600)+'h';
|
|
return 'in '+Math.round(d/86400)+'d';
|
|
}
|
|
|
|
/// Your settings: the theme, your subscriptions as OPML, and, to read, what the server does
|
|
/// with feeds. The server's own settings, the accounts and the log are on /admin, which only an
|
|
/// admin is sent (issue #19); this used to hold them too, shown to admins only.
|
|
async function prefsModal(){
|
|
const g = await api('/api/settings');
|
|
const admin = !!(S.me&&S.me.admin);
|
|
openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button><h3>Settings</h3>
|
|
<div class="field"><label>Theme</label>
|
|
<select id="stheme">${Object.entries(THEMES).map(([k,t])=>
|
|
`<option value="${k}"${theme.name===k?' selected':''}>${esc(t.name)}</option>`).join('')}</select></div>
|
|
<div class="field" id="smodefield"${THEMES[theme.name].modes?'':' hidden'}><label>Light or dark</label>
|
|
<select id="smode">${Object.entries(MODES).map(([k,t])=>
|
|
`<option value="${k}"${theme.mode===k?' selected':''}>${esc(t)}</option>`).join('')}</select>
|
|
<span class="hint">Auto follows your system's light/dark setting.</span></div>
|
|
<div class="field"><label>Subscriptions</label>
|
|
<div class="inline">
|
|
<!-- Words as well as icons: a floppy disk and a plus mean nothing on their own here. -->
|
|
<a class="btn" href="/api/opml" download="ipx-subscriptions.opml" title="Export OPML" aria-label="Export OPML">${ICON.save} Export</a>
|
|
<button class="btn" id="gopml" title="Import OPML…" aria-label="Import OPML">${ICON.plus} Import…</button>
|
|
</div>
|
|
<span class="hint">Export saves your subscriptions as OPML for another podcast app. Import
|
|
subscribes you to every feed in one.</span></div>
|
|
<div class="field"><label for="sblock">Hide items mentioning</label>
|
|
<input type="text" id="sblock" value="${esc((S.me?.blocked||[]).join(', '))}">
|
|
<span class="hint">Comma separated words or phrases, in every feed you read. An item with
|
|
one in its title or text is hidden from you and not downloaded for you. Each feed's
|
|
settings can add more.</span></div>
|
|
<div class="field"><label for="tokname">API tokens</label>
|
|
<div id="tokens"></div>
|
|
<div class="inline"><input type="text" id="tokname" placeholder="What it is for">
|
|
<button class="btn" id="tokadd" title="Make an API token" aria-label="Make an API token">${ICON.plus} Make</button></div>
|
|
<span class="hint">A token lets a script or an agent use iPX as you, adding and removing
|
|
your feeds and reading your items: it sends <code>Authorization: Bearer</code> and the token.
|
|
Anyone holding one is you here, so revoke one you no longer use.</span></div>
|
|
<div class="field"><label>Feeds are checked every</label>
|
|
<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own.
|
|
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>`);
|
|
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true);
|
|
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
|
|
$('#gopml').onclick=opmlModal;
|
|
drawTokens();
|
|
$('#tokadd').onclick=async()=>{
|
|
const name=$('#tokname').value.trim(); if(!name){ $('#tokname').focus(); return; }
|
|
try{
|
|
const t=await api('/api/tokens',{method:'POST',body:JSON.stringify({name})});
|
|
$('#tokname').value='';
|
|
await drawTokens();
|
|
// Shown this once: only its hash is kept, so a lost token is revoked and made again.
|
|
$('#tokens').insertAdjacentHTML('afterbegin',`<div class="field" id="toknew"><span class="hint">Your new token,
|
|
${esc(t.name)}. Copy it now: it is not shown again.</span>
|
|
<div class="inline"><input type="text" id="tokval" readonly value="${esc(t.token)}">
|
|
<button class="btn ico" id="tokcopy" title="Copy" aria-label="Copy">${ICON.copy}</button></div></div>`);
|
|
$('#tokcopy').onclick=()=>copyText(t.token,$('#tokcopy'));
|
|
$('#tokval').select();
|
|
}catch(err){ toast(err.message,true); }
|
|
};
|
|
$('#sblock').onchange=async e=>{
|
|
const blocked=splitWords(e.target.value);
|
|
try{
|
|
await api('/api/me',{method:'PATCH',body:JSON.stringify({blocked})});
|
|
if(S.me) S.me.blocked=blocked;
|
|
toast('Saved'); await loadFeeds(true); loadEntries();
|
|
}catch(err){ toast(err.message,true); }
|
|
};
|
|
}
|
|
|
|
/// Your API tokens, each with when it was made and last used, and a button to revoke it.
|
|
async function drawTokens(){
|
|
const box=$('#tokens'); if(!box) return;
|
|
const list=await api('/api/tokens').catch(()=>[]);
|
|
box.innerHTML=list.map(t=>`<div class="tokrow"><span><b>${esc(t.name)}</b>
|
|
<span class="hint">made ${dateOf(t.created)}, last used ${ago(t.last_used)}</span></span>
|
|
<button class="btn ico" data-tok="${t.id}" title="Revoke ${esc(t.name)}" aria-label="Revoke ${esc(t.name)}">${ICON.trash}</button></div>`).join('');
|
|
for(const b of $$('[data-tok]',box)) b.onclick=async()=>{
|
|
try{ await api(`/api/tokens/${b.dataset.tok}`,{method:'DELETE'}); toast('Revoked'); drawTokens(); }
|
|
catch(err){ toast(err.message,true); }
|
|
};
|
|
}
|
|
|
|
const splitWords=(s: string)=>s.split(',').map(w=>w.trim()).filter(Boolean);
|
|
|
|
function settingsModal(f, newUrl?: string){
|
|
const isGroup = S.feeds.some(c=>c.group===f.id);
|
|
openModal(`<h3>${esc(f.title||f.id)}</h3>
|
|
${isGroup?`<p class="hint" style="margin:-6px 0 12px">This is ${isPatreon(f)?'a Patreon creator':'an OPML subscription'}. These
|
|
settings apply to it and are inherited by every feed inside it.</p>`:''}
|
|
${f.managed?`<p class="hint" style="margin:-6px 0 12px">This feed comes from
|
|
${isPatreon(S.feeds.find(p=>p.id===f.group))?'a Patreon creator':'an OPML subscription'} and follows its settings. Saving anything here gives it its own entry in
|
|
config.toml, and it stops following the subscription's settings.</p>`:''}
|
|
<p class="hint" style="margin:-4px 0 10px">These are <b>your</b> settings for this feed.
|
|
Everyone else keeps their own.</p>
|
|
<div class="field"><label>Keywords</label>
|
|
<input type="text" id="skw" value="${esc(f.keywords.join(', '))}">
|
|
<span class="hint">Comma separated. Empty takes everything.</span></div>
|
|
${isGroup?'':`<div class="field"><label for="sblock">Hide items mentioning</label>
|
|
<input type="text" id="sblock" value="${esc((f.blocked||[]).join(', '))}">
|
|
<span class="hint">Comma separated words or phrases. An item with one in its title or text
|
|
is hidden from you and not downloaded for you, as well as those your Settings hide
|
|
everywhere.</span></div>`}
|
|
<div class="field"><label>Newest episodes to download</label>
|
|
<input type="number" id="smax" min="0" value="${f.max_new_per_check??''}">
|
|
<span class="hint">Blank follows the global default (${globalMax}). Older episodes stay
|
|
listed to download by hand. <b>0 means every episode</b>, the whole back catalogue.</span></div>
|
|
<label class="check"><input type="checkbox" id="sauto" ${f.auto_download?'checked':''}> Download new items automatically</label>
|
|
<label class="check"><input type="checkbox" id="sexp" ${f.allow_explicit?'checked':''}> Allow items marked explicit</label>
|
|
<div class="field"><label>Feed URL</label>
|
|
<div class="inline">
|
|
<input type="text" id="surl" value="${esc(newUrl||f.url)}" spellcheck="false" ${S.me&&S.me.admin?'':'readonly'}>
|
|
<button type="button" class="btn ico" id="scopy" title="Copy the URL" aria-label="Copy the URL">${ICON.copy}</button>
|
|
</div>
|
|
<span class="hint">${S.me&&S.me.admin
|
|
? `Shared with everyone reading this feed. Editing it keeps every item and download —
|
|
handy when an auth token in the URL is rotated. The feed is re-checked from scratch
|
|
on the next scan.`
|
|
: `The same for everyone reading this feed, so only an admin can change it.`}</span></div>
|
|
${S.me&&S.me.admin?`<div class="field"><label>Download folder (shared)</label>
|
|
<input type="text" id="sfolder" value="${esc(f.folder||'')}" placeholder="${esc(f.title||f.id)}">
|
|
<span class="hint">Where the files land. There is one copy however many people
|
|
subscribe, so this is the same for everyone.</span></div>`:''}
|
|
${S.me&&S.me.admin?`<div class="field"><label>Directory category (shared)</label>
|
|
<input type="text" id="scat" list="scats" value="${esc(f.category||'')}" placeholder="${esc(f.feed_category||'None')}">
|
|
<datalist id="scats"></datalist>
|
|
<span class="hint">${f.feed_category
|
|
? `The feed names its own, ${esc(f.feed_category)}, and the Directory uses that.`
|
|
: `The feed names none, so the Directory files it under this. Pick one already listed where it fits.`}</span></div>`:''}
|
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
|
<button class="btn ico primary" id="ssave" title="Save" aria-label="Save">${ICON.check}</button></div>`);
|
|
$('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy'));
|
|
// Offer the categories the Directory already shows, so a blog about games joins Games rather
|
|
// than starting a second chip beside it.
|
|
if($('#scats')) api('/api/directory').then(rows=>{ $('#scats').innerHTML=[...new Set((rows||[])
|
|
.flatMap(p=>[p.category,p.subcategory]).filter(Boolean))].sort().map(c=>`<option value="${esc(c)}">`).join(''); }).catch(()=>{});
|
|
$('#ssave').onclick=async()=>{
|
|
const max=$('#smax').value;
|
|
try{
|
|
const patch: Record<string, unknown>={
|
|
keywords:splitWords($('#skw').value),
|
|
max_new_per_check:max===''?null:Number(max),
|
|
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked};
|
|
if($('#sblock')) patch.blocked=splitWords($('#sblock').value);
|
|
// The shared half is an admin's to change, and the API refuses it from anyone else.
|
|
if(S.me&&S.me.admin){
|
|
patch.url=$('#surl').value.trim();
|
|
patch.folder=$('#sfolder').value.trim()||null;
|
|
patch.category=$('#scat').value.trim()||null;
|
|
}
|
|
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify(patch)});
|
|
closeModal(); toast('Saved — applies on the next scan');
|
|
await loadFeeds(true); renderFeed(); loadEntries();
|
|
}catch(e){ toast(e.message,true); }
|
|
};
|
|
}
|
|
|
|
function downloadLatestModal(f){
|
|
openModal(`<h3>Download latest items</h3>
|
|
<div class="field"><label>How many of the newest undownloaded items?</label>
|
|
<input type="number" id="dcount" min="1" max="100" value="5">
|
|
<span class="hint">Queued immediately, ignoring the per-scan limit.</span></div>
|
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
|
<button class="btn ico primary" id="dgo" title="Download" aria-label="Download">${ICON.download}</button></div>`);
|
|
$('#dgo').onclick=async()=>{
|
|
const n=Number($('#dcount').value)||5;
|
|
try{
|
|
const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/download-latest`,
|
|
{method:'POST',body:JSON.stringify({count:n})});
|
|
closeModal(); toast(`Queued ${r.queued} item${r.queued===1?'':'s'}`);
|
|
}catch(e){ toast(e.message,true); }
|
|
};
|
|
}
|
|
|
|
function removeFeed(f){
|
|
openModal(`<h3>Unsubscribe?</h3>
|
|
<p style="color:var(--dim)">Removes <b>${esc(f.title||f.id)}</b> 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.</p>
|
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
|
<button class="btn ico danger" id="rgo" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button></div>`);
|
|
$('#rgo').onclick=async()=>{
|
|
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'DELETE'});
|
|
closeModal(); toast('Unsubscribed'); S.feed=null;
|
|
await loadFeeds(); if(!S.feeds.length) renderFeed();
|
|
};
|
|
}
|
|
|
|
function opmlModal(){
|
|
openModal(`<h3>OPML</h3>
|
|
<p style="color:var(--dim);font-size:13.5px">Move subscriptions between podcast apps.</p>
|
|
<div class="field"><label>Export: save your subscriptions as OPML</label>
|
|
<div class="inline">
|
|
<a class="btn ico" href="/api/opml" download="ipx-subscriptions.opml" title="Export OPML" aria-label="Export OPML">${ICON.save}</a>
|
|
</div></div>
|
|
<div class="field" style="margin-top:16px"><label>Import: choose a file, or paste OPML</label>
|
|
<input type="file" id="opmlFile" accept=".opml,.xml,text/x-opml,text/xml,application/xml" style="margin-bottom:8px">
|
|
<textarea id="opmlText" rows="6" style="width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:8px;font:12px monospace"></textarea></div>
|
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button>
|
|
<button class="btn ico primary" id="oimp" title="Import: subscribe to every feed in it" aria-label="Import">${ICON.plus}</button></div>`);
|
|
$('#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.
|
|
const pick=$('#opmlFile'), file=pick.files[0];
|
|
const xml=file ? await file.text() : $('#opmlText').value;
|
|
const letGo=()=>{ pick.value=''; };
|
|
// A quick look before sending anything. The server parses it properly and has the last word.
|
|
if(!/<opml[\s>]/i.test(xml)){
|
|
letGo(); toast(`${file?file.name:'That'} is not an OPML file`,true); return;
|
|
}
|
|
try{
|
|
const r=await api('/api/opml',{method:'POST',body:JSON.stringify({xml})});
|
|
letGo(); closeModal(); toast(`Subscribed to ${r.added} feed(s)`+(r.already?`, ${r.already} you already had`:'')); loadFeeds(true);
|
|
}catch(e){ letGo(); toast(e.message,true); }
|
|
};
|
|
}
|
|
|
|
// No toast: the spinners on the rows being checked say it (issue #37).
|
|
async function scanAll(){ await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
|
|
$('#scanAll').onclick=scanAll;
|
|
$('#prefs').onclick=prefsModal;
|
|
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while
|
|
// the proxy still vouches for them. /api/me says where, when that is the case.
|
|
$('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); location.href=S.me?.sign_out||'/login'; };
|
|
api('/api/me').then(u=>{
|
|
S.me=u;
|
|
$('#who').textContent=u.name+(u.admin?' · admin':'');
|
|
}).catch(()=>{});
|
|
$('#feedFilter').oninput=renderFeeds;
|
|
// The feed list from the keyboard: Enter or Space opens a row, Right and Left open and close a
|
|
// folder. Handled keys stop here, or the player's own Space and arrows would act on them too.
|
|
$('#feedlist').onkeydown=ev=>{
|
|
const row=ev.target.closest('[data-id]'); if(!row) return;
|
|
const id=row.dataset.id;
|
|
if((ev.key==='Enter'||ev.key===' ')&&ev.target===row) row.click();
|
|
else if(row.classList.contains('group')&&
|
|
(ev.key==='ArrowRight'&&!expanded.has(id)||ev.key==='ArrowLeft'&&expanded.has(id))) toggleGroup(id);
|
|
else return;
|
|
ev.preventDefault(); ev.stopPropagation();
|
|
};
|
|
$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
|
|
$('#scrim').onclick=()=>nav(false);
|
|
|