/* ---------------- 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=`
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)+
`
`+
// 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?`
`:'');
// 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||'')+
`
`;
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.
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=>`