Currently Listening's search box said "Search items…" and did nothing: loadEntries returns early for a place with its own list. Typing now redraws the list with the search sent along, as /api/entries already takes one, title and text, and says so when nothing in progress matches. The keys list says / searches it too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
597 lines
36 KiB
TypeScript
597 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;
|
|
// The search box looks through these too, title and text, as it does a feed's items (#127).
|
|
const n=await renderListening(S.q?`${v.url}&q=${encodeURIComponent(S.q)}`: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?'':S.q?`<p class="hint">Nothing you are listening to mentions “${esc(S.q)}”.</p>`
|
|
:'<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; Currently Listening and the rest ask the
|
|
// server (#126, #127).
|
|
searchT=setTimeout(()=>{ S.q=ev.target.value; S.offset=0;
|
|
if(S.feed===':directory'){ dirShow=null; drawDirectory(); }
|
|
else if(S.feed===':listening') renderListed(VIEWS[':listening']);
|
|
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);
|
|
|