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:
@@ -154,9 +154,11 @@ node tests/page-smoke.js
|
|||||||
node --test tests/page-unit.js
|
node --test tests/page-unit.js
|
||||||
```
|
```
|
||||||
|
|
||||||
The first type-checks `web/src`: every parameter and variable has a type, and what the API sends
|
The first type-checks `web/src` in TypeScript's `strict` mode: every parameter and variable has a
|
||||||
is typed in `web/src/types.ts`, which mirrors the Rust structs and changes with them. Not yet
|
type, what the API sends is typed in `web/src/types.ts`, which mirrors the Rust structs and changes
|
||||||
null-safe: `strictNullChecks` is off and `$` returns `any` (#236). The second
|
with them, and a value that may be null has to be checked before use. `$<T>` returns the element
|
||||||
|
type it is given (`$<HTMLInputElement>('#nurl')`) and assumes the element is there; one that may
|
||||||
|
not be is written `$(...)?.`, and the page check below proves the ones wired at load exist. The second
|
||||||
builds the page as shipped and runs its script against a stub DOM, checking every selector it
|
builds the page as shipped and runs its script against a stub DOM, checking every selector it
|
||||||
wires at load actually exists. That check exists because a patch once anchored on a deleted
|
wires at load actually exists. That check exists because a patch once anchored on a deleted
|
||||||
function, `String.replace` silently matched nothing, and the whole UI died with a
|
function, `String.replace` silently matched nothing, and the whole UI died with a
|
||||||
|
|||||||
@@ -5,14 +5,9 @@
|
|||||||
"target": "es2022",
|
"target": "es2022",
|
||||||
"lib": ["es2022", "dom", "dom.iterable"],
|
"lib": ["es2022", "dom", "dom.iterable"],
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
// strict, a flag at a time (#235); strictNullChecks still to come, with a typed $ (#236).
|
// Strict throughout (#235, #236). $ assumes the element is there: the page check proves the
|
||||||
"strict": false,
|
// ones wired at load exist, and a lookup that may find nothing says so with ?.
|
||||||
"noImplicitAny": true,
|
"strict": true,
|
||||||
"strictFunctionTypes": true,
|
|
||||||
"strictBindCallApply": true,
|
|
||||||
"noImplicitThis": true,
|
|
||||||
"alwaysStrict": true,
|
|
||||||
"useUnknownInCatchVariables": true,
|
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["web/src/*.ts"]
|
"include": ["web/src/*.ts"]
|
||||||
|
|||||||
@@ -56,15 +56,15 @@ async function drawServer(){
|
|||||||
<div class="field"><label>Download folder</label>
|
<div class="field"><label>Download folder</label>
|
||||||
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
|
<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>`;
|
<div class="cardacts"><button class="btn primary" id="gsave">${ICON.check} Save</button></div>`;
|
||||||
$('#gsave').onclick = async () => {
|
$<HTMLButtonElement>('#gsave').onclick = async () => {
|
||||||
try{
|
try{
|
||||||
await api('/api/settings', {method: 'PATCH', body: JSON.stringify({
|
await api('/api/settings', {method: 'PATCH', body: JSON.stringify({
|
||||||
schedule: `every ${Math.max(1, Number($('#gnum').value) || 1)}${$('#gunit').value}`,
|
schedule: `every ${Math.max(1, Number($<HTMLInputElement>('#gnum').value) || 1)}${$<HTMLSelectElement>('#gunit').value}`,
|
||||||
max_new_per_check: Math.max(0, Number($('#gmax').value) || 0),
|
max_new_per_check: Math.max(0, Number($<HTMLInputElement>('#gmax').value) || 0),
|
||||||
media_types: $('#gtypes').value.split(',').map((t: string) => t.trim()).filter(Boolean),
|
media_types: $<HTMLInputElement>('#gtypes').value.split(',').map((t: string) => t.trim()).filter(Boolean),
|
||||||
max_total_gb: Number($('#gquota').value) || 0,
|
max_total_gb: Number($<HTMLInputElement>('#gquota').value) || 0,
|
||||||
max_age_days: Number($('#gage').value) || 0,
|
max_age_days: Number($<HTMLInputElement>('#gage').value) || 0,
|
||||||
art_cache_mb: Math.max(0, Number($('#gart').value) || 0)})});
|
art_cache_mb: Math.max(0, Number($<HTMLInputElement>('#gart').value) || 0)})});
|
||||||
toast('Settings saved');
|
toast('Settings saved');
|
||||||
}catch(e){ toast(why(e), true); }
|
}catch(e){ toast(why(e), true); }
|
||||||
};
|
};
|
||||||
@@ -101,13 +101,13 @@ async function drawDirectorySettings(){
|
|||||||
drawDirectorySettings();
|
drawDirectorySettings();
|
||||||
}catch(e){ toast(why(e), true); }
|
}catch(e){ toast(why(e), true); }
|
||||||
};
|
};
|
||||||
$('#dsave').onclick = () => {
|
$<HTMLButtonElement>('#dsave').onclick = () => {
|
||||||
const patch: Record<string, unknown> = {directory_url: $('#durl').value.trim(), directory_share: $('#dshare').checked};
|
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.
|
// 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');
|
save(patch, 'Directory settings saved');
|
||||||
};
|
};
|
||||||
const clear = $('#dclear');
|
const clear = $<HTMLButtonElement>('#dclear');
|
||||||
if(clear) clear.onclick = () => save({directory_token: ''}, 'Token cleared');
|
if(clear) clear.onclick = () => save({directory_token: ''}, 'Token cleared');
|
||||||
drawDirectoryStatus();
|
drawDirectoryStatus();
|
||||||
}
|
}
|
||||||
@@ -161,12 +161,12 @@ async function drawDirectoryAdmin(){
|
|||||||
const row = b.closest<HTMLElement>('.urow');
|
const row = b.closest<HTMLElement>('.urow');
|
||||||
try{
|
try{
|
||||||
if(b.dataset.a === 'drop'){
|
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;
|
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'});
|
await api(`/api/directory-instances/${encodeURIComponent(name)}`, {method: 'DELETE'});
|
||||||
toast(`Dropped ${name}'s snapshot`);
|
toast(`Dropped ${name}'s snapshot`);
|
||||||
}else if(b.dataset.a === 'unblock'){
|
}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');
|
toast('Unblocked. It comes back with the next snapshot that has it');
|
||||||
}
|
}
|
||||||
drawDirectoryAdmin();
|
drawDirectoryAdmin();
|
||||||
@@ -205,18 +205,18 @@ async function drawAccounts(){
|
|||||||
drawAccounts(); // on a refusal, this puts the checkbox back where the server left it
|
drawAccounts(); // on a refusal, this puts the checkbox back where the server left it
|
||||||
};
|
};
|
||||||
for(const row of $$('#accounts [data-id]')){
|
for(const row of $$('#accounts [data-id]')){
|
||||||
const u = users.find(x => String(x.id) === row.dataset.id);
|
const u = users.find(x => String(x.id) === row.dataset.id)!;
|
||||||
$('[data-a="admin"]', row).onchange = (e: Event & {target: HTMLInputElement}) =>
|
$('[data-a="admin"]', row).onchange = (e: Event) =>
|
||||||
change(u, {method: 'PATCH', body: JSON.stringify({admin: e.target.checked})});
|
change(u, {method: 'PATCH', body: JSON.stringify({admin: (e.target as HTMLInputElement).checked})});
|
||||||
$('[data-a="rm"]', row).onclick = () => {
|
$('[data-a="rm"]', row).onclick = () => {
|
||||||
if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`))
|
if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`))
|
||||||
change(u, {method: 'DELETE'});
|
change(u, {method: 'DELETE'});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
$('#uadd').onclick = async () => {
|
$<HTMLButtonElement>('#uadd').onclick = async () => {
|
||||||
try{
|
try{
|
||||||
await api('/api/users', {method: 'POST', body: JSON.stringify({
|
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();
|
toast('Added'); drawAccounts();
|
||||||
}catch(e){ toast(why(e), true); } // keep what was typed
|
}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
|
The buffer keeps debug detail even when the terminal does not; <b>IPX_UI_LOG</b> changes
|
||||||
what it captures.</span>`;
|
what it captures.</span>`;
|
||||||
for(const b of $$('#logtabs button')) b.onclick = () => {
|
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);
|
for(const x of $$('#logtabs button')) x.classList.toggle('on', x.dataset.t === logTab);
|
||||||
drawLog();
|
drawLog();
|
||||||
};
|
};
|
||||||
$('#loglevel').onchange = (e: Event & {target: HTMLSelectElement}) => { logLevel = e.target.value; drawLog(); };
|
$<HTMLSelectElement>('#loglevel').onchange = (e: Event) => { logLevel = (e.target as HTMLSelectElement).value; drawLog(); };
|
||||||
$('#logq').oninput = (e: Event & {target: HTMLInputElement}) => { logFilter = e.target.value.toLowerCase(); drawLog(); };
|
$<HTMLInputElement>('#logq').oninput = (e: Event) => { logFilter = (e.target as HTMLInputElement).value.toLowerCase(); drawLog(); };
|
||||||
$('#logcopy').onclick = () => copyText(visibleLog().map(l =>
|
$<HTMLButtonElement>('#logcopy').onclick = () => copyText(visibleLog().map(l =>
|
||||||
`${new Date(l.ts * 1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $('#logcopy'));
|
`${new Date(l.ts * 1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $<HTMLButtonElement>('#logcopy'));
|
||||||
pollLog();
|
pollLog();
|
||||||
if(!logTimer) logTimer = setInterval(pollLog, 2000);
|
if(!logTimer) logTimer = setInterval(pollLog, 2000);
|
||||||
}
|
}
|
||||||
@@ -294,7 +294,7 @@ function visibleLog(){
|
|||||||
}
|
}
|
||||||
function drawLog(){
|
function drawLog(){
|
||||||
const box = $('#logbox'); if(!box) return;
|
const box = $('#logbox'); if(!box) return;
|
||||||
const follow = $('#logfollow')?.checked;
|
const follow = $<HTMLInputElement>('#logfollow')?.checked;
|
||||||
const rows = visibleLog();
|
const rows = visibleLog();
|
||||||
box.innerHTML = rows.length ? rows.map(l => {
|
box.innerHTML = rows.length ? rows.map(l => {
|
||||||
const t = new Date(l.ts * 1000).toLocaleTimeString();
|
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(); };
|
$('#modal').onclick=(e: MouseEvent)=>{ if((e.target as Element).id==='modal') closeModal(); };
|
||||||
|
|
||||||
$('#addFeed').onclick=()=>{
|
$<HTMLButtonElement>('#addFeed').onclick=()=>{
|
||||||
openModal(`<h3>Add a feed</h3>
|
openModal(`<h3>Add a feed</h3>
|
||||||
<div class="field"><label>Feed URL</label><input type="text" id="nurl" placeholder="https://example.com/rss">
|
<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>
|
<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>
|
<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>
|
<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>`);
|
<button class="btn ico primary" id="nsave" title="Add feed" aria-label="Add feed">${ICON.plus}</button></div>`);
|
||||||
$('#nurl').focus();
|
$<HTMLInputElement>('#nurl').focus();
|
||||||
$('#nsave').onclick=async()=>{
|
$<HTMLButtonElement>('#nsave').onclick=async()=>{
|
||||||
const url=$('#nurl').value.trim(); if(!url) return;
|
const url=$<HTMLInputElement>('#nurl').value.trim(); if(!url) return;
|
||||||
$('#nsave').disabled=true; $('#nsave').title='Adding…';
|
$<HTMLButtonElement>('#nsave').disabled=true; $<HTMLButtonElement>('#nsave').title='Adding…';
|
||||||
try{
|
try{
|
||||||
const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({
|
const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({
|
||||||
url, allow_explicit:$('#nexp').checked,
|
url, allow_explicit:$<HTMLInputElement>('#nexp').checked,
|
||||||
keywords:$('#nkw').value.split(',').map((s: string)=>s.trim()).filter(Boolean)})});
|
keywords:$<HTMLInputElement>('#nkw').value.split(',').map((s: string)=>s.trim()).filter(Boolean)})});
|
||||||
closeModal(); toast(r.existing?`Already subscribed as ${r.id}`:`Added ${r.id}`);
|
closeModal(); toast(r.existing?`Already subscribed as ${r.id}`:`Added ${r.id}`);
|
||||||
await loadFeeds(true); selectFeed(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;
|
html=head(`Results for “${q}”`,tiles.length?count(tiles.length):`No ${word} here has that in its name.`,dirCat||'')+tools()+grid;
|
||||||
}else if(!dirCat){
|
}else if(!dirCat){
|
||||||
chart=top(rows);
|
chart=top(rows);
|
||||||
const cats=new Map<string,any[]>();
|
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); }
|
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.
|
// Other, the feeds with no category, goes last.
|
||||||
const order=[...cats.keys()].sort((a,b)=>Number(a===OTHER)-Number(b===OTHER)||a.localeCompare(b));
|
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.`)
|
html=head('Directory',`${count(rows.length)} shared by iPX servers. Private feeds are never listed.`)
|
||||||
+(chart.length?sec('Most subscribed',board):'')
|
+(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>`);
|
`<button type="button" class="dall" data-go="${EVERY}">See all ${rows.length.toLocaleString()}</button>`);
|
||||||
}else{
|
}else{
|
||||||
const inCat=rows.filter(of(dirCat));
|
const inCat=rows.filter(of(dirCat));
|
||||||
const subs=dirCat===EVERY?[]:[...new Set(inCat.map(p=>p.subcategory).filter(Boolean))].sort();
|
const subs=dirCat===EVERY?[]:[...new Set(inCat.map(p=>p.subcategory).filter((s): s is string=>!!s))].sort();
|
||||||
if(!subs.includes(dirSub)) dirSub=null;
|
if(!dirSub||!subs.includes(dirSub)) dirSub=null;
|
||||||
if(dirCat!==EVERY) chart=top(inCat);
|
if(dirCat!==EVERY) chart=top(inCat);
|
||||||
tiles=inCat.filter(p=>!dirSub||p.subcategory===dirSub);
|
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);
|
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);
|
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))); }
|
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);
|
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)=>{
|
box.onclick=(ev: MouseEvent)=>{
|
||||||
const b=(ev.target as Element).closest<HTMLElement>('[data-go],[data-kind],[data-sub],[data-back]');
|
const b=(ev.target as Element).closest<HTMLElement>('[data-go],[data-kind],[data-sub],[data-back]');
|
||||||
if(!b) return;
|
if(!b) return;
|
||||||
// Back from a feed's page, onto the tile or row it was opened from.
|
// 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.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)}"]`); }
|
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.
|
// Going anywhere ends a search.
|
||||||
if(S.q){ S.q=''; $('#epSearch').value=''; }
|
if(S.q){ S.q=''; $<HTMLInputElement>('#epSearch').value=''; }
|
||||||
const from=dirCat;
|
const from=dirCat;
|
||||||
dirCat=b.dataset.go||null; dirSub=null; dirShow=null;
|
dirCat=b.dataset.go||null; dirSub=null; dirShow=null;
|
||||||
$('#content').scrollTop=0;
|
$('#content').scrollTop=0;
|
||||||
// Into a page, onto its way back; back out, onto the tile you went in by.
|
// 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);
|
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(s) s.onchange=()=>{ dirSort=s.value; drawDirectory(box,'#dirsort'); };
|
||||||
if(focus) $(focus,box)?.focus();
|
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}){
|
async function renderListed(v: {title: string, icon: string, url?: string, blurb?: string}){
|
||||||
const box=$('#content');
|
const box=$('#content');
|
||||||
box.classList.add('plain');
|
box.classList.add('plain');
|
||||||
$('#tbRemove').disabled=true;
|
$<HTMLButtonElement>('#tbRemove').disabled=true;
|
||||||
syncTools(null);
|
syncTools(null);
|
||||||
if(v===VIEWS[':directory']){
|
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>';
|
box.innerHTML='<div id="directory"><p class="hint">Loading…</p></div>';
|
||||||
$('#count').textContent=v.title;
|
$('#count').textContent=v.title;
|
||||||
const n=await renderDirectory(v.url,$('#directory'));
|
const n=await renderDirectory(v.url!,$('#directory'));
|
||||||
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,'feed')}`;
|
if(VIEWS[S.feed ?? '']===v) $('#count').textContent=`${v.title}: ${plural(n,'feed')}`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$('#epSearch').placeholder='Search items…';
|
$<HTMLInputElement>('#epSearch').placeholder='Search items…';
|
||||||
box.innerHTML=`
|
box.innerHTML=`
|
||||||
<div class="fhead slim">
|
<div class="fhead slim">
|
||||||
<div class="art">${v.icon}</div>
|
<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>`;
|
<div class="childlist" id="listening"><p class="hint">Loading…</p></div>`;
|
||||||
$('#count').textContent=v.title;
|
$('#count').textContent=v.title;
|
||||||
// The search box looks through these too, title and text, as it does a feed's items (#127).
|
// 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));
|
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')}`;
|
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
|
/// 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.
|
/// 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}){
|
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.
|
// 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);
|
if(now&&audio.currentTime) e.position=Math.floor(audio.currentTime);
|
||||||
// The player's own length first: a feed's can be minutes out.
|
// 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.
|
/// Keeps the list in step with the player. Only a row that is, or was, the one in it changes.
|
||||||
function syncListening(){
|
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);
|
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.
|
/// episode with a saved position short of the end, so the position is what has to go.
|
||||||
async function forget(e: Entry){
|
async function forget(e: Entry){
|
||||||
// Closed without saving first, or the player's next save would put it straight back.
|
// 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{
|
try{
|
||||||
await api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/position`,
|
await api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/position`,
|
||||||
{method:'POST',body:JSON.stringify({secs:0})});
|
{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.
|
// 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); };
|
$<HTMLButtonElement>('#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); };
|
$<HTMLButtonElement>('#tbPlay').onclick=()=>{ const e=cur(); if(e) play(e); };
|
||||||
$('#tbRead').onclick=()=>{ const e=cur(); if(e) epAction('read',e,null); };
|
$<HTMLButtonElement>('#tbRead').onclick=()=>{ const e=cur(); if(e) epAction('read',e,null); };
|
||||||
$('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); };
|
$<HTMLButtonElement>('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); };
|
||||||
let searchT: number;
|
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
|
// The Directory looks through what it already has; Currently Listening and the rest ask the
|
||||||
// server (#126, #127).
|
// 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(); }
|
if(S.feed===':directory'){ dirShow=null; drawDirectory(); }
|
||||||
else if(S.feed===':listening') renderListed(VIEWS[':listening']);
|
else if(S.feed===':listening') renderListed(VIEWS[':listening']);
|
||||||
else loadEntries(); },250); };
|
else loadEntries(); },250); };
|
||||||
@@ -429,27 +429,27 @@ async function prefsModal(){
|
|||||||
<div class="field"><label>Feeds are checked every</label>
|
<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.
|
<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>`);
|
${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);
|
$<HTMLSelectElement>('#stheme').onchange=(e: Event)=>setTheme((e.target as HTMLSelectElement).value,undefined,true);
|
||||||
$('#smode').onchange=(e: Event & {target: HTMLSelectElement})=>setTheme(undefined,e.target.value,true);
|
$<HTMLSelectElement>('#smode').onchange=(e: Event)=>setTheme(undefined,(e.target as HTMLSelectElement).value,true);
|
||||||
$('#gopml').onclick=opmlModal;
|
$<HTMLButtonElement>('#gopml').onclick=opmlModal;
|
||||||
drawTokens();
|
drawTokens();
|
||||||
$('#tokadd').onclick=async()=>{
|
$<HTMLButtonElement>('#tokadd').onclick=async()=>{
|
||||||
const name=$('#tokname').value.trim(); if(!name){ $('#tokname').focus(); return; }
|
const name=$<HTMLInputElement>('#tokname').value.trim(); if(!name){ $<HTMLInputElement>('#tokname').focus(); return; }
|
||||||
try{
|
try{
|
||||||
const t=await api('/api/tokens',{method:'POST',body:JSON.stringify({name})});
|
const t=await api('/api/tokens',{method:'POST',body:JSON.stringify({name})});
|
||||||
$('#tokname').value='';
|
$<HTMLInputElement>('#tokname').value='';
|
||||||
await drawTokens();
|
await drawTokens();
|
||||||
// Shown this once: only its hash is kept, so a lost token is revoked and made again.
|
// 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,
|
$('#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>
|
${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)}">
|
<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>`);
|
<button class="btn ico" id="tokcopy" title="Copy" aria-label="Copy">${ICON.copy}</button></div></div>`);
|
||||||
$('#tokcopy').onclick=()=>copyText(t.token,$('#tokcopy'));
|
$<HTMLButtonElement>('#tokcopy').onclick=()=>copyText(t.token,$<HTMLButtonElement>('#tokcopy'));
|
||||||
$('#tokval').select();
|
$<HTMLInputElement>('#tokval').select();
|
||||||
}catch(err){ toast(why(err),true); }
|
}catch(err){ toast(why(err),true); }
|
||||||
};
|
};
|
||||||
$('#sblock').onchange=async (e: Event & {target: HTMLInputElement})=>{
|
$<HTMLInputElement>('#sblock').onchange=async (e: Event)=>{
|
||||||
const blocked=splitWords(e.target.value);
|
const blocked=splitWords((e.target as HTMLInputElement).value);
|
||||||
try{
|
try{
|
||||||
await api('/api/me',{method:'PATCH',body:JSON.stringify({blocked})});
|
await api('/api/me',{method:'PATCH',body:JSON.stringify({blocked})});
|
||||||
if(S.me) S.me.blocked=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.
|
/// The words in a chip box, with whatever is typed and not yet a chip.
|
||||||
function chipWords(box: ParentNode){
|
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){
|
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.
|
// The hint only in an empty box: beside chips it reads as one more.
|
||||||
const hint=()=>{ input.placeholder=$$('.chip',box).length?'':CHIP_HINT; };
|
const hint=()=>{ input.placeholder=$$('.chip',box).length?'':CHIP_HINT; };
|
||||||
const add=()=>{
|
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)){
|
for(const w of splitWords(input.value)){
|
||||||
if(have.includes(w.toLowerCase())) continue;
|
if(have.includes(w.toLowerCase())) continue;
|
||||||
input.insertAdjacentHTML('beforebegin',chipHTML(w)); have.push(w.toLowerCase());
|
input.insertAdjacentHTML('beforebegin',chipHTML(w)); have.push(w.toLowerCase());
|
||||||
@@ -509,7 +509,7 @@ function wireChips(box: HTMLElement){
|
|||||||
input.onblur=add;
|
input.onblur=add;
|
||||||
box.onclick=(e: MouseEvent)=>{
|
box.onclick=(e: MouseEvent)=>{
|
||||||
const x=(e.target as Element).closest('.chipx');
|
const x=(e.target as Element).closest('.chipx');
|
||||||
if(x){ x.parentElement.remove(); hint(); }
|
if(x){ x.parentElement!.remove(); hint(); }
|
||||||
input.focus();
|
input.focus();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -524,7 +524,7 @@ function settingsModal(f: Feed, newUrl?: string){
|
|||||||
const name=f.title||f.id;
|
const name=f.title||f.id;
|
||||||
// Blank follows the server's default; 0 is every item (#166).
|
// 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 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
|
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.`
|
? `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
|
: 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.
|
// What only matters while it downloads on its own folds away, out of reach, when it does not.
|
||||||
const deps=$('#sdeps'), inner=$('.fdepsin',deps);
|
const deps=$('#sdeps'), inner=$('.fdepsin',deps);
|
||||||
inner.inert=!f.auto_download;
|
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
|
// 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).
|
// newest items, not a pace through the back catalogue (#97).
|
||||||
@@ -605,13 +605,13 @@ function settingsModal(f: Feed, newUrl?: string){
|
|||||||
}
|
}
|
||||||
if(!words) return;
|
if(!words) return;
|
||||||
$('#swcap').innerHTML=caption();
|
$('#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); };
|
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.
|
// 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; };
|
$<HTMLInputElement>('#sdead').onchange=(e: Event)=>{ $<HTMLInputElement>('#sdpause').disabled=!(e.target as HTMLInputElement).checked; };
|
||||||
const pause=$('#sdpause'), pauseText=()=>{ const s=`${pause.value/1000} s`; $('#sdpauseout').textContent=s; pause.setAttribute('aria-valuetext',s); };
|
const pause=$<HTMLInputElement>('#sdpause'), pauseText=()=>{ const s=`${Number(pause.value)/1000} s`; $('#sdpauseout').textContent=s; pause.setAttribute('aria-valuetext',s); };
|
||||||
pause.oninput=pauseText; pauseText();
|
pause.oninput=pauseText; pauseText();
|
||||||
draw(true);
|
draw(true);
|
||||||
// The feed's own newest items, each as tall as it is long, so the strip reads as this show.
|
// 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;
|
if(x<=b.getBoundingClientRect().right) break;
|
||||||
}
|
}
|
||||||
newest=Math.max(1,n);
|
newest=Math.max(1,n);
|
||||||
if(mode!=='newest'){ mode='newest'; $('[name=swin][value=newest]').checked=true; draw(true); }
|
if(mode!=='newest'){ mode='newest'; $<HTMLInputElement>('[name=swin][value=newest]').checked=true; draw(true); }
|
||||||
else { $('#smax').value=String(newest); draw(false); }
|
else { $<HTMLInputElement>('#smax').value=String(newest); draw(false); }
|
||||||
};
|
};
|
||||||
strip.onpointerdown=(e: PointerEvent)=>{ strip.setPointerCapture(e.pointerId); pick(e.clientX); };
|
strip.onpointerdown=(e: PointerEvent)=>{ strip.setPointerCapture(e.pointerId); pick(e.clientX); };
|
||||||
strip.onpointermove=(e: PointerEvent)=>{ if(strip.hasPointerCapture(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'));
|
wireChips($('#skw'));
|
||||||
if($('#sblock')) wireChips($('#sblock'));
|
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.
|
// 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{
|
try{
|
||||||
const patch: Record<string, unknown>={
|
const patch: Record<string, unknown>={
|
||||||
keywords:chipWords($('#skw')),
|
keywords:chipWords($('#skw')),
|
||||||
max_new_per_check:mode==='default'?null:mode==='all'?0:newest,
|
max_new_per_check:mode==='default'?null:mode==='all'?0:newest,
|
||||||
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked, deadair:$('#sdead').checked,
|
auto_download:$<HTMLInputElement>('#sauto').checked, allow_explicit:$<HTMLInputElement>('#sexp').checked, deadair:$<HTMLInputElement>('#sdead').checked,
|
||||||
deadair_pause:Number($('#sdpause').value)};
|
deadair_pause:Number($<HTMLInputElement>('#sdpause').value)};
|
||||||
if($('#sblock')) patch.blocked=chipWords($('#sblock'));
|
if($('#sblock')) patch.blocked=chipWords($('#sblock'));
|
||||||
// Sent only when it changed: anyone may mark a feed private, but only an admin unmark it.
|
// 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;
|
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
|
// 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.
|
// 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)});
|
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify(patch)});
|
||||||
closeModal(); toast('Saved. It applies from the next check.');
|
closeModal(); toast('Saved. It applies from the next check.');
|
||||||
await loadFeeds(true); renderFeed(); loadEntries();
|
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>
|
<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>
|
<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>`);
|
<button class="btn ico primary" id="dgo" title="Download" aria-label="Download">${ICON.download}</button></div>`);
|
||||||
$('#dgo').onclick=async()=>{
|
$<HTMLButtonElement>('#dgo').onclick=async()=>{
|
||||||
const n=Number($('#dcount').value)||5;
|
const n=Number($<HTMLInputElement>('#dcount').value)||5;
|
||||||
try{
|
try{
|
||||||
const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/download-latest`,
|
const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/download-latest`,
|
||||||
{method:'POST',body:JSON.stringify({count:n})});
|
{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>
|
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>
|
<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>`);
|
<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'});
|
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'DELETE'});
|
||||||
closeModal(); toast('Unsubscribed'); S.feed=null;
|
closeModal(); toast('Unsubscribed'); S.feed=null;
|
||||||
await loadFeeds(); if(!S.feeds.length) renderFeed();
|
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>
|
<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>
|
<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>`);
|
<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
|
// 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.
|
// the picker lets go of it on this side too, whether it was refused or imported.
|
||||||
const pick=$('#opmlFile'), file=pick.files[0];
|
const pick=$<HTMLInputElement>('#opmlFile'), file=pick.files![0];
|
||||||
const xml=file ? await file.text() : $('#opmlText').value;
|
const xml=file ? await file.text() : $<HTMLTextAreaElement>('#opmlText').value;
|
||||||
const letGo=()=>{ pick.value=''; };
|
const letGo=()=>{ pick.value=''; };
|
||||||
// A quick look before sending anything. The server parses it properly and has the last word.
|
// A quick look before sending anything. The server parses it properly and has the last word.
|
||||||
if(!/<opml[\s>]/i.test(xml)){
|
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})}); }
|
try{ await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
|
||||||
catch(e){ spinEnd(); throw e; }
|
catch(e){ spinEnd(); throw e; }
|
||||||
}
|
}
|
||||||
$('#scanAll').onclick=scanAll;
|
$<HTMLButtonElement>('#scanAll').onclick=scanAll;
|
||||||
$('#prefs').onclick=prefsModal;
|
$<HTMLButtonElement>('#prefs').onclick=prefsModal;
|
||||||
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while
|
// 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.
|
// 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=>{
|
api('/api/me').then(u=>{
|
||||||
S.me=u;
|
S.me=u;
|
||||||
$('#who').textContent=u.name+(u.admin?' · admin':'');
|
$('#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.
|
// 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);
|
if(u.theme&&!/(^|; )ipx_theme=/.test(document.cookie)) setTheme(u.theme.name,u.theme.mode,true);
|
||||||
}).catch(()=>{});
|
}).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
|
// 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.
|
// folder. Handled keys stop here, or the player's own Space and arrows would act on them too.
|
||||||
$('#feedlist').onkeydown=(ev: KeyboardEvent)=>{
|
$('#feedlist').onkeydown=(ev: KeyboardEvent)=>{
|
||||||
const row=(ev.target as Element).closest<HTMLElement>('[data-id]'); if(!row) return;
|
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();
|
if((ev.key==='Enter'||ev.key===' ')&&ev.target===row) row.click();
|
||||||
else if(row.classList.contains('group')&&
|
else if(row.classList.contains('group')&&
|
||||||
(ev.key==='ArrowRight'&&!expanded.has(id)||ev.key==='ArrowLeft'&&expanded.has(id))) toggleGroup(id);
|
(ev.key==='ArrowRight'&&!expanded.has(id)||ev.key==='ArrowLeft'&&expanded.has(id))) toggleGroup(id);
|
||||||
else return;
|
else return;
|
||||||
ev.preventDefault(); ev.stopPropagation();
|
ev.preventDefault(); ev.stopPropagation();
|
||||||
};
|
};
|
||||||
$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
|
$<HTMLButtonElement>('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
|
||||||
$('#scrim').onclick=()=>nav(false);
|
$('#scrim').onclick=()=>nav(false);
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ function connect(){
|
|||||||
// Only the row actually downloading. Without the enclosure id this used to paint
|
// 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.
|
// 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`);
|
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)}%`);
|
$('#count') && ($('#count').textContent=`downloading ${ev.file} — ${pct.toFixed(0)}%`);
|
||||||
}
|
}
|
||||||
else if(ev.ev==='download_done'){
|
else if(ev.ev==='download_done'){
|
||||||
|
|||||||
@@ -3,16 +3,16 @@ function renderFeed(){
|
|||||||
paintScanning(); // another feed's page may be the one open now
|
paintScanning(); // another feed's page may be the one open now
|
||||||
const box=$('#content');
|
const box=$('#content');
|
||||||
box.classList.remove('plain');
|
box.classList.remove('plain');
|
||||||
const v=VIEWS[S.feed];
|
const v=VIEWS[S.feed ?? ''];
|
||||||
if(v&&v.url) return renderListed(v);
|
if(v&&v.url) return renderListed(v);
|
||||||
const f=v?null:S.feeds.find(x=>x.id===S.feed);
|
const f=v?null:S.feeds.find(x=>x.id===S.feed);
|
||||||
$('#tbRemove').disabled=!f;
|
$<HTMLButtonElement>('#tbRemove').disabled=!f;
|
||||||
syncTools(null);
|
syncTools(null);
|
||||||
if(!v&&!f){ box.innerHTML='<p class="empty">Add a feed to get started.</p>'; $('#count').textContent=''; return; }
|
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;
|
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):[];
|
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);
|
const unreadAll=S.feeds.reduce((n,x)=>n+(x.unread||0),0);
|
||||||
box.innerHTML = (f ? `
|
box.innerHTML = (f ? `
|
||||||
<div class="fhead slim">
|
<div class="fhead slim">
|
||||||
@@ -61,7 +61,7 @@ function renderFeed(){
|
|||||||
if(f) pane.className='one';
|
if(f) pane.className='one';
|
||||||
pane.innerHTML='<div id="list">'+sortHead()+
|
pane.innerHTML='<div id="list">'+sortHead()+
|
||||||
'<div id="eps"></div></div><div id="files"></div><div id="grab"></div><div id="detail"></div>';
|
'<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);
|
box.appendChild(pane);
|
||||||
pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '60%');
|
pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '60%');
|
||||||
dragSplit(pane);
|
dragSplit(pane);
|
||||||
@@ -69,9 +69,9 @@ function renderFeed(){
|
|||||||
S.sel=null;
|
S.sel=null;
|
||||||
showDetail(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=()=>{
|
$$('#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{}
|
try{ localStorage.setItem('ipx.filter',S.filter); }catch{}
|
||||||
renderFeed(); loadEntries();
|
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>
|
<span style="color:var(--faint);font-size:12.5px">${esc(f.url)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="childlist" id="kidlist"></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);
|
wireFailBanner($('#content'),f);
|
||||||
const draw=()=>{
|
const draw=()=>{
|
||||||
const q=($('#kidSearch').value||'').trim().toLowerCase();
|
const q=($<HTMLInputElement>('#kidSearch').value||'').trim().toLowerCase();
|
||||||
const box=$('#kidlist'); box.innerHTML='';
|
const box=$('#kidlist'); box.innerHTML='';
|
||||||
const rows=kids.filter(c=>!q||(c.title||c.id).toLowerCase().includes(q)).sort(unreadFirst);
|
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; }
|
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);
|
box.appendChild(el);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
$('#kidSearch').oninput=draw;
|
$<HTMLInputElement>('#kidSearch').oninput=draw;
|
||||||
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.
|
// brand new account) leaves S.feed alone, so the empty state's own message shows instead.
|
||||||
if(!keepSel && S.feeds.length){
|
if(!keepSel && S.feeds.length){
|
||||||
const known = S.feed && (VIEWS[S.feed] || S.feeds.some(f=>f.id===S.feed));
|
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
|
// 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();
|
paintScanning();
|
||||||
}
|
}
|
||||||
function 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')){
|
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));
|
const on=slow(id)||S.feeds.some(c=>c.group===id&&slow(c.id));
|
||||||
row.classList.toggle('scanning',on);
|
row.classList.toggle('scanning',on);
|
||||||
if(on) row.title='Checking for new items…'; else row.removeAttribute('title');
|
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(){
|
function renderFeeds(){
|
||||||
const q=$('#feedFilter').value.trim().toLowerCase();
|
const q=$<HTMLInputElement>('#feedFilter').value.trim().toLowerCase();
|
||||||
const list=$('#feedlist'); const top=list.scrollTop;
|
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.
|
// 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 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 back=was&&([was.dataset.id,a.classList.contains('chev')] as [string, boolean]);
|
||||||
const done=()=>{
|
const done=()=>{
|
||||||
list.scrollTop=top;
|
list.scrollTop=top;
|
||||||
const row=back&&list.querySelector(`[data-id="${CSS.escape(back[0])}"]`);
|
const row=back&&list.querySelector<HTMLElement>(`[data-id="${CSS.escape(back[0])}"]`);
|
||||||
if(row) (back[1]&&$('.chev',row)||row).focus();
|
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 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
|
// 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.
|
// feed's new row came in (#134). Matched by their markup, so a changed one is made anew.
|
||||||
const drawn=new Map<string,HTMLImageElement[]>();
|
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='';
|
list.innerHTML='';
|
||||||
const unreadAll=S.feeds.reduce((n,f)=>n+(f.unread||0),0);
|
const unreadAll=S.feeds.reduce((n,f)=>n+(f.unread||0),0);
|
||||||
const places=document.createElement('div');
|
const places=document.createElement('div');
|
||||||
@@ -168,7 +168,7 @@ function renderFeeds(){
|
|||||||
done();
|
done();
|
||||||
}
|
}
|
||||||
function selectFeed(id: string){
|
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{}
|
try{ localStorage.setItem('ipx.feed',id); }catch{}
|
||||||
renderFeeds(); renderFeed(); loadEntries();
|
renderFeeds(); renderFeed(); loadEntries();
|
||||||
}
|
}
|
||||||
@@ -185,6 +185,6 @@ function failBannerHTML(f: Feed){
|
|||||||
}
|
}
|
||||||
function wireFailBanner(box: ParentNode,f: Feed){
|
function wireFailBanner(box: ParentNode,f: Feed){
|
||||||
const un=$('[data-ffail="unsub"]',box); if(un) un.onclick=()=>removeFeed(f);
|
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.
|
// back button is never swallowed with it.
|
||||||
if(t.clientX < 14 && narrow() && ev.cancelable) ev.preventDefault();
|
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});
|
}, {passive: false});
|
||||||
|
|
||||||
document.addEventListener('touchmove', ev => {
|
document.addEventListener('touchmove', ev => {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ async function loadEntries(append?: boolean){
|
|||||||
S.total=r.total;
|
S.total=r.total;
|
||||||
for(const e of r.entries){
|
for(const e of r.entries){
|
||||||
const w=readWrites.get(readKey(e));
|
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;
|
let entries = append ? S.entries.concat(r.entries) : r.entries;
|
||||||
// A background scan finishing refreshes the list from the server, which -- on the Unread
|
// 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(){
|
function renderEntries(){
|
||||||
const box=$('#eps'); if(!box) return;
|
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);
|
const unread=f?f.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0);
|
||||||
$('#count').textContent=
|
$('#count').textContent=
|
||||||
`${f?(f.title||f.id):v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`;
|
`${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>`,
|
num&&`<span>${num}</span>`,
|
||||||
left&&`<span>${left}</span>`,
|
left&&`<span>${left}</span>`,
|
||||||
others>0&&`<span>+${others} more file${others===1?'':'s'}</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.deadair_pending&&DEAD_PENDING,
|
||||||
enc&&enc.last_error&&enc.state==='error'&&`<span style="color:var(--bad)">${esc(enc.last_error)}</span>`,
|
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>
|
].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>`:''}
|
${has?`<button class="iconbtn" data-a="del" title="Delete file" aria-label="Delete file">${ICON.trash}</button>`:''}
|
||||||
</div>`;
|
</div>`;
|
||||||
el.onclick=()=>selectEntry(e);
|
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;
|
return el;
|
||||||
}
|
}
|
||||||
/// Whether the browser can play it. Having a file is not the same as being playable:
|
/// 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
|
/// blog feeds put article images in enclosures, and an <audio> element pointed at a JPEG
|
||||||
/// is just a broken player.
|
/// is just a broken player.
|
||||||
function isPlayable(x: Enclosure){
|
function isPlayable(x: Enclosure|undefined){
|
||||||
if(!x || !x.path) return false;
|
if(!x || !x.path) return false;
|
||||||
const m=(x.mime||'').toLowerCase();
|
const m=(x.mime||'').toLowerCase();
|
||||||
if(m.startsWith('audio/')||m.startsWith('video/')) return true;
|
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; }
|
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,
|
/// 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
|
/// 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
|
/// 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"
|
/// element fetches its images. A line break or paragraph's end is a space, or "2010)<br>Recorded"
|
||||||
/// read "2010)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||'')
|
? (new DOMParser().parseFromString(html.replace(/<(br|\/p|\/div|\/li|\/h\d)\b[^>]*>/gi,' $&'),'text/html').body.textContent||'')
|
||||||
.replace(/\s+/g,' ').trim() : '';
|
.replace(/\s+/g,' ').trim() : '';
|
||||||
function entryName(e: Partial<Entry>): {text: string, derived: boolean}{
|
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;
|
const cut=words.length>120 ? words.slice(0,120).replace(/\s+\S*$/,'')+'…' : words;
|
||||||
return {text:cut, derived:true};
|
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}; } }
|
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.
|
/// 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.
|
// the first. Key rows by feed as well if that ever happens.
|
||||||
const cur=()=>S.entries.find(x=>x.guid===S.sel);
|
const cur=()=>S.entries.find(x=>x.guid===S.sel);
|
||||||
function syncTools(e: Entry|null){
|
function syncTools(e: Entry|null){
|
||||||
$('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable));
|
$<HTMLButtonElement>('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable));
|
||||||
$('#tbRead').disabled=$('#tbFlag').disabled=!e;
|
$<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
|
// 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
|
// 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.
|
// side said opposite things (#74). The shape carries it, not the colour.
|
||||||
$('#tbRead').innerHTML=e&&!e.read?ICON.unread:ICON.check;
|
$<HTMLButtonElement>('#tbRead').innerHTML=e&&!e.read?ICON.unread:ICON.check;
|
||||||
$('#tbRead').setAttribute('aria-pressed',String(!!(e&&e.read)));
|
$<HTMLButtonElement>('#tbRead').setAttribute('aria-pressed',String(!!(e&&e.read)));
|
||||||
$('#tbFlag').setAttribute('aria-pressed',String(!!(e&&e.flagged)));
|
$<HTMLButtonElement>('#tbFlag').setAttribute('aria-pressed',String(!!(e&&e.flagged)));
|
||||||
$('#tbFlag').innerHTML=e&&e.flagged?ICON.pinOn:ICON.pin;
|
$<HTMLButtonElement>('#tbFlag').innerHTML=e&&e.flagged?ICON.pinOn:ICON.pin;
|
||||||
if(e){
|
if(e){
|
||||||
$('#tbRead').title=`Mark ${e.read?'unread':'read'}`;
|
$<HTMLButtonElement>('#tbRead').title=`Mark ${e.read?'unread':'read'}`;
|
||||||
$('#tbFlag').title=e.flagged?'Unpin':'Pin, so it is never deleted';
|
$<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.
|
/// 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;
|
const box=$('#detail'), files=$('#files'); if(!box) return;
|
||||||
// A pane that can only say "No files" gives its width to the list instead.
|
// A pane that can only say "No files" gives its width to the list instead.
|
||||||
if(files) files.hidden=!(e&&e.enclosures.length);
|
if(files) files.hidden=!(e&&e.enclosures.length);
|
||||||
@@ -278,9 +278,9 @@ function showDetail(e: Entry){
|
|||||||
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
|
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
|
||||||
box.innerHTML=detailHtml(e);
|
box.innerHTML=detailHtml(e);
|
||||||
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
|
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=>
|
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();
|
syncPlayButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,8 +328,9 @@ function encBox(x: Enclosure){
|
|||||||
: 'Delete file';
|
: 'Delete file';
|
||||||
const delBtn=`<button class="btn ico danger" data-a="del" data-enc="${x.id}" title="${delLabel}" aria-label="${delLabel}">${ICON.trash}</button>`;
|
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).
|
// 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 dead=(x.deadair??0)>0;
|
||||||
const saveBtn=`<a class="btn ico" href="/media/${x.id}${x.deadair>0?'/deadair':''}" download title="${saveLabel}" aria-label="${saveLabel}">${ICON.save}</a>`;
|
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.
|
// 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>`;
|
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)){
|
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)}`;
|
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
|
||||||
try{
|
try{
|
||||||
if(a==='play') play(e, encId!=null ? enc : undefined);
|
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==='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==='read'){ await setRead(e,!e.read); redraw(); }
|
||||||
if(a==='get'){
|
if(a==='get'){
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
document.getElementById('f').onsubmit = async e => {
|
document.getElementById('f')!.onsubmit = async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const msg = document.getElementById('msg');
|
const msg = document.getElementById('msg')!;
|
||||||
msg.textContent = '';
|
msg.textContent = '';
|
||||||
const r = await fetch('/api/login', {
|
const r = await fetch('/api/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* ---------------- player ---------------- */
|
/* ---------------- 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,
|
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};
|
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.
|
/// 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)){
|
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);
|
toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet', true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -84,7 +84,7 @@ function mediaSession(e: Entry,f: Feed|undefined){
|
|||||||
if(!('mediaSession' in navigator)) return;
|
if(!('mediaSession' in navigator)) return;
|
||||||
navigator.mediaSession.metadata=new MediaMetadata({
|
navigator.mediaSession.metadata=new MediaMetadata({
|
||||||
title:entryName(e).text, artist:f?(f.title||f.id):'', album:f?(f.title||''):'',
|
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(),
|
const h: Record<string,()=>void>={play:()=>audio.play(),pause:()=>audio.pause(),
|
||||||
seekbackward:()=>audio.currentTime-=15,seekforward:()=>audio.currentTime+=30};
|
seekbackward:()=>audio.currentTime-=15,seekforward:()=>audio.currentTime+=30};
|
||||||
@@ -94,7 +94,7 @@ audio.addEventListener('timeupdate',()=>{
|
|||||||
const d=audio.duration||player.entry?.duration||0;
|
const d=audio.duration||player.entry?.duration||0;
|
||||||
$('#pcur').textContent=clock(audio.currentTime);
|
$('#pcur').textContent=clock(audio.currentTime);
|
||||||
$('#pdur').textContent=clock(d);
|
$('#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
|
// 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.
|
// that would write back whatever the list said, however old.
|
||||||
if(!audio.paused) player.moved=true;
|
if(!audio.paused) player.moved=true;
|
||||||
@@ -117,29 +117,29 @@ function savePos(){
|
|||||||
const duration=isFinite(audio.duration)?Math.floor(audio.duration):null;
|
const duration=isFinite(audio.duration)?Math.floor(audio.duration):null;
|
||||||
navigator.sendBeacon?.(
|
navigator.sendBeacon?.(
|
||||||
// A beacon sends no headers, so the trace goes in the address (#192).
|
// 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'}));
|
new Blob([JSON.stringify({secs:Math.floor(audio.currentTime),duration})],{type:'application/json'}));
|
||||||
}
|
}
|
||||||
audio.addEventListener('pause',savePos);
|
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.
|
// body.playing is what sets the EQ bars moving.
|
||||||
audio.addEventListener('play',()=>{ $('#pplay').innerHTML=ICON.pause; document.body.classList.add('playing'); });
|
audio.addEventListener('play',()=>{ $<HTMLButtonElement>('#pplay').innerHTML=ICON.pause; document.body.classList.add('playing'); });
|
||||||
audio.addEventListener('pause',()=>{ $('#pplay').innerHTML=ICON.play; document.body.classList.remove('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);
|
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
|
/// 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.
|
/// pane's, the toolbar's. Only the bar's used to change, so the others said play while it played.
|
||||||
function syncPlayButtons(){
|
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 paint=(b: HTMLElement,now: boolean,idle: string)=>{
|
||||||
const label=now?'Pause':idle;
|
const label=now?'Pause':idle;
|
||||||
if(b.title===label) return;
|
if(b.title===label) return;
|
||||||
b.title=label; b.setAttribute('aria-label',label); b.innerHTML=now?ICON.pause:ICON.play;
|
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]'))
|
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');
|
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);
|
for(const ev of ['play','pause','timeupdate']) audio.addEventListener(ev,syncListening);
|
||||||
window.addEventListener('beforeunload',savePos);
|
window.addEventListener('beforeunload',savePos);
|
||||||
@@ -157,23 +157,23 @@ audio.addEventListener('playing',()=>{
|
|||||||
asked=0; waited=0;
|
asked=0; waited=0;
|
||||||
});
|
});
|
||||||
audio.addEventListener('error',()=>{ if(audio.error) report('player_error',{msg:`media error ${audio.error.code}: ${audio.error.message}`,...playing()}); });
|
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();
|
$<HTMLButtonElement>('#pplay').onclick=()=>audio.paused?audio.play():audio.pause();
|
||||||
$('#pback').onclick=()=>audio.currentTime-=15;
|
$<HTMLButtonElement>('#pback').onclick=()=>audio.currentTime-=15;
|
||||||
$('#pfwd').onclick=()=>audio.currentTime+=30;
|
$<HTMLButtonElement>('#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};
|
$<HTMLInputElement>('#seek').oninput=(e: Event)=>{const d=audio.duration;if(d)audio.currentTime=d*Number((e.target as HTMLInputElement).value)/1000};
|
||||||
$('#rate').onchange=(e: Event & {target: HTMLSelectElement})=>{audio.playbackRate=+e.target.value;localStorage.setItem('ipx.rate',e.target.value)};
|
$<HTMLSelectElement>('#rate').onchange=(e: Event)=>{audio.playbackRate=+(e.target as HTMLSelectElement).value;localStorage.setItem('ipx.rate',(e.target as HTMLSelectElement).value)};
|
||||||
$('#vol').oninput=(e: Event & {target: HTMLInputElement})=>{audio.volume=Number(e.target.value)/100;localStorage.setItem('ipx.vol',e.target.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
|
/// 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.
|
/// icon's shape and aria-pressed, never a colour.
|
||||||
function paintDead(){
|
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');
|
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.setAttribute('aria-pressed',String(!!f.deadair));
|
||||||
b.innerHTML=f.deadair?ICON.deadairOn:ICON.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';
|
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 f=S.feeds.find(x=>x.id===player.feed); if(!f) return;
|
||||||
const on=!f.deadair;
|
const on=!f.deadair;
|
||||||
try{ await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({deadair:on})}); }
|
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.
|
/// turned on or off for its show. True if it changed version: a copy not made yet plays next time.
|
||||||
async function switchVersion(){
|
async function switchVersion(){
|
||||||
if(!player.guid) return false;
|
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.
|
// Saved and waited for, not beaconed, so the entry asked for next has this place in it.
|
||||||
if(audio.readyState){
|
if(audio.readyState){
|
||||||
await api(`/api/entries/${feed}/${guid}/position`,{method:'POST',body:JSON.stringify({
|
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(()=>{});
|
if(was) (wait||Promise.resolve()).then(()=>audio.play()).catch(()=>{});
|
||||||
return true;
|
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.
|
// Called here, not left to the pause event: closing a player already paused fires none.
|
||||||
syncListening()};
|
syncListening()};
|
||||||
(function restore(){
|
(function restore(){
|
||||||
const r=localStorage.getItem('ipx.rate'), v=localStorage.getItem('ipx.vol');
|
const r=localStorage.getItem('ipx.rate'), v=localStorage.getItem('ipx.vol');
|
||||||
if(r){$('#rate').value=r;audio.playbackRate=+r}
|
if(r){$<HTMLSelectElement>('#rate').value=r;audio.playbackRate=+r}
|
||||||
if(v){$('#vol').value=v;audio.volume=Number(v)/100}
|
if(v){$<HTMLInputElement>('#vol').value=v;audio.volume=Number(v)/100}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
document.addEventListener('keydown',ev=>{
|
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()}
|
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==='ArrowLeft'&&player.guid){audio.currentTime-=15}
|
||||||
else if(ev.key==='ArrowRight'&&player.guid){audio.currentTime+=30}
|
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);
|
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'};
|
const GO: Record<string,string>={a:':all',d:':directory',l:':listening'};
|
||||||
let gAt=0;
|
let gAt=0;
|
||||||
function stepEntry(by: number){
|
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 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))];
|
const e=S.entries[i<0?0:Math.min(S.entries.length-1,Math.max(0,i+by))];
|
||||||
selectEntry(e,true);
|
selectEntry(e,true);
|
||||||
@@ -250,7 +250,7 @@ const KEYS: Record<string,()=>void>={
|
|||||||
J:()=>stepFeed(1), K:()=>stepFeed(-1),
|
J:()=>stepFeed(1), K:()=>stepFeed(-1),
|
||||||
// The toolbar's own buttons, so a key does exactly what the click does, and nothing while
|
// The toolbar's own buttons, so a key does exactly what the click does, and nothing while
|
||||||
// they are disabled.
|
// 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'); },
|
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(),
|
A:()=>$('#content .fhead [data-a="read"], #content .fhead [data-a="readall"]')?.click(),
|
||||||
r:async()=>{ await loadFeeds(true); if(S.feed){ renderFeed(); loadEntries(); } },
|
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'
|
root.dataset.mode = !both ? 'light'
|
||||||
: theme.mode === 'auto' ? (systemDark && !systemDark.matches ? 'light' : 'dark') : theme.mode;
|
: theme.mode === 'auto' ? (systemDark && !systemDark.matches ? 'light' : 'dark') : theme.mode;
|
||||||
// The tab's icon in the same variant as the logo on the page.
|
// 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
|
// 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.
|
// 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; }
|
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 sel = $<HTMLSelectElement>('#stheme'); if(sel) sel.value = theme.name;
|
||||||
const ms = $('#smode'); if(ms) ms.value = theme.mode;
|
const ms = $<HTMLSelectElement>('#smode'); if(ms) ms.value = theme.mode;
|
||||||
const mf = $('#smodefield'); if(mf) mf.hidden = !both;
|
const mf = $('#smodefield'); if(mf) mf.hidden = !both;
|
||||||
if(save) saveTheme();
|
if(save) saveTheme();
|
||||||
}
|
}
|
||||||
@@ -62,6 +62,6 @@ systemDark?.addEventListener?.('change', () => { if(theme.mode === 'auto') setTh
|
|||||||
// becomes its cookie, once.
|
// becomes its cookie, once.
|
||||||
let name: string | null = null, mode: string | null = null;
|
let name: string | null = null, mode: string | null = null;
|
||||||
try{ name = localStorage.getItem('ipx.theme'); mode = localStorage.getItem('ipx.mode'); }catch{}
|
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);
|
setTheme(name ?? undefined, mode ?? undefined, !!name);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
// `any`: the page reads .value, .dataset and .onclick off whatever it looks up, and the
|
// `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.
|
// smoke test, not the type checker, is what makes sure a selector exists.
|
||||||
const $ = (s: string, r: ParentNode = document): any => r.querySelector(s);
|
const $ = <T extends HTMLElement = HTMLElement>(s: string, r: ParentNode = document): T => r.querySelector<T>(s)!;
|
||||||
const $$ = (s: string, r: ParentNode = document): any[] => [...r.querySelectorAll(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.
|
/// 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 why = (e: unknown): string => e instanceof Error ? e.message : String(e);
|
||||||
const ESCAPED: Record<string,string> = {'&':'&','<':'<','>':'>','"':'"',"'":'''};
|
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
|
// 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.
|
// 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.
|
// 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
|
/// 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).
|
/// 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){
|
function artSrc(url: string){
|
||||||
return /^https?:\/\//i.test(url) ? '/api/art?u='+encodeURIComponent(url) : url;
|
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
|
return url
|
||||||
? `<img class="art ${cls||''}" src="${esc(artSrc(url))}" alt="" loading="lazy" onerror="this.outerHTML=${esc(JSON.stringify(tileHTML(name,cls)))}">`
|
? `<img class="art ${cls||''}" src="${esc(artSrc(url))}" alt="" loading="lazy" onerror="this.outerHTML=${esc(JSON.stringify(tileHTML(name,cls)))}">`
|
||||||
: 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);
|
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.
|
// 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=>
|
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.
|
/// 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',
|
filter:(()=>{ try{ return localStorage.getItem('ipx.filter'); }catch{ return null; } })()||'all',
|
||||||
q:'', sel:null, me:null,
|
q:'', sel:null, me:null,
|
||||||
// The item table's order, kept across visits. The server sorts: a list arrives fifty at a time.
|
// 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'},
|
||{col:'published',dir:'desc'},
|
||||||
};
|
};
|
||||||
const LIMIT = 50;
|
const LIMIT = 50;
|
||||||
|
|||||||
Reference in New Issue
Block a user