/* ---------------- 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'); if(logTimer){ clearInterval(logTimer); logTimer=null; } } /* ---------------- log view ---------------- */ let logTimer=null, logSeq=0, logLines=[], logFilter='', logLevel='', logTab='all'; // Which sources belong to each tab. "daemon" is the control protocol itself: every // command in and every event out, whatever sent it. const LOG_TABS={ all: null, daemon: t=>t==='ipx::io', scan: t=>t==='ipx::scan', web: t=>t==='ipx::http', }; const LEVELS={ERROR:3,WARN:2,INFO:1,DEBUG:0,TRACE:0}; function logsModal(){ logSeq=0; logLines=[]; openModal(`

Log

${Object.keys(LOG_TABS).map(t=> ``).join('')}

Loading…

Daemon I/O is the control protocol itself — every command in and every event out. Scans is feed and download activity, HTTP is web requests. The buffer keeps debug detail even when the terminal does not; IPX_UI_LOG changes what it captures.`, true); $$('#logtabs button').forEach(b=>b.onclick=()=>{ logTab=b.dataset.t; $$('#logtabs button').forEach(x=>x.classList.toggle('on',x.dataset.t===logTab)); drawLog(); }); $('#loglevel').onchange=e=>{ logLevel=e.target.value; drawLog(); }; $('#logq').oninput=e=>{ logFilter=e.target.value.toLowerCase(); drawLog(); }; $('#logcopy').onclick=()=>copyText(visibleLog().map(l=> `${new Date(l.ts*1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $('#logcopy')); pollLog(); logTimer=setInterval(pollLog,2000); } async function pollLog(){ try{ const r=await api(`/api/logs?after=${logSeq}&limit=500`); if(r.lines.length){ logLines=logLines.concat(r.lines).slice(-2000); logSeq=r.latest; drawLog(); }else if(!logLines.length){ drawLog(); } }catch(e){ const box=$('#logbox'); if(box) box.innerHTML=`

Lost contact with the daemon: ${esc(e.message)}

`; } } function visibleLog(){ const min=logLevel?LEVELS[logLevel]:-1; const tab=LOG_TABS[logTab]; return logLines.filter(l=> (!tab || tab(l.target)) && (LEVELS[l.level]??1)>=min && (!logFilter || (l.msg+' '+l.target).toLowerCase().includes(logFilter))); } function drawLog(){ const box=$('#logbox'); if(!box) return; const follow=$('#logfollow')?.checked; const rows=visibleLog(); box.innerHTML = rows.length ? rows.map(l=>{ const t=new Date(l.ts*1000).toLocaleTimeString(); if(logTab==='daemon'){ const out=l.msg.startsWith('<-'); return `
`+ `${out?'out':'in'}`+ `${esc(l.msg.replace(/^[<-]+\s*/,''))}
`; } return `
${esc(l.level)}`+ `${esc(l.target.replace(/^ipx::?/,''))}${esc(l.msg)}
`; }).join('') : '

Nothing matches.

'; if(follow) box.scrollTop=box.scrollHeight; } $('#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; } }; }; // What everyone here reads, you included, as a place to start. 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.

'; async function listFeeds(url,box){ let rows=[]; try{ rows=await api(url)||[]; }catch{} box.innerHTML=rows.length?'':NONE_LISTED; for(const p of rows) box.appendChild(listedFeed(p,'childrow')); return rows.length; } /// One listed feed: a row in Popular and the Add a feed dialog, a tile in Directory's grid. The /// parts are the same either way; the class lays them out. function listedFeed(p,cls){ const el=document.createElement('div'); el.className=cls; el.innerHTML=artHTML(p.image,p.title||p.id)+ `
${esc(p.title||p.id)}`+ `${p.subscribers} subscriber${p.subscribers===1?'':'s'}
`+ // Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one. (p.subscribed?`${ICON.subbed}` :``); // Yours already: the row opens it instead. if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; return el; } $('[data-a="sub"]',el).onclick=async()=>{ try{ await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'}); closeModal(); toast(`Subscribed to ${p.title||p.id}`); await loadFeeds(true); selectFeed(p.id); }catch(e){ toast(e.message,true); } }; return el; } // Directory's filters. Kept out here because a finished scan redraws the pane, which would // otherwise clear them. let dirKind='All', dirCat=null; const KINDS={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast}; /// Directory: every listed feed as its cover art, under two filters that combine: what a feed is /// (Podcasts, anything with audio or video, or Blogs, the rest) and what it is about (its iTunes /// category, as chips). Both filter in place, without asking the server again. async function renderDirectory(url,box){ let rows=[]; try{ rows=await api(url)||[]; }catch{} if(!rows.length){ box.innerHTML=NONE_LISTED; return 0; } const bar=$('#dirbar'); // Only a filter when the server has both kinds. const both=rows.some(KINDS.Podcasts)&&rows.some(KINDS.Blogs); const btn=(k,v,on)=>``; const draw=()=>{ if(!both) dirKind='All'; const ofKind=rows.filter(KINDS[dirKind]); // No empty chips: only the categories among the feeds the kind lets through. const cats=[...new Set(ofKind.map(p=>p.category).filter(Boolean))].sort(); if(!cats.includes(dirCat)) dirCat=null; bar.innerHTML= (both?`
${Object.keys(KINDS).map(k=>btn('kind',k,k===dirKind)).join('')}
`:'')+ (cats.length?`
${cats.map(c=>btn('cat',c,c===dirCat)).join('')}
`:''); // A picked chip lifts on a second press. Everything is redrawn, so the keyboard goes back to // the button just pressed. for(const b of $$('button',bar)) b.onclick=()=>{ const k=b.dataset.kind!=null?'kind':'cat', v=b.dataset[k]; if(k==='kind') dirKind=v; else dirCat=dirCat===v?null:v; draw(); $(`[data-${k}="${CSS.escape(v)}"]`,bar)?.focus(); }; box.innerHTML=''; for(const p of ofKind.filter(p=>!dirCat||p.category===dirCat)) box.appendChild(listedFeed(p,'tile')); }; draw(); return rows.length; } /// Directory and Popular 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); $('#epSearch').placeholder='Search items…'; const listening=v===VIEWS[':listening'], grid=v===VIEWS[':directory']; box.innerHTML=`
${v.icon}

${v.title}

${v.blurb}${listening?'':' Everyone counts, you included. Private feeds are never listed.'}
${grid?'
':''}

Loading…

`; $('#count').textContent=v.title; const n=await (listening?renderListening:grid?renderDirectory:listFeeds)(v.url,$(listening?'#listening':'#popular',box)); if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,listening?'episode':'feed')}`; } /// 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(e.title||'(untitled)')}`+ `${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); searchT=setTimeout(()=>{ S.q=ev.target.value; S.offset=0; 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; const UNITS = [['m','minutes'],['h','hours'],['d','days'],['w','weeks']]; const UNIT_MINS = {m:1, h:60, d:1440, w:10080}; /// Largest unit that divides evenly, so 120 reads "2 hours" not "120 minutes". function splitEvery(m){ if(!m) return {n:1, u:'h'}; for(const u of ['w','d','h']) if(m % UNIT_MINS[u] === 0) return {n:m/UNIT_MINS[u], u}; return {n:m, u:'m'}; } function unitOptions(sel){ return UNITS.map(([v,l]) => ``).join(''); } function everyText(m){ if(!m) return '\u2014'; const {n,u} = splitEvery(m); const name = {m:'min', h:'hour', d:'day', w:'week'}[u]; return n + ' ' + name + (u!=='m' && n!==1 ? 's' : ''); } 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'; } /// Global settings. GET /api/settings is open to anyone signed in; only the PATCH, and the /// Users screen behind it, are the operator's alone (the server refuses both from anyone /// else). A non-admin gets the same modal minus those two parts, not no settings at all -- /// Export/Import are theirs regardless, and seeing the schedule and quota explains why a /// feed is polled when it is. async function prefsModal(){ const g = await api('/api/settings'); const gs = splitEvery(g.every_mins); const admin = !!(S.me&&S.me.admin); openModal(`

Settings

Auto follows your system's light/dark setting.
${admin?`
Applies to every feed that does not set its own. A feed's suggested interval (its ttl) is still honoured when it asks to be polled less often.` :`${everyText(g.every_mins)}, for every feed that does not set its own. Only an admin changes this.`}
${admin?`
Applies to any feed that does not set its own — including every feed inside an OPML subscription. 0 means unlimited, which will pull a whole back catalogue the first time a feed is scanned.
Anything else is still listed and can be downloaded by hand — blog feeds put article images in enclosures, and those are not worth keeping. Empty takes everything.
Over this, the oldest played items are deleted first. Pinned items are never touched.
`:''}
${esc(g.download_dir)}
${ICON.save} Export
Export saves your subscriptions as OPML for another podcast app. Import subscribes you to every feed in one.
${admin?`
Add and remove the people who can sign in, and choose who is an admin.
`:''}
${admin?``:''}
`); $('#stheme').onchange=e=>setTheme(e.target.value); $('#smode').onchange=e=>setTheme(undefined,e.target.value); $('#gopml').onclick=opmlModal; if(!admin) return; $('#gusers').onclick=usersModal; $('#gsave').onclick=async()=>{ try{ await api('/api/settings',{method:'PATCH',body:JSON.stringify({ schedule:`every ${Math.max(1,Number($('#gnum').value)||1)}${$('#gunit').value}`, max_new_per_check:Math.max(0,Number($('#gmax').value)||0), media_types:$('#gtypes').value.split(',').map(t=>t.trim()).filter(Boolean), max_total_gb:Number($('#gquota').value)||0, max_age_days:Number($('#gage').value)||0})}); closeModal(); toast('Settings saved'); await loadFeeds(true); if(S.feed){ renderFeed(); loadEntries(); } }catch(e){ toast(e.message,true); } }; } // Admin only, and the server enforces that: this screen is just the way in. async function usersModal(){ const users = await api('/api/users') || []; openModal(`

Users

${users.map(u=>`
${esc(u.name)} ${u.created?`Added ${dateOf(u.created)}`:'Added before this was kept'} · ${ u.last_login?`signed in ${ago(u.last_login)}`:'never signed in'}
${u.password?'':'Proxy'}
`).join('')}
At least 8 characters. Leave the password empty for someone who signs in through the proxy. New people start with no feeds.
`); const change=async(u,opts)=>{ try{ await api(`/api/users/${u.id}`,opts); // Demoting yourself takes this screen away; reload so the page stops offering it. Only // on success: reloading after a refusal wiped the toast that said why. if(u.name===S.me?.name){ location.reload(); return; } }catch(e){ toast(e.message,true); } usersModal(); // on a refusal, this puts the checkbox back where the server left it }; $$('#modalCard [data-id]').forEach(row=>{ const u=users.find(x=>String(x.id)===row.dataset.id); $('[data-a="admin"]',row).onchange=e=> change(u,{method:'PATCH',body:JSON.stringify({admin:e.target.checked})}); $('[data-a="rm"]',row).onclick=()=>{ if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`)) change(u,{method:'DELETE'}); }; }); $('#uadd').onclick=async()=>{ try{ await api('/api/users',{method:'POST',body:JSON.stringify({ name:$('#uname').value, password:$('#upass').value, admin:$('#uadmin').checked})}); toast('Added'); usersModal(); }catch(e){ toast(e.message,true); } // keep what was typed }; } 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.
Blank follows the global default (${globalMax}). The rest wait for the next scan.
${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||[]) .map(p=>p.category).filter(Boolean))].sort().map(c=>`