808 lines
51 KiB
TypeScript
808 lines
51 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: MouseEvent)=>{ if((e.target as Element).id==='modal') closeModal(); };
|
||
|
||
$<HTMLButtonElement>('#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>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>`);
|
||
$<HTMLInputElement>('#nurl').focus();
|
||
$<HTMLButtonElement>('#nsave').onclick=async()=>{
|
||
const url=$<HTMLInputElement>('#nurl').value.trim(); if(!url) return;
|
||
$<HTMLButtonElement>('#nsave').disabled=true; $<HTMLButtonElement>('#nsave').title='Adding…';
|
||
try{
|
||
const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({
|
||
url, allow_explicit:$<HTMLInputElement>('#nexp').checked,
|
||
keywords:$<HTMLInputElement>('#nkw').value.split(',').map((s: string)=>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(why(e),true); $<HTMLButtonElement>('#nsave').title='Add feed'; $<HTMLButtonElement>('#nsave').disabled=false; }
|
||
};
|
||
};
|
||
|
||
// The rows come from the directory through iPX (#205), and carry its id, never a URL, so a key in
|
||
// someone's feed address never reaches this page.
|
||
const NONE_LISTED='<p class="hint">Nothing in the directory yet. Feeds people subscribe to show up here once their servers share them.</p>';
|
||
// Why the directory could not be shown: none set up, or it did not answer. In iPX's words.
|
||
let dirError='';
|
||
|
||
/// 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: DirFeed,cls: string,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>`);
|
||
// Yours has an id here as well as the directory's, and opens by it.
|
||
if(p.subscribed){ el.onclick=()=>selectFeed(p.feed||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: Event)=>{ ev.stopPropagation(); subscribeListed(p); };
|
||
return el;
|
||
}
|
||
|
||
async function subscribeListed(p: DirFeed){
|
||
try{
|
||
const r=await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
|
||
toast(`Subscribed to ${NAME(p)}`);
|
||
await loadFeeds(true); selectFeed(r.id);
|
||
}catch(e){ toast(why(e),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: string|null=null, dirSub: string|null=null, dirSort='az', dirRows: DirFeed[]=[];
|
||
// The feed whose page is open over all that, by id; going back leaves the rest as it was.
|
||
let dirShow: string|null=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: {title: string|null, id: string})=>(p.title||p.id);
|
||
const byName=(a: DirFeed,b: DirFeed)=>NAME(a).localeCompare(NAME(b),undefined,{sensitivity:'base',numeric:true});
|
||
const bySubs=(a: DirFeed,b: DirFeed)=>b.subscribers-a.subscribers||byName(a,b);
|
||
/// The grid's orders. It opens A to Z, as the server sends it.
|
||
const DIR_SORTS: Record<string,[string,(a: DirFeed,b: DirFeed)=>number]>={
|
||
subscribers:['Most subscribers',bySubs],
|
||
az:['Name, A to Z',byName],
|
||
za:['Name, Z to A',(a: DirFeed,b: DirFeed)=>byName(b,a)],
|
||
};
|
||
const KINDS: Record<string,(p: DirFeed)=>boolean>={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast};
|
||
const KIND_WORD: Record<string,string>={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: string,box: HTMLElement,counted: (n: number)=>void){
|
||
// Each visit starts at the front, as going back to a tab does.
|
||
dirCat=null; dirSub=null; dirShow=null;
|
||
dirError='';
|
||
// Drawn at once from what was kept or fetched ahead, then again only if the catalogue changed
|
||
// (#248); with nothing kept, a spinner until it comes.
|
||
const kept=pages.get(url);
|
||
if(kept){ dirRows=kept; drawDirectory(box); counted(dirRows.length); }
|
||
else listLoading(box);
|
||
try{
|
||
const fresh=await fetchPage(url)||[];
|
||
if(kept&&JSON.stringify(kept)===JSON.stringify(fresh)) return;
|
||
if(!box.isConnected) return;
|
||
dirRows=fresh;
|
||
}catch(e){ if(kept) return; dirRows=[]; dirError=why(e); }
|
||
drawDirectory(box);
|
||
counted(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=dirError?`<p class="hint">${esc(dirError)}</p>`: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: number)=>`${n.toLocaleString()} ${word}${n===1?'':'s'}`;
|
||
const of=(c: string)=>(p: DirFeed)=>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: string)=>c===EVERY?`All ${word}s`:c===OTHER?'Other':c;
|
||
const top=(list: DirFeed[])=>list.filter(p=>p.subscribers).sort(bySubs).slice(0,10);
|
||
const btn=(k: string,v: string,label: string,on: boolean)=>`<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: string,sub: string,back?: string)=>`<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: string,body: string,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: string, chart: DirFeed[]=[], tiles: DirFeed[]|null=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 subscribes':'people 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,DirFeed[]>();
|
||
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)} shared by iPX servers. 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((s): s is string=>!!s))].sort();
|
||
if(!dirSub||!subs.includes(dirSub)) dirSub=null;
|
||
if(dirCat!==EVERY) chart=top(inCat);
|
||
tiles=inCat.filter(p=>!dirSub||p.subcategory===dirSub);
|
||
const pill=(v: string,label: string,n: number)=>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: DirFeed,i: number)=>c.appendChild(listedFeed(p,'rank',i+1))); }
|
||
const t=$('#dtiles',box);
|
||
if(t&&tiles) for(const p of tiles.sort(DIR_SORTS[dirSort][1])) t.appendChild(listedFeed(p,'tile'));
|
||
box.onclick=(ev: MouseEvent)=>{
|
||
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=''; $<HTMLInputElement>('#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=$<HTMLSelectElement>('#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: HTMLElement,p: DirFeed){
|
||
const list=$('#dshow',box), about=$('#dabout',box);
|
||
let got: {description: string|null, failing: string|null, items: ListedItem[], can_admin: boolean};
|
||
try{ got=await api(`/api/directory/${encodeURIComponent(p.id)}`); }
|
||
catch(e){ list.innerHTML=`<p class="hint">${esc(why(e))}</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; }
|
||
// An admin whose token may administer the directory (#206); once, as Refresh fills it again.
|
||
const acts=$('.show .acts',box);
|
||
if(got.can_admin&&!$('[data-a="dremove"]',acts)){
|
||
acts.insertAdjacentHTML('beforeend',
|
||
`<button class="btn ico" data-a="drefresh" title="Check this feed now" aria-label="Check this feed now">${ICON.scan}</button>`
|
||
+`<button class="btn ico danger" data-a="dremove" title="Remove from the directory" aria-label="Remove from the directory">${ICON.trash}</button>`);
|
||
$('[data-a="drefresh"]',acts).onclick=async()=>{
|
||
try{ await api(`/api/directory/${encodeURIComponent(p.id)}/refresh`,{method:'POST'}); toast('Checked'); fillShow(box,p); }
|
||
catch(e){ toast(why(e),true); }
|
||
};
|
||
$('[data-a="dremove"]',acts).onclick=async()=>{
|
||
if(!confirm(`Remove ${NAME(p)} from the directory? It is blocked, so no server's snapshot brings it back. Admin, Directory can unblock it.`)) return;
|
||
try{ await api(`/api/directory/${encodeURIComponent(p.id)}`,{method:'DELETE'}); }
|
||
catch(e){ return toast(why(e),true); }
|
||
toast(`Removed ${NAME(p)} from the directory`);
|
||
dirRows=dirRows.filter(r=>r.id!==p.id); dirShow=null;
|
||
drawDirectory(box);
|
||
};
|
||
}
|
||
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. The directory checks each feed every few hours.</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: string,name: string,count: string,rows: DirFeed[]){
|
||
// 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: {title: string, icon: string, url?: string, blurb?: string}){
|
||
const box=$('#content');
|
||
box.classList.add('plain');
|
||
$<HTMLButtonElement>('#tbRemove').disabled=true;
|
||
syncTools(null);
|
||
if(v===VIEWS[':directory']){
|
||
$<HTMLInputElement>('#epSearch').placeholder='Find a feed…';
|
||
box.innerHTML='<div id="directory"></div>';
|
||
$('#count').textContent=v.title;
|
||
await renderDirectory(v.url!,$('#directory'),n=>{ if(VIEWS[S.feed ?? '']===v) $('#count').textContent=`${v.title}: ${plural(n,'feed')}`; });
|
||
return;
|
||
}
|
||
$<HTMLInputElement>('#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"></div>`;
|
||
$('#count').textContent=v.title;
|
||
// The search box looks through these too, title and text, as it does a feed's items (#127).
|
||
await renderListening(S.q?`${v.url}&q=${encodeURIComponent(S.q)}`:v.url!,$('#listening',box),
|
||
n=>{ 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: string,box: HTMLElement,counted: (n: number)=>void){
|
||
// Drawn at once from what was kept or fetched ahead, then again only if it changed (#248): it
|
||
// took over a second to answer. With nothing kept, a spinner until it comes.
|
||
const kept: {entries?: Entry[]}|undefined=pages.get(url);
|
||
if(kept){ drawListening(box,kept.entries||[]); counted((kept.entries||[]).length); }
|
||
else listLoading(box);
|
||
let fresh: {entries?: Entry[]};
|
||
try{ fresh=await fetchPage(url); }catch{ if(kept) return; fresh={}; }
|
||
if(kept&&JSON.stringify(kept)===JSON.stringify(fresh)) return;
|
||
if(!box.isConnected) return;
|
||
drawListening(box,fresh.entries||[]);
|
||
counted((fresh.entries||[]).length);
|
||
}
|
||
function drawListening(box: HTMLElement,rows: Entry[]){
|
||
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?: Entry}=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);
|
||
}
|
||
}
|
||
|
||
/// One row's time left, progress and play button, taken from the player when it is the one in it.
|
||
function paintListenRow(el: HTMLElement & {entry?: Entry}){
|
||
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 $$<HTMLElement & {entry?: Entry}>('#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: Entry){
|
||
// Closed without saving first, or the player's next save would put it straight back.
|
||
if(player.guid===e.guid){ player.guid=null; $<HTMLButtonElement>('#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(why(err),true); }
|
||
// The kept list still has it; drawn from that, it would show again until the refresh came.
|
||
for(const k of [...pages.keys()]) if(k.startsWith(VIEWS[':listening'].url!)) pages.delete(k);
|
||
if(S.feed===':listening') renderListed(VIEWS[':listening']);
|
||
}
|
||
|
||
// The toolbar acts on whatever is selected: the feed on the left, the item in the table.
|
||
$<HTMLButtonElement>('#tbRemove').onclick=()=>{ const f=S.feeds.find(x=>x.id===S.feed); if(f) removeFeed(f); };
|
||
$<HTMLButtonElement>('#tbPlay').onclick=()=>{ const e=cur(); if(e) play(e); };
|
||
$<HTMLButtonElement>('#tbRead').onclick=()=>{ const e=cur(); if(e) epAction('read',e,null); };
|
||
$<HTMLButtonElement>('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); };
|
||
let searchT: number;
|
||
$<HTMLInputElement>('#epSearch').oninput=(ev: Event)=>{ 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 as HTMLInputElement).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: string){
|
||
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: number){
|
||
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: Me|null){
|
||
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"><label for="stskey">TypeSafe key</label>
|
||
<div class="inline"><input type="password" id="stskey" autocomplete="off" spellcheck="false"
|
||
placeholder="${S.me?.typesafe_key_set?'Saved. Paste a new one to replace it':'Paste your key'}">
|
||
<button class="btn ico" id="stsforget" title="Forget the key" aria-label="Forget the key"${S.me?.typesafe_key_set?'':' hidden'}>${ICON.trash}</button></div>
|
||
<span class="hint">Signals pick what matters out of everything you subscribe to, on your own
|
||
key from <a href="https://typesafe.ai" target="_blank" rel="noopener">typesafe.ai</a>.
|
||
The key is kept on this server and never shown again.</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>`);
|
||
$<HTMLSelectElement>('#stheme').onchange=(e: Event)=>setTheme((e.target as HTMLSelectElement).value,undefined,true);
|
||
$<HTMLSelectElement>('#smode').onchange=(e: Event)=>setTheme(undefined,(e.target as HTMLSelectElement).value,true);
|
||
$<HTMLButtonElement>('#gopml').onclick=opmlModal;
|
||
drawTokens();
|
||
$<HTMLButtonElement>('#tokadd').onclick=async()=>{
|
||
const name=$<HTMLInputElement>('#tokname').value.trim(); if(!name){ $<HTMLInputElement>('#tokname').focus(); return; }
|
||
try{
|
||
const t=await api('/api/tokens',{method:'POST',body:JSON.stringify({name})});
|
||
$<HTMLInputElement>('#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>`);
|
||
$<HTMLButtonElement>('#tokcopy').onclick=()=>copyText(t.token,$<HTMLButtonElement>('#tokcopy'));
|
||
$<HTMLInputElement>('#tokval').select();
|
||
}catch(err){ toast(why(err),true); }
|
||
};
|
||
const saveKey=async(typesafe_key: string|null)=>{
|
||
try{
|
||
await api('/api/me',{method:'PATCH',body:JSON.stringify({typesafe_key})});
|
||
if(S.me) S.me.typesafe_key_set=!!typesafe_key;
|
||
$<HTMLInputElement>('#stskey').value='';
|
||
$<HTMLInputElement>('#stskey').placeholder=typesafe_key?'Saved. Paste a new one to replace it':'Paste your key';
|
||
$<HTMLButtonElement>('#stsforget').hidden=!typesafe_key;
|
||
toast(typesafe_key?'Saved':'Forgotten');
|
||
}catch(err){ toast(why(err),true); }
|
||
};
|
||
$<HTMLInputElement>('#stskey').onchange=(e: Event)=>{ const k=(e.target as HTMLInputElement).value.trim(); if(k) saveKey(k); };
|
||
$<HTMLButtonElement>('#stsforget').onclick=()=>saveKey(null);
|
||
$<HTMLInputElement>('#sblock').onchange=async (e: Event)=>{
|
||
const blocked=splitWords((e.target as HTMLInputElement).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(why(err),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: {id: number, name: string, created: number, last_used: number|null}[]=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(why(err),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: ParentNode){
|
||
return [...$$('.chip',box).map(c=>c.dataset.w), ...splitWords($<HTMLInputElement>('.chipin',box).value)];
|
||
}
|
||
function wireChips(box: HTMLElement){
|
||
const input=$<HTMLInputElement>('.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: KeyboardEvent)=>{
|
||
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: MouseEvent)=>{
|
||
const x=(e.target as Element).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: Feed, 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&&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 class="ffield">${sw(`<input type="checkbox" role="switch" id="sprivate"${f.private||f.private_by_address?' checked':''}${
|
||
f.private_by_address||(f.private&&!admin)?' disabled':''}>`,'Private',' lead')}
|
||
<p class="cap">${f.private_by_address
|
||
?'Its address or login shows it is someone’s own, so it is never shared with the directory.'
|
||
:f.private&&!admin
|
||
?'Never shared with the directory. Only an admin can share it again.'
|
||
:`Never shared with the directory, for ${isGroup?'shows':'a show'} you pay for. iPX knows most paid feeds by their address; this is for the ones it does not.`}</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;
|
||
$<HTMLInputElement>('#sauto').onchange=(e: Event)=>{ deps.classList.toggle('off',!(e.target as HTMLInputElement).checked); inner.inert=!(e.target as HTMLInputElement).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: HTMLElement[]=[];
|
||
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=$<HTMLInputElement>('#smax');
|
||
if(n) n.oninput=()=>{ newest=Math.max(1,Math.floor(Number(n.value))||1); draw(false); };
|
||
};
|
||
for(const r of $$<HTMLInputElement>('[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.
|
||
$<HTMLInputElement>('#sdead').onchange=(e: Event)=>{ $<HTMLInputElement>('#sdpause').disabled=!(e.target as HTMLInputElement).checked; };
|
||
const pause=$<HTMLInputElement>('#sdpause'), pauseText=()=>{ const s=`${Number(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: Entry[]=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: Entry,i: number)=>{
|
||
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'; $<HTMLInputElement>('[name=swin][value=newest]').checked=true; draw(true); }
|
||
else { $<HTMLInputElement>('#smax').value=String(newest); draw(false); }
|
||
};
|
||
strip.onpointerdown=(e: PointerEvent)=>{ strip.setPointerCapture(e.pointerId); pick(e.clientX); };
|
||
strip.onpointermove=(e: PointerEvent)=>{ if(strip.hasPointerCapture(e.pointerId)) pick(e.clientX); };
|
||
}).catch(()=>{});
|
||
|
||
wireChips($('#skw'));
|
||
if($('#sblock')) wireChips($('#sblock'));
|
||
$<HTMLButtonElement>('#scopy').onclick=()=>copyText($<HTMLInputElement>('#surl').value,$<HTMLButtonElement>('#scopy'));
|
||
// Opened to follow a feed that moved: the new address is what there is to look at.
|
||
if(newUrl){ $<HTMLInputElement>('#surl').scrollIntoView({block:'center'}); $<HTMLInputElement>('#surl').focus(); }
|
||
|
||
$<HTMLButtonElement>('#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:$<HTMLInputElement>('#sauto').checked, allow_explicit:$<HTMLInputElement>('#sexp').checked, deadair:$<HTMLInputElement>('#sdead').checked,
|
||
deadair_pause:Number($<HTMLInputElement>('#sdpause').value)};
|
||
if($('#sblock')) patch.blocked=chipWords($('#sblock'));
|
||
// Sent only when it changed: anyone may mark a feed private, but only an admin unmark it.
|
||
const priv=$<HTMLInputElement>('#sprivate');
|
||
if(!priv.disabled&&priv.checked!==!!f.private) patch.private=priv.checked;
|
||
// 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=$<HTMLInputElement>('#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(why(e),true); }
|
||
};
|
||
}
|
||
|
||
function downloadLatestModal(f: Feed){
|
||
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>`);
|
||
$<HTMLButtonElement>('#dgo').onclick=async()=>{
|
||
const n=Number($<HTMLInputElement>('#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(why(e),true); }
|
||
};
|
||
}
|
||
|
||
function removeFeed(f: Feed){
|
||
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>`);
|
||
$<HTMLButtonElement>('#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>`);
|
||
$<HTMLButtonElement>('#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=$<HTMLInputElement>('#opmlFile'), file=pick.files![0];
|
||
const xml=file ? await file.text() : $<HTMLTextAreaElement>('#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(why(e),true); }
|
||
};
|
||
}
|
||
|
||
// No toast: the spinners on the rows being checked say it (issue #37).
|
||
async function scanAll(){
|
||
spinStart(null);
|
||
try{ await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
|
||
catch(e){ spinEnd(); throw e; }
|
||
}
|
||
$<HTMLButtonElement>('#scanAll').onclick=scanAll;
|
||
$<HTMLButtonElement>('#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.
|
||
$<HTMLButtonElement>('#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':'');
|
||
// ipx's version and the page's, each released on its own.
|
||
$('#applogo').title=`iPX ${u.version}, page __WEB_VERSION__`;
|
||
// Whether the link to Admin shows, now and before the first frame of the next visit.
|
||
document.documentElement.toggleAttribute('data-admin',!!u.admin);
|
||
try{ localStorage.setItem('ipx.admin',u.admin?'1':'0'); }catch{}
|
||
// A browser with no theme of its own takes the one the account kept from before, once.
|
||
if(u.theme&&!/(^|; )ipx_theme=/.test(document.cookie)) setTheme(u.theme.name,u.theme.mode,true);
|
||
}).catch(()=>{});
|
||
$<HTMLInputElement>('#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: KeyboardEvent)=>{
|
||
const row=(ev.target as Element).closest<HTMLElement>('[data-id]'); if(!row) return;
|
||
const id=row.dataset.id!;
|
||
if((ev.key==='Enter'||ev.key===' ')&&ev.target===row) row.click();
|
||
else if(row.matches('.group,.sigs')&&
|
||
(ev.key==='ArrowRight'&&!expanded.has(id)||ev.key==='ArrowLeft'&&expanded.has(id))) toggleGroup(id);
|
||
else return;
|
||
ev.preventDefault(); ev.stopPropagation();
|
||
};
|
||
$<HTMLButtonElement>('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
|
||
$('#scrim').onclick=()=>nav(false);
|
||
|