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

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

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

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

View File

@@ -56,15 +56,15 @@ async function drawServer(){
<div class="field"><label>Download folder</label>
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
<div class="cardacts"><button class="btn primary" id="gsave">${ICON.check} Save</button></div>`;
$('#gsave').onclick = async () => {
$<HTMLButtonElement>('#gsave').onclick = async () => {
try{
await api('/api/settings', {method: 'PATCH', body: JSON.stringify({
schedule: `every ${Math.max(1, Number($('#gnum').value) || 1)}${$('#gunit').value}`,
max_new_per_check: Math.max(0, Number($('#gmax').value) || 0),
media_types: $('#gtypes').value.split(',').map((t: string) => t.trim()).filter(Boolean),
max_total_gb: Number($('#gquota').value) || 0,
max_age_days: Number($('#gage').value) || 0,
art_cache_mb: Math.max(0, Number($('#gart').value) || 0)})});
schedule: `every ${Math.max(1, Number($<HTMLInputElement>('#gnum').value) || 1)}${$<HTMLSelectElement>('#gunit').value}`,
max_new_per_check: Math.max(0, Number($<HTMLInputElement>('#gmax').value) || 0),
media_types: $<HTMLInputElement>('#gtypes').value.split(',').map((t: string) => t.trim()).filter(Boolean),
max_total_gb: Number($<HTMLInputElement>('#gquota').value) || 0,
max_age_days: Number($<HTMLInputElement>('#gage').value) || 0,
art_cache_mb: Math.max(0, Number($<HTMLInputElement>('#gart').value) || 0)})});
toast('Settings saved');
}catch(e){ toast(why(e), true); }
};
@@ -101,13 +101,13 @@ async function drawDirectorySettings(){
drawDirectorySettings();
}catch(e){ toast(why(e), true); }
};
$('#dsave').onclick = () => {
const patch: Record<string, unknown> = {directory_url: $('#durl').value.trim(), directory_share: $('#dshare').checked};
$<HTMLButtonElement>('#dsave').onclick = () => {
const patch: Record<string, unknown> = {directory_url: $<HTMLInputElement>('#durl').value.trim(), directory_share: $<HTMLInputElement>('#dshare').checked};
// Blank leaves the token as it is; Clear is the way to remove it.
if($('#dtoken').value.trim()) patch.directory_token = $('#dtoken').value.trim();
if($<HTMLInputElement>('#dtoken').value.trim()) patch.directory_token = $<HTMLInputElement>('#dtoken').value.trim();
save(patch, 'Directory settings saved');
};
const clear = $('#dclear');
const clear = $<HTMLButtonElement>('#dclear');
if(clear) clear.onclick = () => save({directory_token: ''}, 'Token cleared');
drawDirectoryStatus();
}
@@ -161,12 +161,12 @@ async function drawDirectoryAdmin(){
const row = b.closest<HTMLElement>('.urow');
try{
if(b.dataset.a === 'drop'){
const name = row.dataset.name;
const name = row!.dataset.name!;
if(!confirm(`Drop ${name}'s feeds and counts from the directory? They come back the next time ${name} shares.`)) return;
await api(`/api/directory-instances/${encodeURIComponent(name)}`, {method: 'DELETE'});
toast(`Dropped ${name}'s snapshot`);
}else if(b.dataset.a === 'unblock'){
await api(`/api/directory-blocked/${encodeURIComponent(row.dataset.id)}`, {method: 'DELETE'});
await api(`/api/directory-blocked/${encodeURIComponent(row!.dataset.id!)}`, {method: 'DELETE'});
toast('Unblocked. It comes back with the next snapshot that has it');
}
drawDirectoryAdmin();
@@ -205,18 +205,18 @@ async function drawAccounts(){
drawAccounts(); // on a refusal, this puts the checkbox back where the server left it
};
for(const row of $$('#accounts [data-id]')){
const u = users.find(x => String(x.id) === row.dataset.id);
$('[data-a="admin"]', row).onchange = (e: Event & {target: HTMLInputElement}) =>
change(u, {method: 'PATCH', body: JSON.stringify({admin: e.target.checked})});
const u = users.find(x => String(x.id) === row.dataset.id)!;
$('[data-a="admin"]', row).onchange = (e: Event) =>
change(u, {method: 'PATCH', body: JSON.stringify({admin: (e.target as HTMLInputElement).checked})});
$('[data-a="rm"]', row).onclick = () => {
if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`))
change(u, {method: 'DELETE'});
};
}
$('#uadd').onclick = async () => {
$<HTMLButtonElement>('#uadd').onclick = async () => {
try{
await api('/api/users', {method: 'POST', body: JSON.stringify({
name: $('#uname').value, password: $('#upass').value, admin: $('#uadmin').checked})});
name: $<HTMLInputElement>('#uname').value, password: $<HTMLInputElement>('#upass').value, admin: $<HTMLInputElement>('#uadmin').checked})});
toast('Added'); drawAccounts();
}catch(e){ toast(why(e), true); } // keep what was typed
};
@@ -259,14 +259,14 @@ function drawLogView(){
The buffer keeps debug detail even when the terminal does not; <b>IPX_UI_LOG</b> changes
what it captures.</span>`;
for(const b of $$('#logtabs button')) b.onclick = () => {
logTab = b.dataset.t;
logTab = b.dataset.t!;
for(const x of $$('#logtabs button')) x.classList.toggle('on', x.dataset.t === logTab);
drawLog();
};
$('#loglevel').onchange = (e: Event & {target: HTMLSelectElement}) => { logLevel = e.target.value; drawLog(); };
$('#logq').oninput = (e: Event & {target: HTMLInputElement}) => { logFilter = e.target.value.toLowerCase(); drawLog(); };
$('#logcopy').onclick = () => copyText(visibleLog().map(l =>
`${new Date(l.ts * 1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $('#logcopy'));
$<HTMLSelectElement>('#loglevel').onchange = (e: Event) => { logLevel = (e.target as HTMLSelectElement).value; drawLog(); };
$<HTMLInputElement>('#logq').oninput = (e: Event) => { logFilter = (e.target as HTMLInputElement).value.toLowerCase(); drawLog(); };
$<HTMLButtonElement>('#logcopy').onclick = () => copyText(visibleLog().map(l =>
`${new Date(l.ts * 1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $<HTMLButtonElement>('#logcopy'));
pollLog();
if(!logTimer) logTimer = setInterval(pollLog, 2000);
}
@@ -294,7 +294,7 @@ function visibleLog(){
}
function drawLog(){
const box = $('#logbox'); if(!box) return;
const follow = $('#logfollow')?.checked;
const follow = $<HTMLInputElement>('#logfollow')?.checked;
const rows = visibleLog();
box.innerHTML = rows.length ? rows.map(l => {
const t = new Date(l.ts * 1000).toLocaleTimeString();