Below the box it read as a setting about something else. It sits after the download options' fold, not in it, since DeadAir applies whether or not the show downloads on its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
725 lines
43 KiB
TypeScript
725 lines
43 KiB
TypeScript
/* ---------------- modals ---------------- */
|
|
/// `cls` names a dialog with a frame of its own. Set whole each time, so it never outlives its dialog.
|
|
function openModal(html: string, wide?: boolean, cls?: string){
|
|
$('#modalCard').innerHTML=html;
|
|
$('#modalCard').className='card'+(wide?' wide':'')+(cls?' '+cls:'');
|
|
$('#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>`:''}
|
|
${n?`<p class="sub">${n} ${n===1?'person here subscribes':'people here subscribe'}</p>`:''}
|
|
<p class="sub dfail" id="dfail" hidden></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));
|
|
};
|
|
}
|
|
// A broken feed says so, or it reads as a quiet one (#142).
|
|
if(got.failing){ const f=$('#dfail',box); f.textContent=got.failing; f.hidden=false; }
|
|
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.${p.subscribers||got.failing?''
|
|
:' 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.
|
|
function deadSavedText(me){
|
|
const s=me&&me.deadair_saved||0;
|
|
return s?`It has saved you ${spanText(s)} of dead air, on episodes you heard three quarters of or more.`
|
|
:'Turn it on in a show\'s settings, or beside the speed while it plays, to cut the dead air from it.';
|
|
}
|
|
|
|
async function prefsModal(){
|
|
// Fresh, so the time saved counts what was heard since the page loaded.
|
|
api('/api/me').then(u=>{ S.me=u; const h=$('#sdeadsaved'); if(h) h.textContent=deadSavedText(u); }).catch(()=>{});
|
|
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>DeadAir</label>
|
|
<span class="hint" id="sdeadsaved">${deadSavedText(S.me)}</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 boxed"><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);
|
|
|
|
/// Words as chips, for the words that pick or hide items: each word or phrase ends with Enter or a
|
|
/// comma, and comes off with its x or Backspace. One comma-separated box ran one phrase into the
|
|
/// next (#167).
|
|
const chipHTML=(w: string)=>`<span class="chip" data-w="${esc(w)}">${esc(w)}<button type="button" class="chipx"
|
|
title="Remove ${esc(w)}" aria-label="Remove ${esc(w)}">${ICON.close}</button></span>`;
|
|
/// What goes inside a chip box. The box itself is written out where it is used, id and all: the page
|
|
/// check finds ids by reading them, and one put together here would read as missing.
|
|
const CHIP_HINT='Add a word or phrase';
|
|
function chipsHTML(words: string[], input: string){
|
|
return `${words.map(chipHTML).join('')}<input type="text" class="chipin" id="${input}"
|
|
placeholder="${words.length?'':CHIP_HINT}" enterkeyhint="done" autocomplete="off" spellcheck="false">`;
|
|
}
|
|
/// The words in a chip box, with whatever is typed and not yet a chip.
|
|
function chipWords(box){
|
|
return [...$$('.chip',box).map(c=>c.dataset.w), ...splitWords($('.chipin',box).value)];
|
|
}
|
|
function wireChips(box){
|
|
const input=$('.chipin',box);
|
|
// The hint only in an empty box: beside chips it reads as one more.
|
|
const hint=()=>{ input.placeholder=$$('.chip',box).length?'':CHIP_HINT; };
|
|
const add=()=>{
|
|
const have=$$('.chip',box).map(c=>c.dataset.w.toLowerCase());
|
|
for(const w of splitWords(input.value)){
|
|
if(have.includes(w.toLowerCase())) continue;
|
|
input.insertAdjacentHTML('beforebegin',chipHTML(w)); have.push(w.toLowerCase());
|
|
}
|
|
input.value=''; hint();
|
|
};
|
|
input.onkeydown=e=>{
|
|
if(e.key==='Enter'||e.key===','){ e.preventDefault(); add(); }
|
|
else if(e.key==='Backspace'&&!input.value){ input.previousElementSibling?.remove(); hint(); }
|
|
};
|
|
// Typed and left without Enter is still meant: Save takes it either way.
|
|
input.onblur=add;
|
|
box.onclick=e=>{
|
|
const x=e.target.closest('.chipx');
|
|
if(x){ x.parentElement.remove(); hint(); }
|
|
input.focus();
|
|
};
|
|
}
|
|
|
|
/// Your settings for one feed, under its address (#167). What it says is what will happen, in this feed's numbers: the download limit was a bare
|
|
/// box whose blank, number and 0 were explained in bold small print.
|
|
function settingsModal(f, newUrl?: string){
|
|
const kids=S.feeds.filter(c=>c.group===f.id).length;
|
|
const isGroup=kids>0;
|
|
const parent=f.managed?S.feeds.find(p=>p.id===f.group):null;
|
|
const admin=!!(S.me&&S.me.admin);
|
|
const name=f.title||f.id;
|
|
// Blank follows the server's default; 0 is every item (#166).
|
|
let mode=f.max_new_per_check==null?'default':f.max_new_per_check===0?'all':'newest';
|
|
let newest=f.max_new_per_check>0?f.max_new_per_check:(globalMax||3);
|
|
const whose=isGroup
|
|
? `Your settings for the ${plural(kids,'show')} in this ${isPatreon(f)?'Patreon creator':'OPML subscription'}. A show you set on its own keeps its own.`
|
|
: parent
|
|
? `It comes from ${esc(parent.title||parent.id)}, and follows your settings there until you save these.`
|
|
: 'Your settings for this feed. Everyone else who reads it keeps their own.';
|
|
const sw=(input: string, text: string, cls='')=>`<label class="switch${cls}"><span>${text}</span><span class="sw">${input}<i></i></span></label>`;
|
|
openModal(`<div class="fsetbody">
|
|
<header class="fsethead">${artHTML(f.image,name)}<div><h3>${esc(name)}</h3><p>${whose}</p></div></header>
|
|
<div class="field faddr"><label for="surl">Address</label>
|
|
<div class="inline"><input type="text" id="surl" value="${esc(newUrl||f.url)}" spellcheck="false"${admin?'':' readonly'}>
|
|
<button type="button" class="btn ico" id="scopy" title="Copy the address" aria-label="Copy the address">${ICON.copy}</button></div>
|
|
${newUrl?'<p class="moved">The feed says it has moved here. Save to follow it.</p>':''}</div>
|
|
<div class="fdl">${sw(`<input type="checkbox" role="switch" id="sauto"${f.auto_download?' checked':''}>`,'Download automatically',' lead')}
|
|
<div class="fdeps${f.auto_download?'':' off'}" id="sdeps"><div><div class="fdepsin">
|
|
<div class="tabs seg" role="radiogroup" aria-label="How many to download">${
|
|
[['default',`Default (${globalMax||'all'})`],['newest','Newest'],['all','All']].map(([v,t])=>
|
|
`<label><input type="radio" name="swin" value="${v}"${mode===v?' checked':''}>${t}</label>`).join('')}</div>
|
|
<div class="epwin" id="sepwin" aria-hidden="true"></div>
|
|
<p class="wcap" id="swcap"></p>
|
|
<div class="ffield"><label class="flabel" for="skwin">Only items that mention</label>
|
|
<div class="chips" id="skw">${chipsHTML(f.keywords,'skwin')}</div>
|
|
<p class="cap">Any one of these will do, and a phrase needs all its words. Empty takes everything.</p></div>
|
|
${sw(`<input type="checkbox" role="switch" id="sexp"${f.allow_explicit?' checked':''}>`,'Include explicit items')}
|
|
</div></div></div>
|
|
<div class="ffield fdead">${sw(`<input type="checkbox" role="switch" id="sdead"${f.deadair?' checked':''}>`,'DeadAir',' lead')}
|
|
<p class="cap">Cuts the dead air: long silences in ${isGroup?'these shows':'this show'} shrink to a beat, so
|
|
episodes finish sooner and every voice keeps its own pace. Leave it off for stories whose pauses
|
|
are part of the telling.</p>
|
|
<div class="dpause">
|
|
<div class="dphead"><label class="flabel" for="sdpause">Breathing room</label><output id="sdpauseout" for="sdpause"></output></div>
|
|
<div class="dpslide"><input type="range" id="sdpause" min="200" max="500" step="100" value="${f.deadair_pause||300}"${f.deadair?'':' disabled'}></div>
|
|
<div class="dpends" aria-hidden="true"><span>Brisk</span><span>Relaxed</span></div>
|
|
<p class="cap">The silence kept where a long one is cut. More is easier on the ear; less saves more time.</p></div></div></div>
|
|
${isGroup?'':`<div class="ffield"><label class="flabel" for="sblockin">Hide items that mention</label>
|
|
<div class="chips" id="sblock">${chipsHTML(f.blocked||[],'sblockin')}</div>
|
|
<p class="cap">Whole words or phrases. Hidden from your list and not downloaded for you, along
|
|
with what Settings hides everywhere.</p></div>`}
|
|
</div>
|
|
<div class="cardacts fsetfoot"><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>`, false, 'fset');
|
|
|
|
// What only matters while it downloads on its own folds away, out of reach, when it does not.
|
|
const deps=$('#sdeps'), inner=$('.fdepsin',deps);
|
|
inner.inert=!f.auto_download;
|
|
$('#sauto').onchange=e=>{ deps.classList.toggle('off',!e.target.checked); inner.inert=!e.target.checked; };
|
|
|
|
// The download limit, said as what it does. A number in a box hid that it is a window on the
|
|
// newest items, not a pace through the back catalogue (#97).
|
|
const each=isGroup?' of each show':'';
|
|
const older=' Older ones stay listed, to download by hand.';
|
|
const caption=()=>mode==='newest'
|
|
? `Downloads the newest <input type="number" id="smax" min="1" value="${newest}"
|
|
aria-label="How many of the newest to download">${each} on its own.${older}`
|
|
: mode==='all'
|
|
? `Downloads every item${each}: the whole back catalogue${!isGroup&&f.entries?`, ${f.entries} so far`:''}, and each new one.`
|
|
: globalMax
|
|
? `The server's default: downloads the newest ${globalMax}${each} on its own.${older}`
|
|
: `The server's default: downloads every item${each}, the whole back catalogue.`;
|
|
const limit=()=>mode==='all'||(mode==='default'&&!globalMax)?Infinity:mode==='default'?globalMax:newest;
|
|
let bars=[];
|
|
const draw=(words: boolean)=>{
|
|
// The newest items with a file, as hold_back counts them: one whose files a filter all
|
|
// skipped holds no place.
|
|
let left=limit();
|
|
// Everything: the older ones past the strip are lit too, in words.
|
|
$('#sepwin')?.classList.toggle('all',left===Infinity);
|
|
for(const b of bars){
|
|
const on=!b.classList.contains('nofile')&&left>0;
|
|
if(on) left--;
|
|
b.classList.toggle('on',on);
|
|
}
|
|
if(!words) return;
|
|
$('#swcap').innerHTML=caption();
|
|
const n=$('#smax');
|
|
if(n) n.oninput=()=>{ newest=Math.max(1,Math.floor(Number(n.value))||1); draw(false); };
|
|
};
|
|
for(const r of $$('[name=swin]')) r.onchange=()=>{ mode=r.value; draw(true); };
|
|
// Breathing room only matters with DeadAir on: greyed out, not hidden, so it can be seen beforehand.
|
|
$('#sdead').onchange=e=>{ $('#sdpause').disabled=!e.target.checked; };
|
|
const pause=$('#sdpause'), pauseText=()=>{ const s=`${pause.value/1000} s`; $('#sdpauseout').textContent=s; pause.setAttribute('aria-valuetext',s); };
|
|
pause.oninput=pauseText; pauseText();
|
|
draw(true);
|
|
// The feed's own newest items, each as tall as it is long, so the strip reads as this show.
|
|
if(!isGroup) api(`/api/feeds/${encodeURIComponent(f.id)}/entries?limit=40`).then(page=>{
|
|
const items=page?.entries||[];
|
|
const strip=$('#sepwin'); if(!items.length||!strip) return;
|
|
const longest=Math.max(1,...items.map(e=>e.duration||0));
|
|
strip.innerHTML=items.map((e,i)=>{
|
|
const file=e.enclosures.some(x=>x.state!=='skipped');
|
|
const h=e.duration?Math.round(30+70*Math.sqrt(e.duration/longest)):60;
|
|
return `<i class="${file?'':'nofile'}" style="--h:${h}%;--n:${i}"></i>`;
|
|
}).join('')+(f.entries>items.length?`<small>+${f.entries-items.length} older</small>`:'');
|
|
bars=$$('i',strip);
|
|
draw(false);
|
|
// Drag along it to set how many: as far as the finger, the newest that far down.
|
|
const pick=(x: number)=>{
|
|
let n=0;
|
|
for(const b of bars){
|
|
if(!b.classList.contains('nofile')) n++;
|
|
if(x<=b.getBoundingClientRect().right) break;
|
|
}
|
|
newest=Math.max(1,n);
|
|
if(mode!=='newest'){ mode='newest'; $('[name=swin][value=newest]').checked=true; draw(true); }
|
|
else { $('#smax').value=String(newest); draw(false); }
|
|
};
|
|
strip.onpointerdown=e=>{ strip.setPointerCapture(e.pointerId); pick(e.clientX); };
|
|
strip.onpointermove=e=>{ if(strip.hasPointerCapture(e.pointerId)) pick(e.clientX); };
|
|
}).catch(()=>{});
|
|
|
|
wireChips($('#skw'));
|
|
if($('#sblock')) wireChips($('#sblock'));
|
|
$('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy'));
|
|
// Opened to follow a feed that moved: the new address is what there is to look at.
|
|
if(newUrl){ $('#surl').scrollIntoView({block:'center'}); $('#surl').focus(); }
|
|
|
|
$('#ssave').onclick=async()=>{
|
|
try{
|
|
const patch: Record<string, unknown>={
|
|
keywords:chipWords($('#skw')),
|
|
max_new_per_check:mode==='default'?null:mode==='all'?0:newest,
|
|
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked, deadair:$('#sdead').checked,
|
|
deadair_pause:Number($('#sdpause').value)};
|
|
if($('#sblock')) patch.blocked=chipWords($('#sblock'));
|
|
// The address is the same for everyone: an admin's to change, for a feed that moved, and the
|
|
// API refuses it from anyone else. The folder and category are not this dialog's at all.
|
|
if(admin) patch.url=$('#surl').value.trim();
|
|
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify(patch)});
|
|
closeModal(); toast('Saved. It applies from the next check.');
|
|
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);
|
|
|