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:
@@ -56,15 +56,15 @@ async function drawServer(){
|
||||
<div class="field"><label>Download folder</label>
|
||||
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
|
||||
<div class="cardacts"><button class="btn primary" id="gsave">${ICON.check} Save</button></div>`;
|
||||
$('#gsave').onclick = async () => {
|
||||
$<HTMLButtonElement>('#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: string) => t.trim()).filter(Boolean),
|
||||
max_total_gb: Number($('#gquota').value) || 0,
|
||||
max_age_days: Number($('#gage').value) || 0,
|
||||
art_cache_mb: Math.max(0, Number($('#gart').value) || 0)})});
|
||||
schedule: `every ${Math.max(1, Number($<HTMLInputElement>('#gnum').value) || 1)}${$<HTMLSelectElement>('#gunit').value}`,
|
||||
max_new_per_check: Math.max(0, Number($<HTMLInputElement>('#gmax').value) || 0),
|
||||
media_types: $<HTMLInputElement>('#gtypes').value.split(',').map((t: string) => t.trim()).filter(Boolean),
|
||||
max_total_gb: Number($<HTMLInputElement>('#gquota').value) || 0,
|
||||
max_age_days: Number($<HTMLInputElement>('#gage').value) || 0,
|
||||
art_cache_mb: Math.max(0, Number($<HTMLInputElement>('#gart').value) || 0)})});
|
||||
toast('Settings saved');
|
||||
}catch(e){ toast(why(e), true); }
|
||||
};
|
||||
@@ -101,13 +101,13 @@ async function drawDirectorySettings(){
|
||||
drawDirectorySettings();
|
||||
}catch(e){ toast(why(e), true); }
|
||||
};
|
||||
$('#dsave').onclick = () => {
|
||||
const patch: Record<string, unknown> = {directory_url: $('#durl').value.trim(), directory_share: $('#dshare').checked};
|
||||
$<HTMLButtonElement>('#dsave').onclick = () => {
|
||||
const patch: Record<string, unknown> = {directory_url: $<HTMLInputElement>('#durl').value.trim(), directory_share: $<HTMLInputElement>('#dshare').checked};
|
||||
// Blank leaves the token as it is; Clear is the way to remove it.
|
||||
if($('#dtoken').value.trim()) patch.directory_token = $('#dtoken').value.trim();
|
||||
if($<HTMLInputElement>('#dtoken').value.trim()) patch.directory_token = $<HTMLInputElement>('#dtoken').value.trim();
|
||||
save(patch, 'Directory settings saved');
|
||||
};
|
||||
const clear = $('#dclear');
|
||||
const clear = $<HTMLButtonElement>('#dclear');
|
||||
if(clear) clear.onclick = () => save({directory_token: ''}, 'Token cleared');
|
||||
drawDirectoryStatus();
|
||||
}
|
||||
@@ -161,12 +161,12 @@ async function drawDirectoryAdmin(){
|
||||
const row = b.closest<HTMLElement>('.urow');
|
||||
try{
|
||||
if(b.dataset.a === 'drop'){
|
||||
const name = row.dataset.name;
|
||||
const name = row!.dataset.name!;
|
||||
if(!confirm(`Drop ${name}'s feeds and counts from the directory? They come back the next time ${name} shares.`)) return;
|
||||
await api(`/api/directory-instances/${encodeURIComponent(name)}`, {method: 'DELETE'});
|
||||
toast(`Dropped ${name}'s snapshot`);
|
||||
}else if(b.dataset.a === 'unblock'){
|
||||
await api(`/api/directory-blocked/${encodeURIComponent(row.dataset.id)}`, {method: 'DELETE'});
|
||||
await api(`/api/directory-blocked/${encodeURIComponent(row!.dataset.id!)}`, {method: 'DELETE'});
|
||||
toast('Unblocked. It comes back with the next snapshot that has it');
|
||||
}
|
||||
drawDirectoryAdmin();
|
||||
@@ -205,18 +205,18 @@ async function drawAccounts(){
|
||||
drawAccounts(); // on a refusal, this puts the checkbox back where the server left it
|
||||
};
|
||||
for(const row of $$('#accounts [data-id]')){
|
||||
const u = users.find(x => String(x.id) === row.dataset.id);
|
||||
$('[data-a="admin"]', row).onchange = (e: Event & {target: HTMLInputElement}) =>
|
||||
change(u, {method: 'PATCH', body: JSON.stringify({admin: e.target.checked})});
|
||||
const u = users.find(x => String(x.id) === row.dataset.id)!;
|
||||
$('[data-a="admin"]', row).onchange = (e: Event) =>
|
||||
change(u, {method: 'PATCH', body: JSON.stringify({admin: (e.target as HTMLInputElement).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 () => {
|
||||
$<HTMLButtonElement>('#uadd').onclick = async () => {
|
||||
try{
|
||||
await api('/api/users', {method: 'POST', body: JSON.stringify({
|
||||
name: $('#uname').value, password: $('#upass').value, admin: $('#uadmin').checked})});
|
||||
name: $<HTMLInputElement>('#uname').value, password: $<HTMLInputElement>('#upass').value, admin: $<HTMLInputElement>('#uadmin').checked})});
|
||||
toast('Added'); drawAccounts();
|
||||
}catch(e){ toast(why(e), true); } // keep what was typed
|
||||
};
|
||||
@@ -259,14 +259,14 @@ function drawLogView(){
|
||||
The buffer keeps debug detail even when the terminal does not; <b>IPX_UI_LOG</b> changes
|
||||
what it captures.</span>`;
|
||||
for(const b of $$('#logtabs button')) b.onclick = () => {
|
||||
logTab = b.dataset.t;
|
||||
logTab = b.dataset.t!;
|
||||
for(const x of $$('#logtabs button')) x.classList.toggle('on', x.dataset.t === logTab);
|
||||
drawLog();
|
||||
};
|
||||
$('#loglevel').onchange = (e: Event & {target: HTMLSelectElement}) => { logLevel = e.target.value; drawLog(); };
|
||||
$('#logq').oninput = (e: Event & {target: HTMLInputElement}) => { 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'));
|
||||
$<HTMLSelectElement>('#loglevel').onchange = (e: Event) => { logLevel = (e.target as HTMLSelectElement).value; drawLog(); };
|
||||
$<HTMLInputElement>('#logq').oninput = (e: Event) => { logFilter = (e.target as HTMLInputElement).value.toLowerCase(); drawLog(); };
|
||||
$<HTMLButtonElement>('#logcopy').onclick = () => copyText(visibleLog().map(l =>
|
||||
`${new Date(l.ts * 1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $<HTMLButtonElement>('#logcopy'));
|
||||
pollLog();
|
||||
if(!logTimer) logTimer = setInterval(pollLog, 2000);
|
||||
}
|
||||
@@ -294,7 +294,7 @@ function visibleLog(){
|
||||
}
|
||||
function drawLog(){
|
||||
const box = $('#logbox'); if(!box) return;
|
||||
const follow = $('#logfollow')?.checked;
|
||||
const follow = $<HTMLInputElement>('#logfollow')?.checked;
|
||||
const rows = visibleLog();
|
||||
box.innerHTML = rows.length ? rows.map(l => {
|
||||
const t = new Date(l.ts * 1000).toLocaleTimeString();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ function connect(){
|
||||
// Only the row actually downloading. Without the enclosure id this used to paint
|
||||
// every pending bar at once, so adding a feed looked like it was fetching the lot.
|
||||
const bar=document.querySelector<HTMLElement>(`.dlbar[data-bar="${ev.enclosure}"] i`);
|
||||
if(bar){ bar.style.width=pct+'%'; bar.parentElement.classList.add('live'); }
|
||||
if(bar){ bar.style.width=pct+'%'; bar.parentElement!.classList.add('live'); }
|
||||
$('#count') && ($('#count').textContent=`downloading ${ev.file} — ${pct.toFixed(0)}%`);
|
||||
}
|
||||
else if(ev.ev==='download_done'){
|
||||
|
||||
@@ -3,16 +3,16 @@ function renderFeed(){
|
||||
paintScanning(); // another feed's page may be the one open now
|
||||
const box=$('#content');
|
||||
box.classList.remove('plain');
|
||||
const v=VIEWS[S.feed];
|
||||
const v=VIEWS[S.feed ?? ''];
|
||||
if(v&&v.url) return renderListed(v);
|
||||
const f=v?null:S.feeds.find(x=>x.id===S.feed);
|
||||
$('#tbRemove').disabled=!f;
|
||||
$<HTMLButtonElement>('#tbRemove').disabled=!f;
|
||||
syncTools(null);
|
||||
if(!v&&!f){ box.innerHTML='<p class="empty">Add a feed to get started.</p>'; $('#count').textContent=''; return; }
|
||||
const name=f?(f.title||f.id):v.title;
|
||||
$('#epSearch').placeholder=`Search ${name}…`;
|
||||
$<HTMLInputElement>('#epSearch').placeholder=`Search ${name}…`;
|
||||
const kids=f?S.feeds.filter(c=>c.group===f.id):[];
|
||||
if(kids.length){ box.classList.add('plain'); renderGroup(f,kids); return; }
|
||||
if(kids.length){ box.classList.add('plain'); renderGroup(f!,kids); return; }
|
||||
const unreadAll=S.feeds.reduce((n,x)=>n+(x.unread||0),0);
|
||||
box.innerHTML = (f ? `
|
||||
<div class="fhead slim">
|
||||
@@ -61,7 +61,7 @@ function renderFeed(){
|
||||
if(f) pane.className='one';
|
||||
pane.innerHTML='<div id="list">'+sortHead()+
|
||||
'<div id="eps"></div></div><div id="files"></div><div id="grab"></div><div id="detail"></div>';
|
||||
$$('.ephead [data-sort]',pane).forEach(b=>b.onclick=()=>sortBy(b.dataset.sort));
|
||||
$$('.ephead [data-sort]',pane).forEach(b=>b.onclick=()=>sortBy(b.dataset.sort!));
|
||||
box.appendChild(pane);
|
||||
pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '60%');
|
||||
dragSplit(pane);
|
||||
@@ -69,9 +69,9 @@ function renderFeed(){
|
||||
S.sel=null;
|
||||
showDetail(null);
|
||||
|
||||
$$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a,f):allAction(b.dataset.a));
|
||||
$$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a!,f):allAction(b.dataset.a!));
|
||||
$$('#content .tabs button').forEach(b=>b.onclick=()=>{
|
||||
S.filter=b.dataset.f; S.offset=0;
|
||||
S.filter=b.dataset.f!; S.offset=0;
|
||||
try{ localStorage.setItem('ipx.filter',S.filter); }catch{}
|
||||
renderFeed(); loadEntries();
|
||||
});
|
||||
@@ -132,10 +132,10 @@ function renderGroup(f: Feed,kids: Feed[]){
|
||||
<span style="color:var(--faint);font-size:12.5px">${esc(f.url)}</span>
|
||||
</div>
|
||||
<div class="childlist" id="kidlist"></div>`;
|
||||
$$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a,f));
|
||||
$$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a!,f));
|
||||
wireFailBanner($('#content'),f);
|
||||
const draw=()=>{
|
||||
const q=($('#kidSearch').value||'').trim().toLowerCase();
|
||||
const q=($<HTMLInputElement>('#kidSearch').value||'').trim().toLowerCase();
|
||||
const box=$('#kidlist'); box.innerHTML='';
|
||||
const rows=kids.filter(c=>!q||(c.title||c.id).toLowerCase().includes(q)).sort(unreadFirst);
|
||||
if(!rows.length){ box.innerHTML='<p class="empty">Nothing matches.</p>'; return; }
|
||||
@@ -154,7 +154,7 @@ function renderGroup(f: Feed,kids: Feed[]){
|
||||
box.appendChild(el);
|
||||
}
|
||||
};
|
||||
$('#kidSearch').oninput=draw;
|
||||
$<HTMLInputElement>('#kidSearch').oninput=draw;
|
||||
draw();
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ async function loadFeeds(keepSel?: boolean){
|
||||
// brand new account) leaves S.feed alone, so the empty state's own message shows instead.
|
||||
if(!keepSel && S.feeds.length){
|
||||
const known = S.feed && (VIEWS[S.feed] || S.feeds.some(f=>f.id===S.feed));
|
||||
selectFeed(known ? S.feed : ':all');
|
||||
selectFeed(known ? S.feed! : ':all');
|
||||
}
|
||||
}
|
||||
// An OPML can hold dozens of feeds; the ones with something new go first. sort is stable, so the
|
||||
@@ -45,9 +45,9 @@ function setScanning(id: string, on: boolean){
|
||||
paintScanning();
|
||||
}
|
||||
function paintScanning(){
|
||||
const slow=(id: string)=>scanning.has(id)&&Date.now()-scanning.get(id)>=SPIN_AFTER;
|
||||
const slow=(id: string)=>scanning.has(id)&&Date.now()-scanning.get(id)!>=SPIN_AFTER;
|
||||
for(const row of $$('#feedlist .feed')){
|
||||
const id=row.dataset.id;
|
||||
const id=row.dataset.id!;
|
||||
const on=slow(id)||S.feeds.some(c=>c.group===id&&slow(c.id));
|
||||
row.classList.toggle('scanning',on);
|
||||
if(on) row.title='Checking for new items…'; else row.removeAttribute('title');
|
||||
@@ -73,21 +73,21 @@ function spinEnd(done?: {only: string|null, forced: boolean}){
|
||||
}
|
||||
|
||||
function renderFeeds(){
|
||||
const q=$('#feedFilter').value.trim().toLowerCase();
|
||||
const q=$<HTMLInputElement>('#feedFilter').value.trim().toLowerCase();
|
||||
const list=$('#feedlist'); const top=list.scrollTop;
|
||||
// Every row is replaced, so put the keyboard back on the row, or the triangle, it was on.
|
||||
const a=document.activeElement, was=a&&a.closest&&a.closest<HTMLElement>('#feedlist [data-id]');
|
||||
const back=was&&([was.dataset.id,a.classList.contains('chev')] as [string, boolean]);
|
||||
const done=()=>{
|
||||
list.scrollTop=top;
|
||||
const row=back&&list.querySelector(`[data-id="${CSS.escape(back[0])}"]`);
|
||||
if(row) (back[1]&&$('.chev',row)||row).focus();
|
||||
const row=back&&list.querySelector<HTMLElement>(`[data-id="${CSS.escape(back[0])}"]`);
|
||||
if(row) (back[1]&&$('.chev',row)||row as HTMLElement).focus();
|
||||
};
|
||||
// The images already drawn, to go into the rows that replace theirs. Made anew, every icon in
|
||||
// the list went blank and loaded again, once a frame through a check of every feed, as each
|
||||
// feed's new row came in (#134). Matched by their markup, so a changed one is made anew.
|
||||
const drawn=new Map<string,HTMLImageElement[]>();
|
||||
for(const img of $$('img',list)){ const k=img.outerHTML; if(!drawn.has(k)) drawn.set(k,[]); drawn.get(k).push(img); }
|
||||
for(const img of $$<HTMLImageElement>('img',list)){ const k=img.outerHTML; if(!drawn.has(k)) drawn.set(k,[]); drawn.get(k)!.push(img); }
|
||||
list.innerHTML='';
|
||||
const unreadAll=S.feeds.reduce((n,f)=>n+(f.unread||0),0);
|
||||
const places=document.createElement('div');
|
||||
@@ -168,7 +168,7 @@ function renderFeeds(){
|
||||
done();
|
||||
}
|
||||
function selectFeed(id: string){
|
||||
S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value='';
|
||||
S.feed=id; S.offset=0; S.sel=null; S.q=''; $<HTMLInputElement>('#epSearch').value='';
|
||||
try{ localStorage.setItem('ipx.feed',id); }catch{}
|
||||
renderFeeds(); renderFeed(); loadEntries();
|
||||
}
|
||||
@@ -185,6 +185,6 @@ function failBannerHTML(f: Feed){
|
||||
}
|
||||
function wireFailBanner(box: ParentNode,f: Feed){
|
||||
const un=$('[data-ffail="unsub"]',box); if(un) un.onclick=()=>removeFeed(f);
|
||||
const nu=$('[data-ffail="newurl"]',box); if(nu) nu.onclick=()=>settingsModal(f,f.failing.new_url);
|
||||
const nu=$('[data-ffail="newurl"]',box); if(nu) nu.onclick=()=>settingsModal(f,f.failing?.new_url??undefined);
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ document.addEventListener('touchstart', ev => {
|
||||
// back button is never swallowed with it.
|
||||
if(t.clientX < 14 && narrow() && ev.cancelable) ev.preventDefault();
|
||||
}
|
||||
else if(list && list.scrollTop <= 0 && !VIEWS[S.feed]?.url) touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'list', dx: 0, dy: 0};
|
||||
else if(list && list.scrollTop <= 0 && !VIEWS[S.feed ?? '']?.url) touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'list', dx: 0, dy: 0};
|
||||
}, {passive: false});
|
||||
|
||||
document.addEventListener('touchmove', ev => {
|
||||
|
||||
@@ -14,7 +14,7 @@ async function loadEntries(append?: boolean){
|
||||
S.total=r.total;
|
||||
for(const e of r.entries){
|
||||
const w=readWrites.get(readKey(e));
|
||||
if(w && !(w.done<asked)) e.read=w.read;
|
||||
if(w && !(w.done!==undefined&&w.done<asked)) e.read=w.read;
|
||||
}
|
||||
let entries = append ? S.entries.concat(r.entries) : r.entries;
|
||||
// A background scan finishing refreshes the list from the server, which -- on the Unread
|
||||
@@ -32,7 +32,7 @@ async function loadEntries(append?: boolean){
|
||||
}
|
||||
function renderEntries(){
|
||||
const box=$('#eps'); if(!box) return;
|
||||
const f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed];
|
||||
const f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed ?? ''];
|
||||
const unread=f?f.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0);
|
||||
$('#count').textContent=
|
||||
`${f?(f.title||f.id):v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`;
|
||||
@@ -78,7 +78,7 @@ function epEl(e: Entry){
|
||||
num&&`<span>${num}</span>`,
|
||||
left&&`<span>${left}</span>`,
|
||||
others>0&&`<span>+${others} more file${others===1?'':'s'}</span>`,
|
||||
enc&&enc.deadair>0&&`<span title="DeadAir cut its dead air">${shorter(enc.deadair)}</span>`,
|
||||
enc&&enc.deadair&&enc.deadair>0&&`<span title="DeadAir cut its dead air">${shorter(enc.deadair)}</span>`,
|
||||
enc&&enc.deadair_pending&&DEAD_PENDING,
|
||||
enc&&enc.last_error&&enc.state==='error'&&`<span style="color:var(--bad)">${esc(enc.last_error)}</span>`,
|
||||
].filter(Boolean).join('<span class="dot"></span>')}</div>
|
||||
@@ -97,13 +97,13 @@ function epEl(e: Entry){
|
||||
${has?`<button class="iconbtn" data-a="del" title="Delete file" aria-label="Delete file">${ICON.trash}</button>`:''}
|
||||
</div>`;
|
||||
el.onclick=()=>selectEntry(e);
|
||||
$$('button[data-a]',el).forEach(b=>b.onclick=(ev: Event)=>{ev.stopPropagation();epAction(b.dataset.a,e,el)});
|
||||
$$('button[data-a]',el).forEach(b=>b.onclick=(ev: Event)=>{ev.stopPropagation();epAction(b.dataset.a!,e,el)});
|
||||
return el;
|
||||
}
|
||||
/// Whether the browser can play it. Having a file is not the same as being playable:
|
||||
/// blog feeds put article images in enclosures, and an <audio> element pointed at a JPEG
|
||||
/// is just a broken player.
|
||||
function isPlayable(x: Enclosure){
|
||||
function isPlayable(x: Enclosure|undefined){
|
||||
if(!x || !x.path) return false;
|
||||
const m=(x.mime||'').toLowerCase();
|
||||
if(m.startsWith('audio/')||m.startsWith('video/')) return true;
|
||||
@@ -138,7 +138,7 @@ function kindIcon(enc: Enclosure){
|
||||
}
|
||||
|
||||
function feedArt(id=S.feed){ const f=S.feeds.find(x=>x.id===id); return f&&f.image; }
|
||||
const feedName=(id: string)=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id):id; };
|
||||
const feedName=(id: string|null|undefined): string=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id):(id??''); };
|
||||
|
||||
/// What an item is called. Its title, or for one published without (RSS 2.0 makes it optional,
|
||||
/// and Scripting News titles almost none of its posts) the opening of its text, then its file's
|
||||
@@ -147,7 +147,7 @@ const feedName=(id: string)=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.t
|
||||
/// Feed HTML as plain words. An inert document: nothing in it loads or runs, where a detached
|
||||
/// element fetches its images. A line break or paragraph's end is a space, or "2010)<br>Recorded"
|
||||
/// read "2010)Recorded".
|
||||
const plainText=(html?: string)=>html
|
||||
const plainText=(html?: string|null)=>html
|
||||
? (new DOMParser().parseFromString(html.replace(/<(br|\/p|\/div|\/li|\/h\d)\b[^>]*>/gi,' $&'),'text/html').body.textContent||'')
|
||||
.replace(/\s+/g,' ').trim() : '';
|
||||
function entryName(e: Partial<Entry>): {text: string, derived: boolean}{
|
||||
@@ -157,9 +157,9 @@ function entryName(e: Partial<Entry>): {text: string, derived: boolean}{
|
||||
const cut=words.length>120 ? words.slice(0,120).replace(/\s+\S*$/,'')+'…' : words;
|
||||
return {text:cut, derived:true};
|
||||
}
|
||||
const file=((e.enclosures||[])[0]?.url||'').split(/[?#]/)[0].split('/').pop().replace(/\.[a-z0-9]{1,5}$/i,'');
|
||||
const file=((e.enclosures||[])[0]?.url||'').split(/[?#]/)[0].split('/').pop()!.replace(/\.[a-z0-9]{1,5}$/i,'');
|
||||
if(file){ try{ return {text:decodeURIComponent(file), derived:true}; }catch{ return {text:file, derived:true}; } }
|
||||
return {text:[feedName(e.feed_id), dateOf(e.published)].filter(Boolean).join(', '), derived:true};
|
||||
return {text:[feedName(e.feed_id), dateOf(e.published??null)].filter(Boolean).join(', '), derived:true};
|
||||
}
|
||||
|
||||
/// Selecting an item shows it in the pane below, rather than expanding the row.
|
||||
@@ -244,23 +244,23 @@ function dragSplit(pane: HTMLElement){
|
||||
// the first. Key rows by feed as well if that ever happens.
|
||||
const cur=()=>S.entries.find(x=>x.guid===S.sel);
|
||||
function syncTools(e: Entry|null){
|
||||
$('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable));
|
||||
$('#tbRead').disabled=$('#tbFlag').disabled=!e;
|
||||
$<HTMLButtonElement>('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable));
|
||||
$<HTMLButtonElement>('#tbRead').disabled=$<HTMLButtonElement>('#tbFlag').disabled=!e;
|
||||
// The same icons as the item's own buttons beside its title, so the two never disagree. Each
|
||||
// shows what is, as the pin always did: read showed what a click would do, so the two side by
|
||||
// side said opposite things (#74). The shape carries it, not the colour.
|
||||
$('#tbRead').innerHTML=e&&!e.read?ICON.unread:ICON.check;
|
||||
$('#tbRead').setAttribute('aria-pressed',String(!!(e&&e.read)));
|
||||
$('#tbFlag').setAttribute('aria-pressed',String(!!(e&&e.flagged)));
|
||||
$('#tbFlag').innerHTML=e&&e.flagged?ICON.pinOn:ICON.pin;
|
||||
$<HTMLButtonElement>('#tbRead').innerHTML=e&&!e.read?ICON.unread:ICON.check;
|
||||
$<HTMLButtonElement>('#tbRead').setAttribute('aria-pressed',String(!!(e&&e.read)));
|
||||
$<HTMLButtonElement>('#tbFlag').setAttribute('aria-pressed',String(!!(e&&e.flagged)));
|
||||
$<HTMLButtonElement>('#tbFlag').innerHTML=e&&e.flagged?ICON.pinOn:ICON.pin;
|
||||
if(e){
|
||||
$('#tbRead').title=`Mark ${e.read?'unread':'read'}`;
|
||||
$('#tbFlag').title=e.flagged?'Unpin':'Pin, so it is never deleted';
|
||||
$<HTMLButtonElement>('#tbRead').title=`Mark ${e.read?'unread':'read'}`;
|
||||
$<HTMLButtonElement>('#tbFlag').title=e.flagged?'Unpin':'Pin, so it is never deleted';
|
||||
}
|
||||
}
|
||||
|
||||
/// The item's text in the pane below the list, and its files in the pane beside it.
|
||||
function showDetail(e: Entry){
|
||||
function showDetail(e: Entry|null){
|
||||
const box=$('#detail'), files=$('#files'); if(!box) return;
|
||||
// A pane that can only say "No files" gives its width to the list instead.
|
||||
if(files) files.hidden=!(e&&e.enclosures.length);
|
||||
@@ -278,9 +278,9 @@ function showDetail(e: Entry){
|
||||
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
|
||||
box.innerHTML=detailHtml(e);
|
||||
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
|
||||
$('#dback').onclick=()=>showDetail(null);
|
||||
$<HTMLButtonElement>('#dback').onclick=()=>showDetail(null);
|
||||
for(const root of [box,files]) if(root) $$('button[data-a]',root).forEach(b=>
|
||||
b.onclick=()=>epAction(b.dataset.a,e,null,b.dataset.enc?Number(b.dataset.enc):null));
|
||||
b.onclick=()=>epAction(b.dataset.a!,e,null,b.dataset.enc?Number(b.dataset.enc):undefined));
|
||||
syncPlayButtons();
|
||||
}
|
||||
|
||||
@@ -328,8 +328,9 @@ function encBox(x: Enclosure){
|
||||
: 'Delete file';
|
||||
const delBtn=`<button class="btn ico danger" data-a="del" data-enc="${x.id}" title="${delLabel}" aria-label="${delLabel}">${ICON.trash}</button>`;
|
||||
// Played without its dead air, so saved without it too (#186).
|
||||
const saveLabel=x.deadair>0?'Save to this computer, without its dead air':'Save to this computer';
|
||||
const saveBtn=`<a class="btn ico" href="/media/${x.id}${x.deadair>0?'/deadair':''}" download title="${saveLabel}" aria-label="${saveLabel}">${ICON.save}</a>`;
|
||||
const dead=(x.deadair??0)>0;
|
||||
const saveLabel=dead?'Save to this computer, without its dead air':'Save to this computer';
|
||||
const saveBtn=`<a class="btn ico" href="/media/${x.id}${dead?'/deadair':''}" download title="${saveLabel}" aria-label="${saveLabel}">${ICON.save}</a>`;
|
||||
// The publisher's address, not this server's copy, which only someone signed in here can open.
|
||||
const shareBtn=`<button class="btn ico" data-a="share" data-enc="${x.id}" title="Share the file" aria-label="Share the file">${ICON.share}</button>`;
|
||||
if(x.path && !isPlayable(x)){
|
||||
@@ -376,7 +377,7 @@ async function epAction(a: string, e: Entry, el: HTMLElement|null, encId?: numbe
|
||||
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
|
||||
try{
|
||||
if(a==='play') play(e, encId!=null ? enc : undefined);
|
||||
if(a==='share') await share(entryName(e).text, encId!=null ? enc.url : e.link, el);
|
||||
if(a==='share') await share(entryName(e).text, encId!=null ? enc.url : e.link!, el??undefined);
|
||||
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); }
|
||||
if(a==='read'){ await setRead(e,!e.read); redraw(); }
|
||||
if(a==='get'){
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
document.getElementById('f').onsubmit = async e => {
|
||||
document.getElementById('f')!.onsubmit = async e => {
|
||||
e.preventDefault();
|
||||
const msg = document.getElementById('msg');
|
||||
const msg = document.getElementById('msg')!;
|
||||
msg.textContent = '';
|
||||
const r = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* ---------------- player ---------------- */
|
||||
const audio=$('#audio');
|
||||
const audio=$<HTMLAudioElement>('#audio');
|
||||
const player: {guid: string|null, feed: string|null, entry: Entry|null, enc?: number, src?: string, moved?: boolean,
|
||||
saveAt: number, marked: boolean}={guid:null,feed:null,entry:null,saveAt:0,marked:false};
|
||||
|
||||
@@ -50,7 +50,7 @@ function markPlayed(){
|
||||
|
||||
/// Plays one of the item's files in the player bar: the one asked for, or its first playable one.
|
||||
function play(e: Entry,enc=e.enclosures.find(isPlayable)){
|
||||
if(!isPlayable(enc)){
|
||||
if(!enc||!isPlayable(enc)){
|
||||
toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet', true);
|
||||
return;
|
||||
}
|
||||
@@ -84,7 +84,7 @@ function mediaSession(e: Entry,f: Feed|undefined){
|
||||
if(!('mediaSession' in navigator)) return;
|
||||
navigator.mediaSession.metadata=new MediaMetadata({
|
||||
title:entryName(e).text, artist:f?(f.title||f.id):'', album:f?(f.title||''):'',
|
||||
artwork:(e.image||(f&&f.image))?[{src:artSrc(e.image||f.image),sizes:'512x512'}]:[],
|
||||
artwork:(e.image||(f&&f.image))?[{src:artSrc((e.image||f?.image)!),sizes:'512x512'}]:[],
|
||||
});
|
||||
const h: Record<string,()=>void>={play:()=>audio.play(),pause:()=>audio.pause(),
|
||||
seekbackward:()=>audio.currentTime-=15,seekforward:()=>audio.currentTime+=30};
|
||||
@@ -94,7 +94,7 @@ audio.addEventListener('timeupdate',()=>{
|
||||
const d=audio.duration||player.entry?.duration||0;
|
||||
$('#pcur').textContent=clock(audio.currentTime);
|
||||
$('#pdur').textContent=clock(d);
|
||||
if(d) $('#seek').value=String(Math.round(audio.currentTime/d*1000));
|
||||
if(d) $<HTMLInputElement>('#seek').value=String(Math.round(audio.currentTime/d*1000));
|
||||
// Only playing counts as moving: the seek to where you left off happens paused, and saving
|
||||
// that would write back whatever the list said, however old.
|
||||
if(!audio.paused) player.moved=true;
|
||||
@@ -117,29 +117,29 @@ function savePos(){
|
||||
const duration=isFinite(audio.duration)?Math.floor(audio.duration):null;
|
||||
navigator.sendBeacon?.(
|
||||
// A beacon sends no headers, so the trace goes in the address (#192).
|
||||
`/api/entries/${encodeURIComponent(player.feed)}/${encodeURIComponent(player.guid)}/position?traceparent=${traceparent()}`,
|
||||
`/api/entries/${encodeURIComponent(player.feed!)}/${encodeURIComponent(player.guid!)}/position?traceparent=${traceparent()}`,
|
||||
new Blob([JSON.stringify({secs:Math.floor(audio.currentTime),duration})],{type:'application/json'}));
|
||||
}
|
||||
audio.addEventListener('pause',savePos);
|
||||
audio.addEventListener('ended',()=>{savePos();markPlayed();$('#pplay').innerHTML=ICON.play});
|
||||
audio.addEventListener('ended',()=>{savePos();markPlayed();$<HTMLButtonElement>('#pplay').innerHTML=ICON.play});
|
||||
// body.playing is what sets the EQ bars moving.
|
||||
audio.addEventListener('play',()=>{ $('#pplay').innerHTML=ICON.pause; document.body.classList.add('playing'); });
|
||||
audio.addEventListener('pause',()=>{ $('#pplay').innerHTML=ICON.play; document.body.classList.remove('playing'); });
|
||||
audio.addEventListener('play',()=>{ $<HTMLButtonElement>('#pplay').innerHTML=ICON.pause; document.body.classList.add('playing'); });
|
||||
audio.addEventListener('pause',()=>{ $<HTMLButtonElement>('#pplay').innerHTML=ICON.play; document.body.classList.remove('playing'); });
|
||||
for(const ev of ['play','pause','ended']) audio.addEventListener(ev,syncPlayButtons);
|
||||
|
||||
/// Every play button for what is playing shows pause, like the player bar's: a row's, the files
|
||||
/// pane's, the toolbar's. Only the bar's used to change, so the others said play while it played.
|
||||
function syncPlayButtons(){
|
||||
const on=(guid: string,enc?: number)=>!audio.paused&&player.guid===guid&&(enc==null||player.enc===enc);
|
||||
const on=(guid: string|null,enc?: number)=>!audio.paused&&player.guid===guid&&(enc==null||player.enc===enc);
|
||||
const paint=(b: HTMLElement,now: boolean,idle: string)=>{
|
||||
const label=now?'Pause':idle;
|
||||
if(b.title===label) return;
|
||||
b.title=label; b.setAttribute('aria-label',label); b.innerHTML=now?ICON.pause:ICON.play;
|
||||
};
|
||||
for(const b of $$('#eps .ep [data-a=play]')) paint(b,on(b.closest('.ep').dataset.guid),'Play');
|
||||
for(const b of $$('#eps .ep [data-a=play]')) paint(b,on(b.closest<HTMLElement>('.ep')!.dataset.guid!),'Play');
|
||||
for(const b of $$('#files [data-a=play][data-enc], #detail [data-a=play][data-enc]'))
|
||||
paint(b,on(S.sel,Number(b.dataset.enc)),'Play');
|
||||
const e=cur(); paint($('#tbPlay'),!!e&&on(e.guid),'Play the selected item');
|
||||
const e=cur(); paint($<HTMLButtonElement>('#tbPlay'),!!e&&on(e.guid),'Play the selected item');
|
||||
}
|
||||
for(const ev of ['play','pause','timeupdate']) audio.addEventListener(ev,syncListening);
|
||||
window.addEventListener('beforeunload',savePos);
|
||||
@@ -157,23 +157,23 @@ audio.addEventListener('playing',()=>{
|
||||
asked=0; waited=0;
|
||||
});
|
||||
audio.addEventListener('error',()=>{ if(audio.error) report('player_error',{msg:`media error ${audio.error.code}: ${audio.error.message}`,...playing()}); });
|
||||
$('#pplay').onclick=()=>audio.paused?audio.play():audio.pause();
|
||||
$('#pback').onclick=()=>audio.currentTime-=15;
|
||||
$('#pfwd').onclick=()=>audio.currentTime+=30;
|
||||
$('#seek').oninput=(e: Event & {target: HTMLInputElement})=>{const d=audio.duration;if(d)audio.currentTime=d*Number(e.target.value)/1000};
|
||||
$('#rate').onchange=(e: Event & {target: HTMLSelectElement})=>{audio.playbackRate=+e.target.value;localStorage.setItem('ipx.rate',e.target.value)};
|
||||
$('#vol').oninput=(e: Event & {target: HTMLInputElement})=>{audio.volume=Number(e.target.value)/100;localStorage.setItem('ipx.vol',e.target.value)};
|
||||
$<HTMLButtonElement>('#pplay').onclick=()=>audio.paused?audio.play():audio.pause();
|
||||
$<HTMLButtonElement>('#pback').onclick=()=>audio.currentTime-=15;
|
||||
$<HTMLButtonElement>('#pfwd').onclick=()=>audio.currentTime+=30;
|
||||
$<HTMLInputElement>('#seek').oninput=(e: Event)=>{const d=audio.duration;if(d)audio.currentTime=d*Number((e.target as HTMLInputElement).value)/1000};
|
||||
$<HTMLSelectElement>('#rate').onchange=(e: Event)=>{audio.playbackRate=+(e.target as HTMLSelectElement).value;localStorage.setItem('ipx.rate',(e.target as HTMLSelectElement).value)};
|
||||
$<HTMLInputElement>('#vol').oninput=(e: Event)=>{audio.volume=Number((e.target as HTMLInputElement).value)/100;localStorage.setItem('ipx.vol',(e.target as HTMLInputElement).value)};
|
||||
/// DeadAir's switch beside the speed: on or off for the show that is playing. The state is in the
|
||||
/// icon's shape and aria-pressed, never a colour.
|
||||
function paintDead(){
|
||||
const b=$('#pdead'), f=S.feeds.find(x=>x.id===player.feed);
|
||||
const b=$<HTMLButtonElement>('#pdead'), f=S.feeds.find(x=>x.id===player.feed);
|
||||
b.hidden=!f||document.body.classList.contains('has-video');
|
||||
if(b.hidden) return;
|
||||
if(!f||b.hidden) return;
|
||||
b.setAttribute('aria-pressed',String(!!f.deadair));
|
||||
b.innerHTML=f.deadair?ICON.deadairOn:ICON.deadair;
|
||||
b.title=f.deadair?'DeadAir is cutting the dead air from this show':'DeadAir: cut the dead air from this show';
|
||||
}
|
||||
$('#pdead').onclick=async()=>{
|
||||
$<HTMLButtonElement>('#pdead').onclick=async()=>{
|
||||
const f=S.feeds.find(x=>x.id===player.feed); if(!f) return;
|
||||
const on=!f.deadair;
|
||||
try{ await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({deadair:on})}); }
|
||||
@@ -188,7 +188,7 @@ $('#pdead').onclick=async()=>{
|
||||
/// turned on or off for its show. True if it changed version: a copy not made yet plays next time.
|
||||
async function switchVersion(){
|
||||
if(!player.guid) return false;
|
||||
const [feed,guid]=[player.feed,player.guid].map(encodeURIComponent);
|
||||
const [feed,guid]=[player.feed!,player.guid!].map(s=>encodeURIComponent(s));
|
||||
// Saved and waited for, not beaconed, so the entry asked for next has this place in it.
|
||||
if(audio.readyState){
|
||||
await api(`/api/entries/${feed}/${guid}/position`,{method:'POST',body:JSON.stringify({
|
||||
@@ -205,13 +205,13 @@ async function switchVersion(){
|
||||
if(was) (wait||Promise.resolve()).then(()=>audio.play()).catch(()=>{});
|
||||
return true;
|
||||
}
|
||||
$('#pclose').onclick=()=>{savePos();audio.pause();dropHls();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries();
|
||||
$<HTMLButtonElement>('#pclose').onclick=()=>{savePos();audio.pause();dropHls();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries();
|
||||
// Called here, not left to the pause event: closing a player already paused fires none.
|
||||
syncListening()};
|
||||
(function restore(){
|
||||
const r=localStorage.getItem('ipx.rate'), v=localStorage.getItem('ipx.vol');
|
||||
if(r){$('#rate').value=r;audio.playbackRate=+r}
|
||||
if(v){$('#vol').value=v;audio.volume=Number(v)/100}
|
||||
if(r){$<HTMLSelectElement>('#rate').value=r;audio.playbackRate=+r}
|
||||
if(v){$<HTMLInputElement>('#vol').value=v;audio.volume=Number(v)/100}
|
||||
})();
|
||||
|
||||
document.addEventListener('keydown',ev=>{
|
||||
@@ -223,7 +223,7 @@ document.addEventListener('keydown',ev=>{
|
||||
if(ev.key===' '&&player.guid){ev.preventDefault();audio.paused?audio.play():audio.pause()}
|
||||
else if(ev.key==='ArrowLeft'&&player.guid){audio.currentTime-=15}
|
||||
else if(ev.key==='ArrowRight'&&player.guid){audio.currentTime+=30}
|
||||
else if(ev.key==='/'){ev.preventDefault();$('#epSearch')?.focus()}
|
||||
else if(ev.key==='/'){ev.preventDefault();$<HTMLInputElement>('#epSearch')?.focus()}
|
||||
else if(!ev.ctrlKey&&!ev.metaKey&&!ev.altKey&&!$('#modal').classList.contains('on')) typed(ev);
|
||||
});
|
||||
|
||||
@@ -233,7 +233,7 @@ document.addEventListener('keydown',ev=>{
|
||||
const GO: Record<string,string>={a:':all',d:':directory',l:':listening'};
|
||||
let gAt=0;
|
||||
function stepEntry(by: number){
|
||||
if(VIEWS[S.feed]?.url||!S.entries.length) return;
|
||||
if(VIEWS[S.feed ?? '']?.url||!S.entries.length) return;
|
||||
const i=S.entries.findIndex(x=>x.guid===S.sel);
|
||||
const e=S.entries[i<0?0:Math.min(S.entries.length-1,Math.max(0,i+by))];
|
||||
selectEntry(e,true);
|
||||
@@ -250,7 +250,7 @@ const KEYS: Record<string,()=>void>={
|
||||
J:()=>stepFeed(1), K:()=>stepFeed(-1),
|
||||
// The toolbar's own buttons, so a key does exactly what the click does, and nothing while
|
||||
// they are disabled.
|
||||
o:()=>$('#tbPlay').click(), m:()=>$('#tbRead').click(), s:()=>$('#tbFlag').click(),
|
||||
o:()=>$<HTMLButtonElement>('#tbPlay').click(), m:()=>$<HTMLButtonElement>('#tbRead').click(), s:()=>$<HTMLButtonElement>('#tbFlag').click(),
|
||||
v:()=>{ const e=cur(); if(e&&e.link) window.open(e.link,'_blank','noopener'); },
|
||||
A:()=>$('#content .fhead [data-a="read"], #content .fhead [data-a="readall"]')?.click(),
|
||||
r:async()=>{ await loadFeeds(true); if(S.feed){ renderFeed(); loadEntries(); } },
|
||||
|
||||
@@ -37,13 +37,13 @@ function setTheme(name = theme.name, mode = theme.mode, save = false){
|
||||
root.dataset.mode = !both ? 'light'
|
||||
: theme.mode === 'auto' ? (systemDark && !systemDark.matches ? 'light' : 'dark') : theme.mode;
|
||||
// The tab's icon in the same variant as the logo on the page.
|
||||
const fav = $('#favicon'); if(fav) fav.href = fav.dataset[root.dataset.mode];
|
||||
const fav = $<HTMLLinkElement>('#favicon'); if(fav) fav.href = fav.dataset[root.dataset.mode!]!;
|
||||
// The top bar's colour for an iPhone before iOS 26 to paint behind the status bar, as Android's
|
||||
// toolbar does; the page's where the bar is see-through (Glass). iOS 26 reads the bar itself.
|
||||
const tc = $('meta[name=theme-color]'), bar = $('#topbar');
|
||||
const tc = $<HTMLMetaElement>('meta[name=theme-color]'), bar = $('#topbar');
|
||||
if(tc){ const c = bar ? getComputedStyle(bar).backgroundColor : ''; tc.content = /^rgb\(/.test(c) ? c : getComputedStyle(root).backgroundColor; }
|
||||
const sel = $('#stheme'); if(sel) sel.value = theme.name;
|
||||
const ms = $('#smode'); if(ms) ms.value = theme.mode;
|
||||
const sel = $<HTMLSelectElement>('#stheme'); if(sel) sel.value = theme.name;
|
||||
const ms = $<HTMLSelectElement>('#smode'); if(ms) ms.value = theme.mode;
|
||||
const mf = $('#smodefield'); if(mf) mf.hidden = !both;
|
||||
if(save) saveTheme();
|
||||
}
|
||||
@@ -62,6 +62,6 @@ systemDark?.addEventListener?.('change', () => { if(theme.mode === 'auto') setTh
|
||||
// becomes its cookie, once.
|
||||
let name: string | null = null, mode: string | null = null;
|
||||
try{ name = localStorage.getItem('ipx.theme'); mode = localStorage.getItem('ipx.mode'); }catch{}
|
||||
if(OLD_THEMES[name]) [name, mode] = OLD_THEMES[name];
|
||||
if(name&&OLD_THEMES[name]) [name, mode] = OLD_THEMES[name];
|
||||
setTheme(name ?? undefined, mode ?? undefined, !!name);
|
||||
})();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use strict';
|
||||
// `any`: the page reads .value, .dataset and .onclick off whatever it looks up, and the
|
||||
// smoke test, not the type checker, is what makes sure a selector exists.
|
||||
const $ = (s: string, r: ParentNode = document): any => r.querySelector(s);
|
||||
const $$ = (s: string, r: ParentNode = document): any[] => [...r.querySelectorAll(s)];
|
||||
const $ = <T extends HTMLElement = HTMLElement>(s: string, r: ParentNode = document): T => r.querySelector<T>(s)!;
|
||||
const $$ = <T extends HTMLElement = HTMLElement>(s: string, r: ParentNode = document): T[] => [...r.querySelectorAll<T>(s)];
|
||||
/// What went wrong, from whatever a catch caught: the page throws Errors, but a catch cannot know.
|
||||
const why = (e: unknown): string => e instanceof Error ? e.message : String(e);
|
||||
const ESCAPED: Record<string,string> = {'&':'&','<':'<','>':'>','"':'"',"'":'''};
|
||||
@@ -61,7 +61,7 @@ const EQ='<span class="eq" aria-hidden="true"><i></i><i></i><i></i></span>';
|
||||
// One meaning per icon: minus unsubscribes, x closes or cancels, plus adds or subscribes, and a
|
||||
// dialog's confirm button carries the icon of what it does. Words go in the tooltip.
|
||||
// The page's own buttons name their icon; this draws it in, ahead of any label they carry.
|
||||
for(const b of $$('[data-icon]')) b.insertAdjacentHTML('afterbegin',ICON[b.dataset.icon]);
|
||||
for(const b of $$('[data-icon]')) b.insertAdjacentHTML('afterbegin',ICON[b.dataset.icon!]);
|
||||
|
||||
/// An episode whose DeadAir copy is still being made: it plays as it is until then, and the line
|
||||
/// becomes how much shorter it is once the copy is ready (the dead_air event).
|
||||
@@ -198,7 +198,7 @@ function tileHTML(name: string,cls?: string){
|
||||
function artSrc(url: string){
|
||||
return /^https?:\/\//i.test(url) ? '/api/art?u='+encodeURIComponent(url) : url;
|
||||
}
|
||||
function artHTML(url: string | null, name: string, cls?: string){
|
||||
function artHTML(url: string | null | undefined, name: string, cls?: string){
|
||||
return url
|
||||
? `<img class="art ${cls||''}" src="${esc(artSrc(url))}" alt="" loading="lazy" onerror="this.outerHTML=${esc(JSON.stringify(tileHTML(name,cls)))}">`
|
||||
: tileHTML(name,cls);
|
||||
@@ -209,7 +209,7 @@ function folderArt(f: Feed,kids: Feed[]){
|
||||
if(art.length<4) return artHTML(f.image,f.title||f.id);
|
||||
// Tinted underneath, so art that fails to load leaves colour behind rather than a hole.
|
||||
return `<div class="art ini mosaic" style="--tint:${tint(f.title||f.id)}">${art.map(c=>
|
||||
`<img src="${esc(artSrc(c.image))}" alt="" loading="lazy" onerror="this.style.visibility='hidden'">`).join('')}</div>`;
|
||||
`<img src="${esc(artSrc(c.image!))}" alt="" loading="lazy" onerror="this.style.visibility='hidden'">`).join('')}</div>`;
|
||||
}
|
||||
|
||||
/// The sidebar slides over the page on a phone, so it needs a scrim to tap away.
|
||||
@@ -225,7 +225,7 @@ const S: State = {
|
||||
filter:(()=>{ try{ return localStorage.getItem('ipx.filter'); }catch{ return null; } })()||'all',
|
||||
q:'', sel:null, me:null,
|
||||
// The item table's order, kept across visits. The server sorts: a list arrives fifty at a time.
|
||||
sort:(()=>{ try{ return JSON.parse(localStorage.getItem('ipx.sort')); }catch{ return null; } })()
|
||||
sort:(()=>{ try{ return JSON.parse(localStorage.getItem('ipx.sort')??'null'); }catch{ return null; } })()
|
||||
||{col:'published',dir:'desc'},
|
||||
};
|
||||
const LIMIT = 50;
|
||||
|
||||
Reference in New Issue
Block a user