/* ---------------- modals ---------------- */ function openModal(html: string, wide?: boolean){ $('#modalCard').innerHTML=html; $('#modalCard').classList.toggle('wide',!!wide); $('#modal').classList.add('on'); } function closeModal(){ $('#modal').classList.remove('on'); } $('#modal').onclick=e=>{ if(e.target.id==='modal') closeModal(); }; $('#addFeed').onclick=()=>{ openModal(`

Add a feed

A Patreon token on its own adds every show from that creator.
Only items matching a keyword are downloaded.
`); $('#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='

Nothing yet. Feeds people here subscribe to show up here.

'; /// 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?`${rank}`:'')+artHTML(p.image,NAME(p))+ `
${esc(NAME(p))}${meta?`${esc(meta)}`:''}
`+ // Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one. (p.subscribed?`${ICON.subbed}` :``); 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)=>``; // back: where its button goes, '' for the front; none on the front itself. const head=(title,sub,back?)=>`
${back==null?'': ``}

${esc(title)}

${both?`
${ Object.keys(KINDS).map(k=>btn('kind',k,k,k===dirKind)).join('')}
`:''}

${esc(sub)}

`; const sec=(title,body,extra='')=>`
${title?`

${esc(title)}

${extra}
`:''}${body}
`; const tools=(pills='')=>`
${pills||''}
`; const q=S.q.trim(), grid='
', board='
'; let html, chart=[], tiles=null; const show=dirShow&&dirRows.find(p=>p.id===dirShow); if(show){ const n=show.subscribers; html=`
${artHTML(show.image,NAME(show))}

${esc(NAME(show))}

${ // Its category's page is one click away from here. show.category?``:''}

${n?`${n} ${n===1?'person here subscribes':'people here subscribe'}`:'Nobody here subscribes yet'}

` +sec(show.podcast?'Latest episodes':'Latest posts','

Loading…

'); }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(); 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',`
${order.map(c=>catTile(c,catName(c),count(cats.get(c).length),cats.get(c))).join('')}
`, ``); }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)}${n}`,(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?`
${ pill('','All',inCat.length)+subs.map(s=>pill(s,s,inCat.filter(p=>p.subcategory===s).length)).join('')}
`:'')+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('[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=`

${esc(e.message)}

`; return; } // Gone back, or on to another, while it loaded. if(dirShow!==p.id||!list.isConnected) return; // Some feeds give their title again as their description, which says nothing new. const said=plainText(got.description); if(said&&said!==NAME(p)){ const text=$('p',about), more=$('button',about); text.textContent=said; about.hidden=false; // Only when there is more than the three lines shown. more.hidden=text.scrollHeight<=text.clientHeight+1; more.onclick=()=>{ const open=about.classList.toggle('open'); more.textContent=open?'Less':'More'; more.setAttribute('aria-expanded',String(open)); }; } const items=got.items; list.innerHTML=items.length?items.map(e=>{ const name=entryName(e), text=name.derived?'':plainText(e.description).slice(0,500); const when=[dateOf(e.published),e.duration&&clock(e.duration)].filter(Boolean); return `
${e.link?`${esc(name.text)}`:esc(name.text)}` +(text?`

${esc(text)}

`:'') +(when.length?`${when.map(s=>`${s}`).join('')}`:'')+'
'; }).join(''):`

Nothing read from it yet. A feed nobody here subscribes to is checked once a day.

`; } /// 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 ``; } /// 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='

Loading…

'; $('#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=`
${v.icon}

${v.title}

${v.blurb}

Loading…

`; $('#count').textContent=v.title; const n=await renderListening(v.url,$('#listening',box)); if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,'episode')}`; } /// Currently Listening: episodes you started and have not finished, across every feed you /// subscribe to. A row resumes the episode in the player bar on click -- a shortcut back to /// where you left off, not another way to browse. The one in the player pauses instead. async function renderListening(url,box){ let rows=[]; try{ rows=(await api(url)).entries||[]; }catch{} box.innerHTML=rows.length?'':'

Nothing in progress. Episodes you start and do not finish show up here.

'; 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||'')+ `
${EQ}${esc(entryName(e).text)}`+ `${esc(feedName(e.feed_id))}
`+ ``+ ``+ `
`; el.onclick=ev=>(ev.target as Element).closest('[data-a=remove]')?forget(e) :el.classList.contains('now')&&!audio.paused?audio.pause():play(e); paintListenRow(el); box.appendChild(el); } return rows.length; } /// One row's time left, progress and play button, taken from the player when it is the one in it. function paintListenRow(el){ const e=el.entry, now=player.guid===e.guid&&player.feed===e.feed_id; // Zero until the player has sought to where you left off; the saved position stands till then. if(now&&audio.currentTime) e.position=Math.floor(audio.currentTime); // The player's own length first: a feed's can be minutes out. const d=(now&&isFinite(audio.duration)&&Math.floor(audio.duration))||e.duration; el.classList.toggle('now',now); $('.left',el).textContent=d?`${clock(d-e.position)} left`:`${clock(e.position)} in`; // With no length there is nothing to show, and an empty rail reads as a heavy border. const rail=$('.rail',el); rail.hidden=!d; $('i',rail).style.width=`${d?Math.min(100,e.position/d*100):0}%`; const b=$('[data-a=play]',el), label=now&&!audio.paused?'Pause':'Resume'; if(b.title!==label){ b.title=label; b.setAttribute('aria-label',label); b.innerHTML=label==='Pause'?ICON.pause:ICON.play; } } /// Keeps the list in step with the player. Only a row that is, or was, the one in it changes. function syncListening(){ for(const el of $$('#listening .childrow')) if(el.entry&&(el.classList.contains('now')||player.guid===el.entry.guid)) paintListenRow(el); } /// Takes an episode off Currently Listening by forgetting where you got to: the list is every /// episode with a saved position short of the end, so the position is what has to go. async function forget(e){ // Closed without saving first, or the player's next save would put it straight back. if(player.guid===e.guid){ player.guid=null; $('#pclose').click(); } try{ await api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/position`, {method:'POST',body:JSON.stringify({secs:0})}); }catch(err){ toast(err.message,true); } if(S.feed===':listening') renderListed(VIEWS[':listening']); } // The toolbar acts on whatever is selected: the feed on the left, the item in the table. $('#tbRemove').onclick=()=>{ const f=S.feeds.find(x=>x.id===S.feed); if(f) removeFeed(f); }; $('#tbPlay').onclick=()=>{ const e=cur(); if(e) play(e); }; $('#tbRead').onclick=()=>{ const e=cur(); if(e) epAction('read',e,null); }; $('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); }; let searchT; $('#epSearch').oninput=ev=>{ clearTimeout(searchT); // The Directory looks through what it already has; the rest ask the server (#126). searchT=setTimeout(()=>{ S.q=ev.target.value; S.offset=0; if(S.feed===':directory'){ dirShow=null; drawDirectory(); } else loadEntries(); },250); }; // Crossing the phone breakpoint moves the files between their pane and the text. window.matchMedia?.('(max-width:820px)')?.addEventListener?.('change',()=>{ const e=cur(); if(e) showDetail(e); }); let expanded = new Set(JSON.parse(localStorage.getItem('ipx.expanded')||'[]')); function toggleGroup(id){ expanded.has(id) ? expanded.delete(id) : expanded.add(id); try{ localStorage.setItem('ipx.expanded', JSON.stringify([...expanded])); }catch{} renderFeeds(); } let globalMax = 3; function due(ts){ const d = ts - Date.now()/1000; if(d <= 0) return 'due now'; if(d < 3600) return 'in '+Math.max(1,Math.round(d/60))+'m'; if(d < 86400) return 'in '+Math.round(d/3600)+'h'; return 'in '+Math.round(d/86400)+'d'; } /// Your settings: the theme, your subscriptions as OPML, and, to read, what the server does /// with feeds. The server's own settings, the accounts and the log are on /admin, which only an /// admin is sent (issue #19); this used to hold them too, shown to admins only. async function prefsModal(){ const g = await api('/api/settings'); const admin = !!(S.me&&S.me.admin); openModal(`

