Each person has a list for every feed they read and one per feed. An item whose title or text holds one of the words, matched as whole words so "ai" does not hide everything that "said" anything, is hidden from them and, since the scanner now keeps each subscriber's filters separate, is fetched only if someone else still wants it. Whole-word matching is not something LIKE can do on both SQLite and Postgres, so the matches are worked out in Rust into a `hidden` table whenever a list changes, someone subscribes, or a scan brings in new items, and the queries only look that table up. Both new tables are tables rather than columns because create_missing adds tables but never columns. Hidden counts as read for the reaper and for "others still want this file", since whoever it is hidden from is as done with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
420 lines
24 KiB
TypeScript
420 lines
24 KiB
TypeScript
/* ---------------- 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(`<h3>Add a feed</h3>
|
|
<div class="field"><label>Feed URL</label><input type="text" id="nurl" placeholder="https://example.com/rss">
|
|
<span class="hint">A Patreon token on its own adds every show from that creator.</span></div>
|
|
<div class="field"><label>Folder (optional)</label><input type="text" id="nfolder" placeholder="Defaults to the feed title"></div>
|
|
<div class="field"><label>Keywords (optional, comma separated)</label>
|
|
<input type="text" id="nkw"><span class="hint">Only items matching a keyword are downloaded.</span></div>
|
|
<label class="check"><input type="checkbox" id="nexp"> Allow items marked explicit</label>
|
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
|
<button class="btn ico primary" id="nsave" title="Add feed" aria-label="Add feed">${ICON.plus}</button></div>`);
|
|
$('#nurl').focus();
|
|
$('#nsave').onclick=async()=>{
|
|
const url=$('#nurl').value.trim(); if(!url) return;
|
|
$('#nsave').disabled=true; $('#nsave').title='Adding…';
|
|
try{
|
|
const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({
|
|
url, folder:$('#nfolder').value.trim()||null, allow_explicit:$('#nexp').checked,
|
|
keywords:$('#nkw').value.split(',').map(s=>s.trim()).filter(Boolean)})});
|
|
closeModal(); toast(r.existing?`Already subscribed as ${r.id}`:`Added ${r.id}`);
|
|
await loadFeeds(true); selectFeed(r.id);
|
|
}catch(e){ toast(e.message,true); $('#nsave').title='Add feed'; $('#nsave').disabled=false; }
|
|
};
|
|
};
|
|
|
|
// 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='<p class="hint">Nothing yet. Feeds people here subscribe to show up here.</p>';
|
|
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)+
|
|
`<div class="txt"><b>${esc(p.title||p.id)}</b>`+
|
|
`<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</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.subbed}</button>`);
|
|
// 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)=>`<button type="button" data-${k}="${esc(v)}" class="${on?'on':''}" aria-pressed="${on}">${esc(v)}</button>`;
|
|
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?`<div class="tabs" role="group" aria-label="Kind">${Object.keys(KINDS).map(k=>btn('kind',k,k===dirKind)).join('')}</div>`:'')+
|
|
(cats.length?`<div class="chips" role="group" aria-label="Category">${cats.map(c=>btn('cat',c,c===dirCat)).join('')}</div>`:'');
|
|
// 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=`
|
|
<div class="fhead slim">
|
|
<div class="art">${v.icon}</div>
|
|
<div class="meta"><h2>${v.title}</h2>
|
|
<div class="sub">${v.blurb}${listening?'':' Everyone counts, you included. Private feeds are never listed.'}</div></div>
|
|
</div>
|
|
${grid?'<div class="dirbar" id="dirbar"></div>':''}
|
|
<div class="${grid?'tiles':'childlist'}" id="${listening?'listening':'popular'}"><p class="hint">Loading…</p></div>`;
|
|
$('#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?'':'<p class="hint">Nothing in progress. Episodes you start and do not finish show up here.</p>';
|
|
for(const e of rows){
|
|
// Carries its episode, for paintListenRow to repaint as the player moves.
|
|
const el: HTMLDivElement & {entry?: any}=document.createElement('div');
|
|
el.className='childrow';
|
|
el.entry=e;
|
|
el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+
|
|
`<div class="txt"><b>${EQ}<span>${esc(e.title||'(untitled)')}</span></b>`+
|
|
`<small><span class="fd">${esc(feedName(e.feed_id))}</span><span class="left"></span></small></div>`+
|
|
`<button class="iconbtn" data-a="play"></button>`+
|
|
`<button class="iconbtn" data-a="remove" title="Remove from Currently Listening" aria-label="Remove from Currently Listening">${ICON.close}</button>`+
|
|
`<div class="rail"><i></i></div>`;
|
|
el.onclick=ev=>(ev.target as Element).closest('[data-a=remove]')?forget(e)
|
|
:el.classList.contains('now')&&!audio.paused?audio.pause():play(e);
|
|
paintListenRow(el);
|
|
box.appendChild(el);
|
|
}
|
|
return rows.length;
|
|
}
|
|
|
|
/// One row's time left, progress and play button, taken from the player when it is the one in it.
|
|
function paintListenRow(el){
|
|
const e=el.entry, now=player.guid===e.guid&&player.feed===e.feed_id;
|
|
// Zero until the player has sought to where you left off; the saved position stands till then.
|
|
if(now&&audio.currentTime) e.position=Math.floor(audio.currentTime);
|
|
// The player's own length first: a feed's can be minutes out.
|
|
const d=(now&&isFinite(audio.duration)&&Math.floor(audio.duration))||e.duration;
|
|
el.classList.toggle('now',now);
|
|
$('.left',el).textContent=d?`${clock(d-e.position)} left`:`${clock(e.position)} in`;
|
|
// With no length there is nothing to show, and an empty rail reads as a heavy border.
|
|
const rail=$('.rail',el); rail.hidden=!d;
|
|
$('i',rail).style.width=`${d?Math.min(100,e.position/d*100):0}%`;
|
|
const b=$('[data-a=play]',el), label=now&&!audio.paused?'Pause':'Resume';
|
|
if(b.title!==label){ b.title=label; b.setAttribute('aria-label',label); b.innerHTML=label==='Pause'?ICON.pause:ICON.play; }
|
|
}
|
|
/// Keeps the list in step with the player. Only a row that is, or was, the one in it changes.
|
|
function syncListening(){
|
|
for(const el of $$('#listening .childrow'))
|
|
if(el.entry&&(el.classList.contains('now')||player.guid===el.entry.guid)) paintListenRow(el);
|
|
}
|
|
|
|
/// Takes an episode off Currently Listening by forgetting where you got to: the list is every
|
|
/// episode with a saved position short of the end, so the position is what has to go.
|
|
async function forget(e){
|
|
// Closed without saving first, or the player's next save would put it straight back.
|
|
if(player.guid===e.guid){ player.guid=null; $('#pclose').click(); }
|
|
try{
|
|
await api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/position`,
|
|
{method:'POST',body:JSON.stringify({secs:0})});
|
|
}catch(err){ toast(err.message,true); }
|
|
if(S.feed===':listening') renderListed(VIEWS[':listening']);
|
|
}
|
|
|
|
// The toolbar acts on whatever is selected: the feed on the left, the item in the table.
|
|
$('#tbRemove').onclick=()=>{ const f=S.feeds.find(x=>x.id===S.feed); if(f) removeFeed(f); };
|
|
$('#tbPlay').onclick=()=>{ const e=cur(); if(e) play(e); };
|
|
$('#tbRead').onclick=()=>{ const e=cur(); if(e) epAction('read',e,null); };
|
|
$('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); };
|
|
let searchT;
|
|
$('#epSearch').oninput=ev=>{ clearTimeout(searchT);
|
|
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;
|
|
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(`<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>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>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>
|
|
<div class="field"><label>Download folder</label>
|
|
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>`);
|
|
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true);
|
|
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
|
|
$('#gopml').onclick=opmlModal;
|
|
$('#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); }
|
|
};
|
|
}
|
|
|
|
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(`<h3>${esc(f.title||f.id)}</h3>
|
|
${isGroup?`<p class="hint" style="margin:-6px 0 12px">This is ${isPatreon(f)?'a Patreon creator':'an OPML subscription'}. These
|
|
settings apply to it and are inherited by every feed inside it.</p>`:''}
|
|
${f.managed?`<p class="hint" style="margin:-6px 0 12px">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.</p>`:''}
|
|
<p class="hint" style="margin:-4px 0 10px">These are <b>your</b> settings for this feed.
|
|
Everyone else keeps their own.</p>
|
|
<div class="field"><label>Keywords</label>
|
|
<input type="text" id="skw" value="${esc(f.keywords.join(', '))}">
|
|
<span class="hint">Comma separated. Empty takes everything.</span></div>
|
|
${isGroup?'':`<div class="field"><label for="sblock">Hide items mentioning</label>
|
|
<input type="text" id="sblock" value="${esc((f.blocked||[]).join(', '))}">
|
|
<span class="hint">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.</span></div>`}
|
|
<div class="field"><label>Max new downloads per scan</label>
|
|
<input type="number" id="smax" min="0" value="${f.max_new_per_check??''}">
|
|
<span class="hint">Blank follows the global default (${globalMax}). The rest wait for
|
|
the next scan.</span></div>
|
|
<label class="check"><input type="checkbox" id="sauto" ${f.auto_download?'checked':''}> Download new items automatically</label>
|
|
<label class="check"><input type="checkbox" id="sexp" ${f.allow_explicit?'checked':''}> Allow items marked explicit</label>
|
|
<div class="field"><label>Feed URL</label>
|
|
<div class="inline">
|
|
<input type="text" id="surl" value="${esc(newUrl||f.url)}" spellcheck="false" ${S.me&&S.me.admin?'':'readonly'}>
|
|
<button type="button" class="btn ico" id="scopy" title="Copy the URL" aria-label="Copy the URL">${ICON.copy}</button>
|
|
</div>
|
|
<span class="hint">${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.`}</span></div>
|
|
${S.me&&S.me.admin?`<div class="field"><label>Download folder (shared)</label>
|
|
<input type="text" id="sfolder" value="${esc(f.folder||'')}" placeholder="${esc(f.title||f.id)}">
|
|
<span class="hint">Where the files land. There is one copy however many people
|
|
subscribe, so this is the same for everyone.</span></div>`:''}
|
|
${S.me&&S.me.admin?`<div class="field"><label>Directory category (shared)</label>
|
|
<input type="text" id="scat" list="scats" value="${esc(f.category||'')}" placeholder="${esc(f.feed_category||'None')}">
|
|
<datalist id="scats"></datalist>
|
|
<span class="hint">${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.`}</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="ssave" title="Save" aria-label="Save">${ICON.check}</button></div>`);
|
|
$('#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=>`<option value="${esc(c)}">`).join(''); }).catch(()=>{});
|
|
$('#ssave').onclick=async()=>{
|
|
const max=$('#smax').value;
|
|
try{
|
|
const patch: Record<string, unknown>={
|
|
keywords:splitWords($('#skw').value),
|
|
max_new_per_check:max===''?null:Number(max),
|
|
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked};
|
|
if($('#sblock')) patch.blocked=splitWords($('#sblock').value);
|
|
// The shared half is an admin's to change, and the API refuses it from anyone else.
|
|
if(S.me&&S.me.admin){
|
|
patch.url=$('#surl').value.trim();
|
|
patch.folder=$('#sfolder').value.trim()||null;
|
|
patch.category=$('#scat').value.trim()||null;
|
|
}
|
|
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify(patch)});
|
|
closeModal(); toast('Saved — applies on the next scan');
|
|
await loadFeeds(true); renderFeed(); loadEntries();
|
|
}catch(e){ toast(e.message,true); }
|
|
};
|
|
}
|
|
|
|
function downloadLatestModal(f){
|
|
openModal(`<h3>Download latest items</h3>
|
|
<div class="field"><label>How many of the newest undownloaded items?</label>
|
|
<input type="number" id="dcount" min="1" max="100" value="5">
|
|
<span class="hint">Queued immediately, ignoring the per-scan limit.</span></div>
|
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
|
<button class="btn ico primary" id="dgo" title="Download" aria-label="Download">${ICON.download}</button></div>`);
|
|
$('#dgo').onclick=async()=>{
|
|
const n=Number($('#dcount').value)||5;
|
|
try{
|
|
const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/download-latest`,
|
|
{method:'POST',body:JSON.stringify({count:n})});
|
|
closeModal(); toast(`Queued ${r.queued} item${r.queued===1?'':'s'}`);
|
|
}catch(e){ toast(e.message,true); }
|
|
};
|
|
}
|
|
|
|
function removeFeed(f){
|
|
openModal(`<h3>Unsubscribe?</h3>
|
|
<p style="color:var(--dim)">Removes <b>${esc(f.title||f.id)}</b> from your feeds. Anyone else
|
|
reading it keeps it, along with their own read state.
|
|
Downloaded files and history are kept, so re-adding it will not pull the back catalogue again.</p>
|
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
|
<button class="btn ico danger" id="rgo" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button></div>`);
|
|
$('#rgo').onclick=async()=>{
|
|
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'DELETE'});
|
|
closeModal(); toast('Unsubscribed'); S.feed=null;
|
|
await loadFeeds(); if(!S.feeds.length) renderFeed();
|
|
};
|
|
}
|
|
|
|
function opmlModal(){
|
|
openModal(`<h3>OPML</h3>
|
|
<p style="color:var(--dim);font-size:13.5px">Move subscriptions between podcast apps.</p>
|
|
<div class="field"><label>Export: save your subscriptions as OPML</label>
|
|
<div class="inline">
|
|
<a class="btn ico" href="/api/opml" download="ipx-subscriptions.opml" title="Export OPML" aria-label="Export OPML">${ICON.save}</a>
|
|
</div></div>
|
|
<div class="field" style="margin-top:16px"><label>Import: choose a file, or paste OPML</label>
|
|
<input type="file" id="opmlFile" accept=".opml,.xml,text/x-opml,text/xml,application/xml" style="margin-bottom:8px">
|
|
<textarea id="opmlText" rows="6" style="width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:8px;font:12px monospace"></textarea></div>
|
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button>
|
|
<button class="btn ico primary" id="oimp" title="Import: subscribe to every feed in it" aria-label="Import">${ICON.plus}</button></div>`);
|
|
$('#oimp').onclick=async()=>{
|
|
// A chosen file is read here and sent as text, so the server never stores it. Clearing
|
|
// the picker lets go of it on this side too, whether it was refused or imported.
|
|
const pick=$('#opmlFile'), file=pick.files[0];
|
|
const xml=file ? await file.text() : $('#opmlText').value;
|
|
const letGo=()=>{ pick.value=''; };
|
|
// A quick look before sending anything. The server parses it properly and has the last word.
|
|
if(!/<opml[\s>]/i.test(xml)){
|
|
letGo(); toast(`${file?file.name:'That'} is not an OPML file`,true); return;
|
|
}
|
|
try{
|
|
const r=await api('/api/opml',{method:'POST',body:JSON.stringify({xml})});
|
|
letGo(); closeModal(); toast(`Subscribed to ${r.added} feed(s)`+(r.already?`, ${r.already} you already had`:'')); loadFeeds(true);
|
|
}catch(e){ letGo(); toast(e.message,true); }
|
|
};
|
|
}
|
|
|
|
// No toast: the spinners on the rows being checked say it (issue #37).
|
|
async function scanAll(){ await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
|
|
$('#scanAll').onclick=scanAll;
|
|
$('#prefs').onclick=prefsModal;
|
|
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while
|
|
// the proxy still vouches for them. /api/me says where, when that is the case.
|
|
$('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); location.href=S.me?.sign_out||'/login'; };
|
|
api('/api/me').then(u=>{
|
|
S.me=u;
|
|
$('#who').textContent=u.name+(u.admin?' · admin':'');
|
|
}).catch(()=>{});
|
|
$('#feedFilter').oninput=renderFeeds;
|
|
// The feed list from the keyboard: Enter or Space opens a row, Right and Left open and close a
|
|
// folder. Handled keys stop here, or the player's own Space and arrows would act on them too.
|
|
$('#feedlist').onkeydown=ev=>{
|
|
const row=ev.target.closest('[data-id]'); if(!row) return;
|
|
const id=row.dataset.id;
|
|
if((ev.key==='Enter'||ev.key===' ')&&ev.target===row) row.click();
|
|
else if(row.classList.contains('group')&&
|
|
(ev.key==='ArrowRight'&&!expanded.has(id)||ev.key==='ArrowLeft'&&expanded.has(id))) toggleGroup(id);
|
|
else return;
|
|
ev.preventDefault(); ev.stopPropagation();
|
|
};
|
|
$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
|
|
$('#scrim').onclick=()=>nav(false);
|
|
|