Page type-check: strict throughout, a typed $, null checks (#236)

$<T> returns the element type it is given and assumes the element is there: 122 lookups take
their type from the tag their id is on in the markup. A handler reads its target through a
cast, since a typed element will not take a handler whose event names a narrower target.
strictNullChecks found no bug the page had; its 94 places were values the code already handles
in ways the type-check could not follow (data- attributes the page writes, a map entry set the
line before, Share drawn only for an item with a link), each now said so.

isPlayable stays a plain boolean: as a type guard its false would have meant "no file", and it
is also false for a file that is not audio or video.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 17:50:33 +00:00
parent 1471b9b9d0
commit 60007d4e6a
13 changed files with 191 additions and 193 deletions

View File

@@ -11,7 +11,7 @@ function closeModal(){
$('#modal').onclick=(e: MouseEvent)=>{ if((e.target as Element).id==='modal') closeModal(); };
$('#addFeed').onclick=()=>{
$<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>
@@ -20,17 +20,17 @@ $('#addFeed').onclick=()=>{
<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…';
$<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:$('#nexp').checked,
keywords:$('#nkw').value.split(',').map((s: string)=>s.trim()).filter(Boolean)})});
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); $('#nsave').title='Add feed'; $('#nsave').disabled=false; }
}catch(e){ toast(why(e),true); $<HTMLButtonElement>('#nsave').title='Add feed'; $<HTMLButtonElement>('#nsave').disabled=false; }
};
};
@@ -154,18 +154,18 @@ function drawDirectory(box=$('#directory'), focus?: string){
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,any[]>();
for(const p of rows){ const c=p.category||OTHER; if(!cats.has(c)) cats.set(c,[]); cats.get(c).push(p); }
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>`,
+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(Boolean))].sort();
if(!subs.includes(dirSub)) dirSub=null;
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);
@@ -180,23 +180,23 @@ function drawDirectory(box=$('#directory'), focus?: string){
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) for(const p of tiles.sort(DIR_SORTS[dirSort][1])) t.appendChild(listedFeed(p,'tile'));
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.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=''; }
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=$('#dirsort',box);
const s=$<HTMLSelectElement>('#dirsort',box);
if(s) s.onchange=()=>{ dirSort=s.value; drawDirectory(box,'#dirsort'); };
if(focus) $(focus,box)?.focus();
}
@@ -268,17 +268,17 @@ function catTile(c: string,name: string,count: string,rows: DirFeed[]){
async function renderListed(v: {title: string, icon: string, url?: string, blurb?: string}){
const box=$('#content');
box.classList.add('plain');
$('#tbRemove').disabled=true;
$<HTMLButtonElement>('#tbRemove').disabled=true;
syncTools(null);
if(v===VIEWS[':directory']){
$('#epSearch').placeholder='Find a feed…';
$<HTMLInputElement>('#epSearch').placeholder='Find a feed…';
box.innerHTML='<div id="directory"><p class="hint">Loading…</p></div>';
$('#count').textContent=v.title;
const n=await renderDirectory(v.url,$('#directory'));
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,'feed')}`;
const n=await renderDirectory(v.url!,$('#directory'));
if(VIEWS[S.feed ?? '']===v) $('#count').textContent=`${v.title}: ${plural(n,'feed')}`;
return;
}
$('#epSearch').placeholder='Search items…';
$<HTMLInputElement>('#epSearch').placeholder='Search items…';
box.innerHTML=`
<div class="fhead slim">
<div class="art">${v.icon}</div>
@@ -287,8 +287,8 @@ async function renderListed(v: {title: string, icon: string, url?: string, blurb
<div class="childlist" id="listening"><p class="hint">Loading…</p></div>`;
$('#count').textContent=v.title;
// The search box looks through these too, title and text, as it does a feed's items (#127).
const n=await renderListening(S.q?`${v.url}&q=${encodeURIComponent(S.q)}`:v.url,$('#listening',box));
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,'episode')}`;
const n=await renderListening(S.q?`${v.url}&q=${encodeURIComponent(S.q)}`: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
@@ -320,7 +320,7 @@ async function renderListening(url: string,box: HTMLElement){
/// 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;
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.
@@ -335,7 +335,7 @@ function paintListenRow(el: HTMLElement & {entry?: Entry}){
}
/// 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'))
for(const el of $$<HTMLElement & {entry?: Entry}>('#listening .childrow'))
if(el.entry&&(el.classList.contains('now')||player.guid===el.entry.guid)) paintListenRow(el);
}
@@ -343,7 +343,7 @@ function syncListening(){
/// 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; $('#pclose').click(); }
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})});
@@ -352,15 +352,15 @@ async function forget(e: Entry){
}
// 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); };
$<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;
$('#epSearch').oninput=(ev: Event & {target: HTMLInputElement})=>{ clearTimeout(searchT);
$<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.value; S.offset=0;
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); };
@@ -429,27 +429,27 @@ async function prefsModal(){
<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>`);
$('#stheme').onchange=(e: Event & {target: HTMLSelectElement})=>setTheme(e.target.value,undefined,true);
$('#smode').onchange=(e: Event & {target: HTMLSelectElement})=>setTheme(undefined,e.target.value,true);
$('#gopml').onclick=opmlModal;
$<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();
$('#tokadd').onclick=async()=>{
const name=$('#tokname').value.trim(); if(!name){ $('#tokname').focus(); return; }
$<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})});
$('#tokname').value='';
$<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>`);
$('#tokcopy').onclick=()=>copyText(t.token,$('#tokcopy'));
$('#tokval').select();
$<HTMLButtonElement>('#tokcopy').onclick=()=>copyText(t.token,$<HTMLButtonElement>('#tokcopy'));
$<HTMLInputElement>('#tokval').select();
}catch(err){ toast(why(err),true); }
};
$('#sblock').onchange=async (e: Event & {target: HTMLInputElement})=>{
const blocked=splitWords(e.target.value);
$<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;
@@ -487,14 +487,14 @@ function chipsHTML(words: string[], input: string){
}
/// 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($('.chipin',box).value)];
return [...$$('.chip',box).map(c=>c.dataset.w), ...splitWords($<HTMLInputElement>('.chipin',box).value)];
}
function wireChips(box: HTMLElement){
const input=$('.chipin',box);
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());
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());
@@ -509,7 +509,7 @@ function wireChips(box: HTMLElement){
input.onblur=add;
box.onclick=(e: MouseEvent)=>{
const x=(e.target as Element).closest('.chipx');
if(x){ x.parentElement.remove(); hint(); }
if(x){ x.parentElement!.remove(); hint(); }
input.focus();
};
}
@@ -524,7 +524,7 @@ function settingsModal(f: Feed, newUrl?: string){
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>0?f.max_new_per_check:(globalMax||3);
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
@@ -576,7 +576,7 @@ function settingsModal(f: Feed, newUrl?: string){
// 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;
$('#sauto').onchange=(e: Event & {target: HTMLInputElement})=>{ deps.classList.toggle('off',!e.target.checked); inner.inert=!e.target.checked; };
$<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).
@@ -605,13 +605,13 @@ function settingsModal(f: Feed, newUrl?: string){
}
if(!words) return;
$('#swcap').innerHTML=caption();
const n=$('#smax');
const n=$<HTMLInputElement>('#smax');
if(n) n.oninput=()=>{ newest=Math.max(1,Math.floor(Number(n.value))||1); draw(false); };
};
for(const r of $$('[name=swin]')) r.onchange=()=>{ mode=r.value; draw(true); };
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.
$('#sdead').onchange=(e: Event & {target: HTMLInputElement})=>{ $('#sdpause').disabled=!e.target.checked; };
const pause=$('#sdpause'), pauseText=()=>{ const s=`${pause.value/1000} s`; $('#sdpauseout').textContent=s; pause.setAttribute('aria-valuetext',s); };
$<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.
@@ -634,8 +634,8 @@ function settingsModal(f: Feed, newUrl?: string){
if(x<=b.getBoundingClientRect().right) break;
}
newest=Math.max(1,n);
if(mode!=='newest'){ mode='newest'; $('[name=swin][value=newest]').checked=true; draw(true); }
else { $('#smax').value=String(newest); draw(false); }
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); };
@@ -643,24 +643,24 @@ function settingsModal(f: Feed, newUrl?: string){
wireChips($('#skw'));
if($('#sblock')) wireChips($('#sblock'));
$('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy'));
$<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){ $('#surl').scrollIntoView({block:'center'}); $('#surl').focus(); }
if(newUrl){ $<HTMLInputElement>('#surl').scrollIntoView({block:'center'}); $<HTMLInputElement>('#surl').focus(); }
$('#ssave').onclick=async()=>{
$<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:$('#sauto').checked, allow_explicit:$('#sexp').checked, deadair:$('#sdead').checked,
deadair_pause:Number($('#sdpause').value)};
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=$('#sprivate');
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=$('#surl').value.trim();
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();
@@ -675,8 +675,8 @@ function downloadLatestModal(f: Feed){
<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;
$<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})});
@@ -692,7 +692,7 @@ function removeFeed(f: Feed){
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()=>{
$<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();
@@ -711,11 +711,11 @@ function opmlModal(){
<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()=>{
$<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=$('#opmlFile'), file=pick.files[0];
const xml=file ? await file.text() : $('#opmlText').value;
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)){
@@ -734,11 +734,11 @@ async function scanAll(){
try{ await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
catch(e){ spinEnd(); throw e; }
}
$('#scanAll').onclick=scanAll;
$('#prefs').onclick=prefsModal;
$<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.
$('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); location.href=S.me?.sign_out||'/login'; };
$<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':'');
@@ -750,18 +750,18 @@ api('/api/me').then(u=>{
// 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(()=>{});
$('#feedFilter').oninput=renderFeeds;
$<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;
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'));
$<HTMLButtonElement>('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
$('#scrim').onclick=()=>nav(false);