Settings

Auto follows your system's light/dark setting.
${ICON.save} Export
Export saves your subscriptions as OPML for another podcast app. Import subscribes you to every feed in one.
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.
A token lets a script or an agent use iPX as you, adding and removing your feeds and reading your items: it sends Authorization: Bearer and the token. Anyone holding one is you here, so revoke one you no longer use.
${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 admin page.':'Only an admin changes this.'}
`); $('#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',`
Your new token, ${esc(t.name)}. Copy it now: it is not shown again.
`); $('#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=>`
${esc(t.name)} made ${dateOf(t.created)}, last used ${ago(t.last_used)}
`).join(''); for(const b of $$('[data-tok]',box)) b.onclick=async()=>{ try{ await api(`/api/tokens/${b.dataset.tok}`,{method:'DELETE'}); toast('Revoked'); drawTokens(); } catch(err){ toast(err.message,true); } }; } const splitWords=(s: string)=>s.split(',').map(w=>w.trim()).filter(Boolean); function settingsModal(f, newUrl?: string){ const isGroup = S.feeds.some(c=>c.group===f.id); openModal(`

${esc(f.title||f.id)}

${isGroup?`

This is ${isPatreon(f)?'a Patreon creator':'an OPML subscription'}. These settings apply to it and are inherited by every feed inside it.

`:''} ${f.managed?`

This feed comes from ${isPatreon(S.feeds.find(p=>p.id===f.group))?'a Patreon creator':'an OPML subscription'} and follows its settings. Saving anything here gives it its own entry in config.toml, and it stops following the subscription's settings.

`:''}

These are your settings for this feed. Everyone else keeps their own.

Comma separated. Empty takes everything.
${isGroup?'':`
Comma separated words or phrases. An item with one in its title or text is hidden from you and not downloaded for you, as well as those your Settings hide everywhere.
`}
Blank follows the global default (${globalMax}). Older episodes stay listed to download by hand. 0 means every episode, the whole back catalogue.
${S.me&&S.me.admin ? `Shared with everyone reading this feed. Editing it keeps every item and download — handy when an auth token in the URL is rotated. The feed is re-checked from scratch on the next scan.` : `The same for everyone reading this feed, so only an admin can change it.`}
${S.me&&S.me.admin?`
Where the files land. There is one copy however many people subscribe, so this is the same for everyone.
`:''} ${S.me&&S.me.admin?`
${f.feed_category ? `The feed names its own, ${esc(f.feed_category)}, and the Directory uses that.` : `The feed names none, so the Directory files it under this. Pick one already listed where it fits.`}
`:''}
`); $('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy')); // Offer the categories the Directory already shows, so a blog about games joins Games rather // than starting a second chip beside it. if($('#scats')) api('/api/directory').then(rows=>{ $('#scats').innerHTML=[...new Set((rows||[]) .flatMap(p=>[p.category,p.subcategory]).filter(Boolean))].sort().map(c=>`