diff --git a/CLAUDE.md b/CLAUDE.md
index 6ed861e..1df60cd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -154,7 +154,9 @@ node tests/page-smoke.js
node --test tests/page-unit.js
```
-The first type-checks `web/src` (loosely: `strict` is off, and `$` returns `any`). The second
+The first type-checks `web/src`: every parameter and variable has a type, and what the API sends
+is typed in `web/src/types.ts`, which mirrors the Rust structs and changes with them. Not yet
+null-safe: `strictNullChecks` is off and `$` returns `any` (#236). The second
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
function, `String.replace` silently matched nothing, and the whole UI died with a
diff --git a/tsconfig.json b/tsconfig.json
index aa46f77..7b0a708 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -5,8 +5,9 @@
"target": "es2022",
"lib": ["es2022", "dom", "dom.iterable"],
"noEmit": true,
- // strict, a flag at a time (#235); noImplicitAny and strictNullChecks still to come.
+ // strict, a flag at a time (#235); strictNullChecks still to come, with a typed $ (#236).
"strict": false,
+ "noImplicitAny": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"noImplicitThis": true,
diff --git a/web/src/admin.ts b/web/src/admin.ts
index bebb4a0..99dac6e 100644
--- a/web/src/admin.ts
+++ b/web/src/admin.ts
@@ -61,7 +61,7 @@ async function drawServer(){
await api('/api/settings', {method: 'PATCH', body: JSON.stringify({
schedule: `every ${Math.max(1, Number($('#gnum').value) || 1)}${$('#gunit').value}`,
max_new_per_check: Math.max(0, Number($('#gmax').value) || 0),
- media_types: $('#gtypes').value.split(',').map(t => t.trim()).filter(Boolean),
+ media_types: $('#gtypes').value.split(',').map((t: string) => t.trim()).filter(Boolean),
max_total_gb: Number($('#gquota').value) || 0,
max_age_days: Number($('#gage').value) || 0,
art_cache_mb: Math.max(0, Number($('#gart').value) || 0)})});
@@ -137,7 +137,7 @@ async function drawDirectoryStatus(){
/// sharing with it, and the feeds removed from it.
async function drawDirectoryAdmin(){
const box = $('#dadmin');
- let a;
+ let a: DirAdmin;
try{ a = await api('/api/directory-admin'); }catch(e){ box.innerHTML = `
${esc(u.name)}
@@ -195,7 +195,7 @@ async function drawAccounts(){
At least 8 characters. Leave the password empty for someone who signs in
through the proxy. New people start with no feeds.
`;
- const change = async (u, opts) => {
+ const change = async (u: Account, opts: RequestInit) => {
try{
await api(`/api/users/${u.id}`, opts);
// Demoting yourself takes this page away; go back to the app rather than stay on a page
@@ -206,7 +206,7 @@ async function drawAccounts(){
};
for(const row of $$('#accounts [data-id]')){
const u = users.find(x => String(x.id) === row.dataset.id);
- $('[data-a="admin"]', row).onchange = e =>
+ $('[data-a="admin"]', row).onchange = (e: Event & {target: HTMLInputElement}) =>
change(u, {method: 'PATCH', body: JSON.stringify({admin: e.target.checked})});
$('[data-a="rm"]', row).onclick = () => {
if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`))
@@ -223,16 +223,16 @@ async function drawAccounts(){
}
/* ---------------- log ---------------- */
-let logTimer = null, logSeq = 0, logLines = [], logFilter = '', logLevel = '', logTab = 'all';
+let logTimer: number|null = null, logSeq = 0, logLines: LogLine[] = [], logFilter = '', logLevel = '', logTab = 'all';
// Which sources belong to each tab. "daemon" is the control protocol itself: every
// command in and every event out, whatever sent it.
-const LOG_TABS = {
+const LOG_TABS: Record boolean)|null> = {
all: null,
daemon: t => t === 'ipx::io',
scan: t => t === 'ipx::scan',
web: t => t === 'ipx::http',
};
-const LEVELS = {ERROR: 3, WARN: 2, INFO: 1, DEBUG: 0, TRACE: 0};
+const LEVELS: Record = {ERROR: 3, WARN: 2, INFO: 1, DEBUG: 0, TRACE: 0};
function drawLogView(){
logSeq = 0; logLines = [];
@@ -263,8 +263,8 @@ function drawLogView(){
for(const x of $$('#logtabs button')) x.classList.toggle('on', x.dataset.t === logTab);
drawLog();
};
- $('#loglevel').onchange = e => { logLevel = e.target.value; drawLog(); };
- $('#logq').oninput = e => { logFilter = e.target.value.toLowerCase(); drawLog(); };
+ $('#loglevel').onchange = (e: Event & {target: HTMLSelectElement}) => { logLevel = e.target.value; drawLog(); };
+ $('#logq').oninput = (e: Event & {target: HTMLInputElement}) => { logFilter = e.target.value.toLowerCase(); drawLog(); };
$('#logcopy').onclick = () => copyText(visibleLog().map(l =>
`${new Date(l.ts * 1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $('#logcopy'));
pollLog();
diff --git a/web/src/dialogs.ts b/web/src/dialogs.ts
index e4bb214..f0589fd 100644
--- a/web/src/dialogs.ts
+++ b/web/src/dialogs.ts
@@ -9,7 +9,7 @@ function closeModal(){
$('#modal').classList.remove('on');
}
-$('#modal').onclick=e=>{ if(e.target.id==='modal') closeModal(); };
+$('#modal').onclick=(e: MouseEvent)=>{ if((e.target as Element).id==='modal') closeModal(); };
$('#addFeed').onclick=()=>{
openModal(`
Add a feed
@@ -27,7 +27,7 @@ $('#addFeed').onclick=()=>{
try{
const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({
url, allow_explicit:$('#nexp').checked,
- keywords:$('#nkw').value.split(',').map(s=>s.trim()).filter(Boolean)})});
+ keywords:$('#nkw').value.split(',').map((s: string)=>s.trim()).filter(Boolean)})});
closeModal(); toast(r.existing?`Already subscribed as ${r.id}`:`Added ${r.id}`);
await loadFeeds(true); selectFeed(r.id);
}catch(e){ toast(why(e),true); $('#nsave').title='Add feed'; $('#nsave').disabled=false; }
@@ -43,7 +43,7 @@ let dirError='';
/// One listed feed: a tile in the Directory's grid or, given its place, a row in its chart. The
/// parts are the same either way; the class lays them out. Yours opens its feed; any other, its
/// page in the Directory, to see what it is before you take it (#128).
-function listedFeed(p,cls,rank?: number){
+function listedFeed(p: DirFeed,cls: string,rank?: number){
const el=document.createElement('div');
el.className=cls+(p.subscribed?' mine':'');
el.tabIndex=0; el.dataset.id=p.id;
@@ -64,11 +64,11 @@ function listedFeed(p,cls,rank?: number){
if(p.subscribed){ el.onclick=()=>selectFeed(p.feed||p.id); return el; }
el.onclick=()=>{ dirShow=p.id; $('#content').scrollTop=0; drawDirectory(undefined,'.dback'); };
// Its own click, not the tile's as well.
- $('[data-a="sub"]',el).onclick=ev=>{ ev.stopPropagation(); subscribeListed(p); };
+ $('[data-a="sub"]',el).onclick=(ev: Event)=>{ ev.stopPropagation(); subscribeListed(p); };
return el;
}
-async function subscribeListed(p){
+async function subscribeListed(p: DirFeed){
try{
const r=await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
toast(`Subscribed to ${NAME(p)}`);
@@ -78,29 +78,29 @@ async function subscribeListed(p){
// Where you are in the Directory: what kind of feed, which category and subcategory, in what
// order. Out here because a search redraws the page, and the kind and order hold across visits.
-let dirKind='All', dirCat=null, dirSub=null, dirSort='az', dirRows=[];
+let dirKind='All', dirCat: string|null=null, dirSub: string|null=null, dirSort='az', dirRows: DirFeed[]=[];
// The feed whose page is open over all that, by id; going back leaves the rest as it was.
-let dirShow=null;
+let dirShow: string|null=null;
// The two pages that are not a category: every feed, and the feeds that name none. A category is
// Apple's or one an admin typed, neither of which starts with a colon.
const EVERY=':every', OTHER=':other';
-const NAME=p=>(p.title||p.id);
-const byName=(a,b)=>NAME(a).localeCompare(NAME(b),undefined,{sensitivity:'base',numeric:true});
-const bySubs=(a,b)=>b.subscribers-a.subscribers||byName(a,b);
+const NAME=(p: {title: string|null, id: string})=>(p.title||p.id);
+const byName=(a: DirFeed,b: DirFeed)=>NAME(a).localeCompare(NAME(b),undefined,{sensitivity:'base',numeric:true});
+const bySubs=(a: DirFeed,b: DirFeed)=>b.subscribers-a.subscribers||byName(a,b);
/// The grid's orders. It opens A to Z, as the server sends it.
-const DIR_SORTS={
+const DIR_SORTS: Recordnumber]>={
subscribers:['Most subscribers',bySubs],
az:['Name, A to Z',byName],
- za:['Name, Z to A',(a,b)=>byName(b,a)],
+ za:['Name, Z to A',(a: DirFeed,b: DirFeed)=>byName(b,a)],
};
-const KINDS={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast};
-const KIND_WORD={All:'feed',Podcasts:'podcast',Blogs:'blog'};
+const KINDS: Recordboolean>={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast};
+const KIND_WORD: Record={All:'feed',Podcasts:'podcast',Blogs:'blog'};
/// The Directory, as Apple's is laid out: it opens on the most subscribed feeds and the
/// categories, and a category has a page of its own, its subcategories along the top of its
/// grid. What a feed is (Podcasts, anything with audio or video, or Blogs, the rest) holds across
/// every page, and the search box looks through the lot. One fetch draws all of it.
-async function renderDirectory(url,box){
+async function renderDirectory(url: string,box: HTMLElement){
// Each visit starts at the front, as going back to a tab does.
dirCat=null; dirSub=null; dirShow=null;
dirError='';
@@ -117,24 +117,24 @@ function drawDirectory(box=$('#directory'), focus?: string){
const both=dirRows.some(KINDS.Podcasts)&&dirRows.some(KINDS.Blogs);
if(!both) dirKind='All';
const rows=dirRows.filter(KINDS[dirKind]), word=KIND_WORD[dirKind];
- const count=n=>`${n.toLocaleString()} ${word}${n===1?'':'s'}`;
- const of=c=>p=>c===EVERY||(c===OTHER?!p.category:p.category===c);
+ const count=(n: number)=>`${n.toLocaleString()} ${word}${n===1?'':'s'}`;
+ const of=(c: string)=>(p: DirFeed)=>c===EVERY||(c===OTHER?!p.category:p.category===c);
// A kind can leave a category with nothing in it; that goes back to the front.
if(dirCat&&!rows.some(of(dirCat))) dirCat=null;
- const catName=c=>c===EVERY?`All ${word}s`:c===OTHER?'Other':c;
- const top=list=>list.filter(p=>p.subscribers).sort(bySubs).slice(0,10);
- const btn=(k,v,label,on)=>``;
+ const catName=(c: string)=>c===EVERY?`All ${word}s`:c===OTHER?'Other':c;
+ const top=(list: DirFeed[])=>list.filter(p=>p.subscribers).sort(bySubs).slice(0,10);
+ const btn=(k: string,v: string,label: string,on: boolean)=>``;
// back: where its button goes, '' for the front; none on the front itself.
- const head=(title,sub,back?)=>`
`;
const q=S.q.trim(), grid='', board='';
- let html, chart=[], tiles=null;
+ let html: string, chart: DirFeed[]=[], tiles: DirFeed[]|null=null;
const show=dirShow&&dirRows.find(p=>p.id===dirShow);
if(show){
const n=show.subscribers;
@@ -168,7 +168,7 @@ function drawDirectory(box=$('#directory'), focus?: string){
if(!subs.includes(dirSub)) dirSub=null;
if(dirCat!==EVERY) chart=top(inCat);
tiles=inCat.filter(p=>!dirSub||p.subcategory===dirSub);
- const pill=(v,label,n)=>btn('sub',v,`${esc(label)}${n}`,(dirSub||'')===v);
+ const pill=(v: string,label: string,n: number)=>btn('sub',v,`${esc(label)}${n}`,(dirSub||'')===v);
html=head(catName(dirCat),dirCat===OTHER?`${count(inCat.length)} that name no category`:count(inCat.length),'')
+(chart.length?sec('Most subscribed',board):'')
// Its own heading only when the chart is above it, to say where one list ends.
@@ -178,10 +178,10 @@ function drawDirectory(box=$('#directory'), focus?: string){
box.innerHTML=html;
if(show){ $('.show [data-a="sub"]',box).onclick=()=>subscribeListed(show); fillShow(box,show); }
const c=$('#dchart',box);
- if(c){ c.style.setProperty('--rows',String(Math.ceil(chart.length/2))); chart.forEach((p,i)=>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);
if(t) for(const p of tiles.sort(DIR_SORTS[dirSort][1])) t.appendChild(listedFeed(p,'tile'));
- box.onclick=ev=>{
+ box.onclick=(ev: MouseEvent)=>{
const b=(ev.target as Element).closest('[data-go],[data-kind],[data-sub],[data-back]');
if(!b) return;
// Back from a feed's page, onto the tile or row it was opened from.
@@ -204,9 +204,9 @@ function drawDirectory(box=$('#directory'), focus?: string){
/// A listed feed's page, filled in: what it says it is, its first lines unless you ask for the
/// rest, and its latest items, each one's title, opening its post on the publisher's site where
/// it has one, its first lines, and when and how long.
-async function fillShow(box,p){
+async function fillShow(box: HTMLElement,p: DirFeed){
const list=$('#dshow',box), about=$('#dabout',box);
- let got;
+ let got: {description: string|null, failing: string|null, items: ListedItem[], can_admin: boolean};
try{ got=await api(`/api/directory/${encodeURIComponent(p.id)}`); }
catch(e){ list.innerHTML=`
${esc(why(e))}
`; return; }
// Gone back, or on to another, while it loaded.
@@ -256,7 +256,7 @@ async function fillShow(box,p){
/// A category as a tile: its name, how many feeds, and the covers of its three most subscribed
/// fanned in the corner, so each one is coloured by what is in it, as Apple's are.
-function catTile(c,name,count,rows){
+function catTile(c: string,name: string,count: string,rows: DirFeed[]){
// Covers before initials, which are all Other had; the likeliest first, and first is in front:
// the fan is drawn back to front.
const art=[...rows].sort((a,b)=>Number(!a.image)-Number(!b.image)||bySubs(a,b)).slice(0,3).reverse();
@@ -265,7 +265,7 @@ function catTile(c,name,count,rows){
}
/// The Directory and Currently Listening open in the main pane, as the original's Directory did.
-async function renderListed(v){
+async function renderListed(v: {title: string, icon: string, url?: string, blurb?: string}){
const box=$('#content');
box.classList.add('plain');
$('#tbRemove').disabled=true;
@@ -294,14 +294,14 @@ async function renderListed(v){
/// Currently Listening: episodes you started and have not finished, across every feed you
/// subscribe to. A row resumes the episode in the player bar on click -- a shortcut back to
/// where you left off, not another way to browse. The one in the player pauses instead.
-async function renderListening(url,box){
- let rows=[];
+async function renderListening(url: string,box: HTMLElement){
+ let rows: Entry[]=[];
try{ rows=(await api(url)).entries||[]; }catch{}
box.innerHTML=rows.length?'':S.q?`
Nothing you are listening to mentions “${esc(S.q)}”.
`
:'
Nothing in progress. Episodes you start and do not finish show up here.
';
for(const e of rows){
// Carries its episode, for paintListenRow to repaint as the player moves.
- const el: HTMLDivElement & {entry?: any}=document.createElement('div');
+ const el: HTMLDivElement & {entry?: Entry}=document.createElement('div');
el.className='childrow';
el.entry=e;
el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+
@@ -319,7 +319,7 @@ async function renderListening(url,box){
}
/// One row's time left, progress and play button, taken from the player when it is the one in it.
-function paintListenRow(el){
+function paintListenRow(el: HTMLElement & {entry?: Entry}){
const e=el.entry, now=player.guid===e.guid&&player.feed===e.feed_id;
// Zero until the player has sought to where you left off; the saved position stands till then.
if(now&&audio.currentTime) e.position=Math.floor(audio.currentTime);
@@ -341,7 +341,7 @@ function syncListening(){
/// Takes an episode off Currently Listening by forgetting where you got to: the list is every
/// episode with a saved position short of the end, so the position is what has to go.
-async function forget(e){
+async function forget(e: Entry){
// Closed without saving first, or the player's next save would put it straight back.
if(player.guid===e.guid){ player.guid=null; $('#pclose').click(); }
try{
@@ -356,8 +356,8 @@ $('#tbRemove').onclick=()=>{ const f=S.feeds.find(x=>x.id===S.feed); if(f) remov
$('#tbPlay').onclick=()=>{ const e=cur(); if(e) play(e); };
$('#tbRead').onclick=()=>{ const e=cur(); if(e) epAction('read',e,null); };
$('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); };
-let searchT;
-$('#epSearch').oninput=ev=>{ clearTimeout(searchT);
+let searchT: number;
+$('#epSearch').oninput=(ev: Event & {target: HTMLInputElement})=>{ clearTimeout(searchT);
// The Directory looks through what it already has; Currently Listening and the rest ask the
// server (#126, #127).
searchT=setTimeout(()=>{ S.q=ev.target.value; S.offset=0;
@@ -368,13 +368,13 @@ $('#epSearch').oninput=ev=>{ clearTimeout(searchT);
window.matchMedia?.('(max-width:820px)')?.addEventListener?.('change',()=>{ const e=cur(); if(e) showDetail(e); });
let expanded = new Set(JSON.parse(localStorage.getItem('ipx.expanded')||'[]'));
-function toggleGroup(id){
+function toggleGroup(id: string){
expanded.has(id) ? expanded.delete(id) : expanded.add(id);
try{ localStorage.setItem('ipx.expanded', JSON.stringify([...expanded])); }catch{}
renderFeeds();
}
let globalMax = 3;
-function due(ts){
+function due(ts: number){
const d = ts - Date.now()/1000;
if(d <= 0) return 'due now';
if(d < 3600) return 'in '+Math.max(1,Math.round(d/60))+'m';
@@ -385,7 +385,7 @@ function due(ts){
/// Your settings: the theme, your subscriptions as OPML, and, to read, what the server does
/// with feeds. The server's own settings, the accounts and the log are on /admin, which only an
/// admin is sent (issue #19); this used to hold them too, shown to admins only.
-function deadSavedText(me){
+function deadSavedText(me: Me|null){
const s=me&&me.deadair_saved||0;
return s?`It has saved you ${spanText(s)} of dead air, on episodes you heard three quarters of or more.`
:'Turn it on in a show\'s settings, or beside the speed while it plays, to cut the dead air from it.';
@@ -429,8 +429,8 @@ async function prefsModal(){
${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 admin page.':'Only an admin changes this.'}
`);
- $('#stheme').onchange=e=>setTheme(e.target.value,undefined,true);
- $('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
+ $('#stheme').onchange=(e: Event & {target: HTMLSelectElement})=>setTheme(e.target.value,undefined,true);
+ $('#smode').onchange=(e: Event & {target: HTMLSelectElement})=>setTheme(undefined,e.target.value,true);
$('#gopml').onclick=opmlModal;
drawTokens();
$('#tokadd').onclick=async()=>{
@@ -448,7 +448,7 @@ async function prefsModal(){
$('#tokval').select();
}catch(err){ toast(why(err),true); }
};
- $('#sblock').onchange=async e=>{
+ $('#sblock').onchange=async (e: Event & {target: HTMLInputElement})=>{
const blocked=splitWords(e.target.value);
try{
await api('/api/me',{method:'PATCH',body:JSON.stringify({blocked})});
@@ -461,7 +461,7 @@ async function prefsModal(){
/// Your API tokens, each with when it was made and last used, and a button to revoke it.
async function drawTokens(){
const box=$('#tokens'); if(!box) return;
- const list=await api('/api/tokens').catch(()=>[]);
+ const list: {id: number, name: string, created: number, last_used: number|null}[]=await api('/api/tokens').catch((): []=>[]);
box.innerHTML=list.map(t=>`
${esc(t.name)}made ${dateOf(t.created)}, last used ${ago(t.last_used)}
`).join('');
@@ -486,10 +486,10 @@ function chipsHTML(words: string[], input: string){
placeholder="${words.length?'':CHIP_HINT}" enterkeyhint="done" autocomplete="off" spellcheck="false">`;
}
/// The words in a chip box, with whatever is typed and not yet a chip.
-function chipWords(box){
+function chipWords(box: ParentNode){
return [...$$('.chip',box).map(c=>c.dataset.w), ...splitWords($('.chipin',box).value)];
}
-function wireChips(box){
+function wireChips(box: HTMLElement){
const input=$('.chipin',box);
// The hint only in an empty box: beside chips it reads as one more.
const hint=()=>{ input.placeholder=$$('.chip',box).length?'':CHIP_HINT; };
@@ -501,14 +501,14 @@ function wireChips(box){
}
input.value=''; hint();
};
- input.onkeydown=e=>{
+ input.onkeydown=(e: KeyboardEvent)=>{
if(e.key==='Enter'||e.key===','){ e.preventDefault(); add(); }
else if(e.key==='Backspace'&&!input.value){ input.previousElementSibling?.remove(); hint(); }
};
// Typed and left without Enter is still meant: Save takes it either way.
input.onblur=add;
- box.onclick=e=>{
- const x=e.target.closest('.chipx');
+ box.onclick=(e: MouseEvent)=>{
+ const x=(e.target as Element).closest('.chipx');
if(x){ x.parentElement.remove(); hint(); }
input.focus();
};
@@ -516,7 +516,7 @@ function wireChips(box){
/// Your settings for one feed, under its address (#167). What it says is what will happen, in this feed's numbers: the download limit was a bare
/// box whose blank, number and 0 were explained in bold small print.
-function settingsModal(f, newUrl?: string){
+function settingsModal(f: Feed, newUrl?: string){
const kids=S.feeds.filter(c=>c.group===f.id).length;
const isGroup=kids>0;
const parent=f.managed?S.feeds.find(p=>p.id===f.group):null;
@@ -576,7 +576,7 @@ function settingsModal(f, newUrl?: string){
// What only matters while it downloads on its own folds away, out of reach, when it does not.
const deps=$('#sdeps'), inner=$('.fdepsin',deps);
inner.inert=!f.auto_download;
- $('#sauto').onchange=e=>{ deps.classList.toggle('off',!e.target.checked); inner.inert=!e.target.checked; };
+ $('#sauto').onchange=(e: Event & {target: HTMLInputElement})=>{ deps.classList.toggle('off',!e.target.checked); inner.inert=!e.target.checked; };
// The download limit, said as what it does. A number in a box hid that it is a window on the
// newest items, not a pace through the back catalogue (#97).
@@ -591,7 +591,7 @@ function settingsModal(f, newUrl?: string){
? `The server's default: downloads the newest ${globalMax}${each} on its own.${older}`
: `The server's default: downloads every item${each}, the whole back catalogue.`;
const limit=()=>mode==='all'||(mode==='default'&&!globalMax)?Infinity:mode==='default'?globalMax:newest;
- let bars=[];
+ let bars: HTMLElement[]=[];
const draw=(words: boolean)=>{
// The newest items with a file, as hold_back counts them: one whose files a filter all
// skipped holds no place.
@@ -610,16 +610,16 @@ function settingsModal(f, newUrl?: string){
};
for(const r of $$('[name=swin]')) r.onchange=()=>{ mode=r.value; draw(true); };
// Breathing room only matters with DeadAir on: greyed out, not hidden, so it can be seen beforehand.
- $('#sdead').onchange=e=>{ $('#sdpause').disabled=!e.target.checked; };
+ $('#sdead').onchange=(e: Event & {target: HTMLInputElement})=>{ $('#sdpause').disabled=!e.target.checked; };
const pause=$('#sdpause'), pauseText=()=>{ const s=`${pause.value/1000} s`; $('#sdpauseout').textContent=s; pause.setAttribute('aria-valuetext',s); };
pause.oninput=pauseText; pauseText();
draw(true);
// The feed's own newest items, each as tall as it is long, so the strip reads as this show.
if(!isGroup) api(`/api/feeds/${encodeURIComponent(f.id)}/entries?limit=40`).then(page=>{
- const items=page?.entries||[];
+ const items: Entry[]=page?.entries||[];
const strip=$('#sepwin'); if(!items.length||!strip) return;
const longest=Math.max(1,...items.map(e=>e.duration||0));
- strip.innerHTML=items.map((e,i)=>{
+ strip.innerHTML=items.map((e: Entry,i: number)=>{
const file=e.enclosures.some(x=>x.state!=='skipped');
const h=e.duration?Math.round(30+70*Math.sqrt(e.duration/longest)):60;
return ``;
@@ -637,8 +637,8 @@ function settingsModal(f, newUrl?: string){
if(mode!=='newest'){ mode='newest'; $('[name=swin][value=newest]').checked=true; draw(true); }
else { $('#smax').value=String(newest); draw(false); }
};
- strip.onpointerdown=e=>{ strip.setPointerCapture(e.pointerId); pick(e.clientX); };
- strip.onpointermove=e=>{ if(strip.hasPointerCapture(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); };
}).catch(()=>{});
wireChips($('#skw'));
@@ -668,7 +668,7 @@ function settingsModal(f, newUrl?: string){
};
}
-function downloadLatestModal(f){
+function downloadLatestModal(f: Feed){
openModal(`
Removes ${esc(f.title||f.id)} from your feeds. Anyone else
reading it keeps it, along with their own read state.
@@ -753,8 +753,8 @@ api('/api/me').then(u=>{
$('#feedFilter').oninput=renderFeeds;
// The feed list from the keyboard: Enter or Space opens a row, Right and Left open and close a
// folder. Handled keys stop here, or the player's own Space and arrows would act on them too.
-$('#feedlist').onkeydown=ev=>{
- const row=ev.target.closest('[data-id]'); if(!row) return;
+$('#feedlist').onkeydown=(ev: KeyboardEvent)=>{
+ const row=(ev.target as Element).closest('[data-id]'); if(!row) return;
const id=row.dataset.id;
if((ev.key==='Enter'||ev.key===' ')&&ev.target===row) row.click();
else if(row.classList.contains('group')&&
diff --git a/web/src/events.ts b/web/src/events.ts
index 575fb4b..33ec0ea 100644
--- a/web/src/events.ts
+++ b/web/src/events.ts
@@ -1,10 +1,10 @@
/* ---------------- live events ---------------- */
-let sse;
+let sse: EventSource;
/// The feed list and the open list, taken again from the server. The events keep both current,
/// and one missed is a count wrong until a reload: reading an item sends none to your other
/// pages, so one read on another device stayed unread here, and an iPhone stops the stream while
/// the app is in the background, when everything is missed (#168).
-const catchUp=(()=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(()=>{
+const catchUp=(()=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(()=>{
loadFeeds(true).catch(()=>{});
if(S.feed) loadEntries().catch(()=>{});
},300); }; })();
@@ -14,11 +14,11 @@ function connect(){
sse=new EventSource('/api/events');
// Back after a drop: whatever was sent meanwhile went to nobody.
sse.onopen=()=>{ if(dropped){ dropped=false; catchUp(); } };
- const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
+ const soon=(fn: ()=>void,ms=500)=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
// Caught: a network gone when one fires (a laptop asleep) was an unhandled rejection (#219).
const refreshEntries=soon(()=>{ if(S.feed) loadEntries().catch(()=>{}); });
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
- const mine=id=>S.feeds.some(f=>f.id===id);
+ const mine=(id: string)=>S.feeds.some(f=>f.id===id);
sse.onmessage=m=>{
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
if(ev.ev==='progress'){
diff --git a/web/src/feedpage.ts b/web/src/feedpage.ts
index 386b8a3..967393c 100644
--- a/web/src/feedpage.ts
+++ b/web/src/feedpage.ts
@@ -89,7 +89,7 @@ function sortHead(){
`${on?`${ICON.caret}`:''}`;
}).join('')+'
';
}
-function sortBy(col){
+function sortBy(col: string){
const first=['published','size','kept'].includes(col)?'desc':'asc';
S.sort={col,dir:S.sort.col===col?(S.sort.dir==='asc'?'desc':'asc'):first};
try{ localStorage.setItem('ipx.sort',JSON.stringify(S.sort)); }catch{}
@@ -99,9 +99,9 @@ function sortBy(col){
/// An OPML subscription's page lists the feeds inside it rather than items, but keeps
/// every action a normal feed has -- it is still an ordinary feed entry underneath.
// A Patreon creator split into its shows is drawn like an OPML, and named for what it is.
-const isPatreon=f=>/patreon\.com\//.test(f&&f.url||'');
+const isPatreon=(f: {url: string}|null)=>/patreon\.com\//.test(f&&f.url||'');
-function renderGroup(f,kids){
+function renderGroup(f: Feed,kids: Feed[]){
const unread=kids.reduce((n,c)=>n+c.unread,0);
const saved=kids.reduce((n,c)=>n+c.downloaded,0);
const gone=kids.filter(c=>c.orphaned).length;
@@ -158,7 +158,7 @@ function renderGroup(f,kids){
draw();
}
-async function feedAction(a,f){
+async function feedAction(a: string,f: Feed){
if(a==='scan'){
spinStart(f.id);
try{ await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
@@ -177,7 +177,7 @@ async function feedAction(a,f){
if(a==='dl') downloadLatestModal(f);
}
/// All Subscriptions' own buttons: a feed's, across every feed you read.
-async function allAction(a){
+async function allAction(a: string){
if(a==='scanall') return scanAll();
if(a==='readall'){
const n=S.feeds.reduce((k,x)=>k+(x.unread||0),0);
diff --git a/web/src/feeds.ts b/web/src/feeds.ts
index ea53cd3..6706cd8 100644
--- a/web/src/feeds.ts
+++ b/web/src/feeds.ts
@@ -2,7 +2,7 @@
/// One feed's row from the server, in place of the old one (or added, for a feed an OPML just
/// listed). A scan sends dozens; the list is redrawn once a frame, not once each.
let rowsQueued=0;
-function patchFeed(row){
+function patchFeed(row: Feed){
const i=S.feeds.findIndex(f=>f.id===row.id);
if(i<0) S.feeds.push(row); else S.feeds[i]=row;
if(!rowsQueued) rowsQueued=requestAnimationFrame(()=>{ rowsQueued=0; renderFeeds(); });
@@ -24,10 +24,10 @@ async function loadFeeds(keepSel?: boolean){
}
// An OPML can hold dozens of feeds; the ones with something new go first. sort is stable, so the
// server's alphabetical order still holds within each half.
-const unreadFirst=(a,b)=>Number(b.unread>0)-Number(a.unread>0);
+const unreadFirst=(a: Feed,b: Feed)=>Number(b.unread>0)-Number(a.unread>0);
// The original's source list opened with these, above the feeds. They are places, not feeds:
// an id starting with ':' can never be a feed's, since feed ids are slugs.
-const VIEWS={
+const VIEWS: Record={
// The Directory holds the most subscribed feeds too, which had a place of their own, Popular (#124).
':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory'},
':listening':{title:'Currently Listening',icon:ICON.audio,url:'/api/entries?filter=in_progress&limit=50',
@@ -108,9 +108,9 @@ function renderFeeds(){
// Feeds from a subscribed OPML sit under it, so the group reads as one thing. Pinned feeds
// go first: a folder with its feeds under it, a feed from inside one lifted out of it.
const byId=Object.fromEntries(shown.map(f=>[f.id,f]));
- const inside=f=>shown.filter(c=>c.group===f.id&&!c.pinned);
+ const inside=(f: Feed)=>shown.filter(c=>c.group===f.id&&!c.pinned);
const tops=shown.filter(f=>f.pinned||!(f.group&&byId[f.group]));
- const order=[];
+ const order: [Feed, number][]=[];
for(const f of [...tops.filter(f=>f.pinned),...tops.filter(f=>!f.pinned)]){
order.push([f,0]);
// A subscription can hold dozens of feeds, so a folder starts closed. Searching
@@ -127,14 +127,14 @@ function renderFeeds(){
// A subscription holds no entries itself, so its counts are the sum of what is inside --
// taken from every feed it holds, not just the ones a filter left showing.
const mine=S.feeds.filter(c=>c.group===f.id);
- const sum=k=>mine.reduce((n,c)=>n+(c[k]||0),0);
+ const sum=(k: 'unread'|'entries'|'downloaded')=>mine.reduce((n,c)=>n+(c[k]||0),0);
const [unread,eps,saved]=mine.length
? [sum('unread'),sum('entries'),sum('downloaded')]
: [f.unread,f.entries,f.downloaded];
// A group's own row has no error of its own worth mentioning if the OPML itself
// reads fine; it is failing when any feed inside it is. The feed's own page says what
// went wrong (failBannerHTML); the list only has to make it findable.
- const bad=c=>c.failing?.reason||c.last_error;
+ const bad=(c: Feed)=>c.failing?.reason||c.last_error;
const err=mine.length ? mine.map(bad).find(Boolean) : bad(f);
const nbad=mine.filter(bad).length;
const el=document.createElement('div');
@@ -160,14 +160,14 @@ function renderFeeds(){
(f.orphaned?'Gone':'')+
`${unread>999?'999+':unread}`;
el.onclick=()=>{ selectFeed(f.id); nav(false); };
- if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
+ if(kids) $('.chev',el).onclick=(ev: Event)=>{ ev.stopPropagation(); toggleGroup(f.id); };
for(const img of $$('img',el)){ const was=drawn.get(img.outerHTML)?.shift(); if(was) img.replaceWith(was); }
list.appendChild(el);
}
paintScanning();
done();
}
-function selectFeed(id){
+function selectFeed(id: string){
S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value='';
try{ localStorage.setItem('ipx.feed',id); }catch{}
renderFeeds(); renderFeed(); loadEntries();
@@ -176,14 +176,14 @@ function selectFeed(id){
/// A failing feed's error, in plain words with something to do about it, once `failing` is
/// set (it has been failing for a day and is a kind worth naming -- see `explain_failure` in
/// src/feed.rs). Anything else still shows the raw error, as before.
-function failBannerHTML(f){
+function failBannerHTML(f: Feed){
if(f.failing) return `
`;
return '';
}
-function wireFailBanner(box,f){
+function wireFailBanner(box: ParentNode,f: Feed){
const un=$('[data-ffail="unsub"]',box); if(un) un.onclick=()=>removeFeed(f);
const nu=$('[data-ffail="newurl"]',box); if(nu) nu.onclick=()=>settingsModal(f,f.failing.new_url);
}
diff --git a/web/src/items.ts b/web/src/items.ts
index cd5e650..cc9d49a 100644
--- a/web/src/items.ts
+++ b/web/src/items.ts
@@ -6,7 +6,7 @@ async function loadEntries(append?: boolean){
const p=new URLSearchParams({offset:String(S.offset),limit:String(LIMIT),filter:S.filter,sort:S.sort.col,dir:S.sort.dir});
if(S.q) p.set('q',S.q);
const asked=performance.now(), seq=++loadSeq;
- const r=await api(S.feed===':all' ? `/api/entries?${p}`
+ const r: {total: number, entries: Entry[]}=await api(S.feed===':all' ? `/api/entries?${p}`
: `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`);
// Only the latest list counts: marking everything read reloads the All tab, and clicking
// Unread straight after could have that answer land last and replace the Unread one.
@@ -21,7 +21,7 @@ async function loadEntries(append?: boolean){
// tab -- would drop the item you have open the moment reading it took it off the filter.
// Keep it until you pick a different one; the next refresh after that no longer protects it.
// The items turned past on the way to it are kept too, so a swipe back still finds them.
- const kept=e=>e.guid===S.sel||turned.has(e.guid);
+ const kept=(e: Entry)=>e.guid===S.sel||turned.has(e.guid);
if(S.filter==='unread') entries=entries.filter(e=>!e.read||kept(e));
if(!append && S.sel){
const gone=S.entries.filter(e=>kept(e)&&!entries.some(n=>n.guid===e.guid));
@@ -53,7 +53,7 @@ function renderEntries(){
box.appendChild(b);
}
}
-function epEl(e){
+function epEl(e: Entry){
// An item may carry several files. The row summarises the one you would act on --
// the playable one, else anything already downloaded, else the first -- and says how
// many others there are; the pane below lists them all.
@@ -97,13 +97,13 @@ function epEl(e){
${has?``:''}
`;
el.onclick=()=>selectEntry(e);
- $$('button[data-a]',el).forEach(b=>b.onclick=ev=>{ev.stopPropagation();epAction(b.dataset.a,e,el)});
+ $$('button[data-a]',el).forEach(b=>b.onclick=(ev: Event)=>{ev.stopPropagation();epAction(b.dataset.a,e,el)});
return el;
}
/// Whether the browser can play it. Having a file is not the same as being playable:
/// blog feeds put article images in enclosures, and an
`;
}
-async function epAction(a: string, e, el, encId?: number){
+async function epAction(a: string, e: Entry, el: HTMLElement|null, encId?: number){
// Swap the row in place and refresh the text below when it is the one being read.
const redraw=()=>{ swapRow(e); if(!el || S.sel===e.guid) showDetail(e); };
const enc=(encId!=null && e.enclosures.find(x=>x.id===encId)) || e.enclosures[0];
diff --git a/web/src/player.ts b/web/src/player.ts
index 8fe7a9c..a3b0bee 100644
--- a/web/src/player.ts
+++ b/web/src/player.ts
@@ -1,11 +1,11 @@
/* ---------------- player ---------------- */
const audio=$('#audio');
-const player: {guid: string|null, feed: string|null, entry: any, 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};
/// Where a file plays from: DeadAir's playlist when the server says this person plays it
/// shortened (#171, #181), whose times the entry is already given in, else the file itself.
-const mediaSrc=enc=>enc.deadair!=null?`/media/${enc.id}/deadair.m3u8`:`/media/${enc.id}`;
+const mediaSrc=(enc: {id: number, deadair?: number|null})=>enc.deadair!=null?`/media/${enc.id}/deadair.m3u8`:`/media/${enc.id}`;
/// hls.js, playing DeadAir's playlist where the browser cannot itself. Fetched the first time it
/// is needed. Only Apple's browsers play it themselves: Chromium says it can ("maybe"), then fails
@@ -31,7 +31,7 @@ function setSrc(url: string): Promise|null{
return ready.then(()=>{
// Its playlist and audio requests join the trace of the tap that played them (#192).
hls=new w.Hls({xhrSetup:(xhr: XMLHttpRequest)=>xhr.setRequestHeader('traceparent',traceparent())});
- hls.on(w.Hls.Events.ERROR,(_,d)=>{ if(d.fatal){ toast('Playback failed: '+d.details,true);
+ hls.on(w.Hls.Events.ERROR,(_: string,d: {fatal: boolean, type: string, details: string})=>{ if(d.fatal){ toast('Playback failed: '+d.details,true);
report('player_error',{msg:`hls.js: ${d.type} ${d.details}`,feed:player.feed,enclosure:player.enc}); } });
hls.loadSource(url); hls.attachMedia(audio);
});
@@ -49,7 +49,7 @@ function markPlayed(){
}
/// Plays one of the item's files in the player bar: the one asked for, or its first playable one.
-function play(e,enc=e.enclosures.find(isPlayable)){
+function play(e: Entry,enc=e.enclosures.find(isPlayable)){
if(!isPlayable(enc)){
toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet', true);
return;
@@ -76,17 +76,17 @@ function play(e,enc=e.enclosures.find(isPlayable)){
player.marked=false;
paintDead();
}
- const go=()=>audio.play().catch(err=>toast('Playback failed: '+err.message,true));
+ const go=()=>audio.play().catch((err: Error)=>toast('Playback failed: '+err.message,true));
if(wait) wait.then(go,err=>toast('Playback failed: '+err.message,true)); else go();
renderEntries();
}
-function mediaSession(e,f){
+function mediaSession(e: Entry,f: Feed|undefined){
if(!('mediaSession' in navigator)) return;
navigator.mediaSession.metadata=new MediaMetadata({
title:entryName(e).text, artist:f?(f.title||f.id):'', album:f?(f.title||''):'',
artwork:(e.image||(f&&f.image))?[{src:artSrc(e.image||f.image),sizes:'512x512'}]:[],
});
- const h={play:()=>audio.play(),pause:()=>audio.pause(),
+ const h: Recordvoid>={play:()=>audio.play(),pause:()=>audio.pause(),
seekbackward:()=>audio.currentTime-=15,seekforward:()=>audio.currentTime+=30};
for(const k in h){ try{navigator.mediaSession.setActionHandler(k as MediaSessionAction,h[k])}catch{} }
}
@@ -130,8 +130,8 @@ for(const ev of ['play','pause','ended']) audio.addEventListener(ev,syncPlayButt
/// Every play button for what is playing shows pause, like the player bar's: a row's, the files
/// pane's, the toolbar's. Only the bar's used to change, so the others said play while it played.
function syncPlayButtons(){
- const on=(guid,enc?)=>!audio.paused&&player.guid===guid&&(enc==null||player.enc===enc);
- const paint=(b,now,idle)=>{
+ const on=(guid: string,enc?: number)=>!audio.paused&&player.guid===guid&&(enc==null||player.enc===enc);
+ const paint=(b: HTMLElement,now: boolean,idle: string)=>{
const label=now?'Pause':idle;
if(b.title===label) return;
b.title=label; b.setAttribute('aria-label',label); b.innerHTML=now?ICON.pause:ICON.play;
@@ -160,9 +160,9 @@ audio.addEventListener('error',()=>{ if(audio.error) report('player_error',{msg:
$('#pplay').onclick=()=>audio.paused?audio.play():audio.pause();
$('#pback').onclick=()=>audio.currentTime-=15;
$('#pfwd').onclick=()=>audio.currentTime+=30;
-$('#seek').oninput=e=>{const d=audio.duration;if(d)audio.currentTime=d*e.target.value/1000};
-$('#rate').onchange=e=>{audio.playbackRate=+e.target.value;localStorage.setItem('ipx.rate',e.target.value)};
-$('#vol').oninput=e=>{audio.volume=e.target.value/100;localStorage.setItem('ipx.vol',e.target.value)};
+$('#seek').oninput=(e: Event & {target: HTMLInputElement})=>{const d=audio.duration;if(d)audio.currentTime=d*Number(e.target.value)/1000};
+$('#rate').onchange=(e: Event & {target: HTMLSelectElement})=>{audio.playbackRate=+e.target.value;localStorage.setItem('ipx.rate',e.target.value)};
+$('#vol').oninput=(e: Event & {target: HTMLInputElement})=>{audio.volume=Number(e.target.value)/100;localStorage.setItem('ipx.vol',e.target.value)};
/// DeadAir's switch beside the speed: on or off for the show that is playing. The state is in the
/// icon's shape and aria-pressed, never a colour.
function paintDead(){
@@ -195,7 +195,7 @@ async function switchVersion(){
secs:Math.floor(audio.currentTime),duration:isFinite(audio.duration)?Math.floor(audio.duration):null})}).catch(()=>{});
player.moved=false;
}
- const e=await api(`/api/entries/${feed}/${guid}`).catch(()=>null);
+ const e: Entry|null=await api(`/api/entries/${feed}/${guid}`).catch((): null=>null);
const enc=e&&e.enclosures.find(x=>x.id===player.enc);
if(!enc||mediaSrc(enc)===player.src) return false;
const was=!audio.paused;
@@ -230,22 +230,22 @@ document.addEventListener('keydown',ev=>{
// Feedly's keys, vim's j and k among them: a letter to move through items or feeds, g and a
// letter to go somewhere, ? to list them. None fire with Ctrl, Alt or Cmd held, so the browser's
// own shortcuts still work, or while a dialog is open.
-const GO={a:':all',d:':directory',l:':listening'};
+const GO: Record={a:':all',d:':directory',l:':listening'};
let gAt=0;
-function stepEntry(by){
+function stepEntry(by: number){
if(VIEWS[S.feed]?.url||!S.entries.length) return;
const i=S.entries.findIndex(x=>x.guid===S.sel);
const e=S.entries[i<0?0:Math.min(S.entries.length-1,Math.max(0,i+by))];
selectEntry(e,true);
$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`)?.scrollIntoView({block:'nearest'});
}
-function stepFeed(by){
+function stepFeed(by: number){
const rows=$$('#feedlist [data-id]'), i=rows.findIndex(r=>r.dataset.id===S.feed), id=rows[i+by]?.dataset.id;
if(!id) return;
selectFeed(id);
$(`#feedlist [data-id="${CSS.escape(id)}"]`)?.scrollIntoView({block:'nearest'});
}
-const KEYS={
+const KEYS: Recordvoid>={
j:()=>stepEntry(1), n:()=>stepEntry(1), k:()=>stepEntry(-1), p:()=>stepEntry(-1),
J:()=>stepFeed(1), K:()=>stepFeed(-1),
// The toolbar's own buttons, so a key does exactly what the click does, and nothing while
@@ -259,7 +259,7 @@ const KEYS={
'?':()=>keysModal(),
g:()=>{ gAt=Date.now(); },
};
-function typed(ev){
+function typed(ev: KeyboardEvent){
// The second key of a g pair counts only if it follows within a second and a half.
const pair=Date.now()-gAt<1500; gAt=0;
const fn=pair ? (GO[ev.key]&&(()=>selectFeed(GO[ev.key])))||(ev.key==='s'&&prefsModal) : KEYS[ev.key];
@@ -268,7 +268,7 @@ function typed(ev){
}
/// What ? shows: every key, grouped as Feedly's own list is.
function keysModal(){
- const k=s=>`${esc(s)}`, g=c=>k('g')+' '+k(c);
+ const k=(s: string)=>`${esc(s)}`, g=(c: string)=>k('g')+' '+k(c);
const rows=[
['Go to'],
[g('a'),'All Subscriptions'],[g('d'),'Directory'],
diff --git a/web/src/types.ts b/web/src/types.ts
index 8159d33..c1f7f83 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -113,3 +113,41 @@ interface State {
me: Me | null;
sort: {col: string, dir: 'asc' | 'desc'};
}
+
+/// An account, `/api/users` (web.rs list_users).
+interface Account {
+ id: number;
+ name: string;
+ admin: boolean;
+ /// Has a password; none signs in through the proxy.
+ password: boolean;
+ created: number | null;
+ last_login: number | null;
+}
+
+/// A line of the daemon's log, `/api/logs` (logbuf.rs LogLine).
+interface LogLine {
+ seq: number;
+ ts: number;
+ level: string;
+ target: string;
+ msg: string;
+}
+
+/// `/api/directory-admin`: the servers sharing with the directory and the feeds it blocks
+/// (directory lib.rs Instance, Blocked).
+interface DirAdmin {
+ instances: {name: string, feeds: number, subscribers: number}[];
+ blocked: {id: string, url: string | null, title: string | null, at: number}[];
+}
+
+/// One of a listed feed's latest items, on its page in the Directory (web.rs ListedItem): never
+/// its files.
+interface ListedItem {
+ guid: string;
+ title: string | null;
+ link: string | null;
+ published: number | null;
+ duration: number | null;
+ description: string | null;
+}
diff --git a/web/src/util.ts b/web/src/util.ts
index ba0d927..e6bf96b 100644
--- a/web/src/util.ts
+++ b/web/src/util.ts
@@ -5,13 +5,14 @@ const $ = (s: string, r: ParentNode = document): any => r.querySelector(s);
const $$ = (s: string, r: ParentNode = document): any[] => [...r.querySelectorAll(s)];
/// What went wrong, from whatever a catch caught: the page throws Errors, but a catch cannot know.
const why = (e: unknown): string => e instanceof Error ? e.message : String(e);
-const esc = s => (s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+const ESCAPED: Record = {'&':'&','<':'<','>':'>','"':'"',"'":'''};
+const esc = (s: string|null|undefined) => (s??'').replace(/[&<>"']/g,c=>ESCAPED[c]);
// Icons: Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com
// License - https://fontawesome.com/license/free (Icons: CC BY 4.0). Embedded as SVG, only the
// ones used, so there is no font to download and nothing is fetched from anyone else. Each takes
// the button's own colour. To add one, copy the path from svgs/