The page's script is TypeScript in web/src, built and minified with swc

- web/src/*.ts: the script that was inline in index.html and login.html, split along its
  existing sections. Still one scope, concatenated in order, not modules.
- web/build.mjs strips the types, puts the script in the page and minifies it with swc;
  build.rs runs it into OUT_DIR and web.rs include_str!s the result. 137 KB -> 106 KB.
- npx tsc -p . type-checks web/src, loosely; the handful of annotations it needed
  change no behaviour.
- The Docker build installs node and swc (npm ci --omit=dev).
- Two list requests racing no longer let the older one win, and switching tabs clears
  the selection it closes, which made a browser test flaky.

Closes #23, #24.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 12:41:34 +00:00
parent fbba447ca6
commit 26802d2b23
22 changed files with 2669 additions and 1666 deletions

340
web/src/items.ts Normal file
View File

@@ -0,0 +1,340 @@
/* ---------------- items ---------------- */
let loadSeq=0;
async function loadEntries(append?: boolean){
// Directory and Popular list feeds, not items.
if(!S.feed || VIEWS[S.feed]?.url) return;
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}`
: `/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.
if(seq!==loadSeq) return;
S.total=r.total;
for(const e of r.entries){
const w=readWrites.get(readKey(e));
if(w && !(w.done<asked)) e.read=w.read;
}
let entries = append ? S.entries.concat(r.entries) : r.entries;
// A background scan finishing refreshes the list from the server, which -- on the Unread
// 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.
if(S.filter==='unread') entries=entries.filter(e=>!e.read||e.guid===S.sel);
if(!append && S.sel && !entries.some(e=>e.guid===S.sel)){
const open=S.entries.find(e=>e.guid===S.sel);
if(open) entries=[open,...entries];
}
S.entries = entries;
renderEntries();
}
function renderEntries(){
const box=$('#eps'); if(!box) return;
const f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed];
const unread=f?f.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0);
$('#count').textContent=
`${f?(f.title||f.id):v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`;
const pane=$('#list'), top=pane?pane.scrollTop:0;
box.innerHTML='';
if(!S.entries.length){
box.innerHTML=`<p class="empty">${S.q?'Nothing matches that search.':'Nothing here yet — try Scan now.'}</p>`;
return;
}
for(const e of S.entries) box.appendChild(epEl(e));
if(pane) pane.scrollTop=top;
if(S.entries.length < S.total){
const b=document.createElement('button');
b.className='btn'; b.id='more'; b.textContent=`Load more (${S.entries.length} of ${S.total})`;
b.onclick=()=>{S.offset+=LIMIT;loadEntries(true)};
box.appendChild(b);
}
}
function epEl(e){
// 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.
const enc=e.enclosures.find(isPlayable) || e.enclosures.find(x=>x.path) || e.enclosures[0];
const has=!!(enc&&enc.path);
const playable=isPlayable(enc);
const others=e.enclosures.length-1;
const el=document.createElement('div');
el.className='ep'+(e.read?' read':'')+(S.sel===e.guid?' sel':'');
el.dataset.guid=e.guid;
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
const left = e.position>10 && e.duration ? `${clock(e.duration-e.position)} left` : (e.duration?clock(e.duration):'');
el.innerHTML=`
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}">${
player.guid===e.guid?EQ:(e.read?'':'●')}</button>
<button class="fl${e.flagged?' on':''}" data-a="flag" title="${
e.flagged?'Unpin':'Pin, so it is never deleted'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
<div class="body">
<span class="t">${esc(e.title||'(untitled)')}</span>
<div class="line">${[
num&&`<span>${num}</span>`,
left&&`<span>${left}</span>`,
others>0&&`<span>+${others} more file${others===1?'':'s'}</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>
</div>
<span class="fd">${esc(feedName(e.feed_id))}</span>
<div class="file">
${enc?kindIcon(enc):''}
${enc&&!has?`<div class="dlbar" data-bar="${enc.id}"><i></i></div>`:''}
</div>
<span class="size">${enc?mb(enc.length):''}</span>
<span class="date">${dateOf(e.published)}</span>
<div class="rowacts">
${playable?`<button class="iconbtn" data-a="play" title="Play" aria-label="Play">${ICON.play}</button>`:
(enc&&!has?`<button class="iconbtn" data-a="get" title="Download this ${
enc.state==='skipped'?kindOf(enc):'file'}">${ICON.download}</button>`:'')}
${has?`<button class="iconbtn" data-a="del" title="Delete file" aria-label="Delete file">${ICON.trash}</button>`:''}
</div>`;
el.onclick=()=>selectEntry(e);
$$('button[data-a]',el).forEach(b=>b.onclick=ev=>{ev.stopPropagation();epAction(b.dataset.a,e,el)});
return el;
}
/// Whether the browser can play it. Having a file is not the same as being playable:
/// blog feeds put article images in enclosures, and an <audio> element pointed at a JPEG
/// is just a broken player.
function isPlayable(x){
if(!x || !x.path) return false;
const m=(x.mime||'').toLowerCase();
if(m.startsWith('audio/')||m.startsWith('video/')) return true;
if(m) return false;
// No declared type: fall back to the file's extension.
return /\.(mp3|m4a|m4b|aac|ogg|oga|opus|flac|wav|mp4|m4v|mov|webm|mkv)$/i
.test((x.path||x.url||'').split('?')[0]);
}
/// What an enclosure is, for a row that is not media: "image", "pdf", "document".
function kindOf(enc){
const m=(enc.mime||'').toLowerCase();
if(m.startsWith('image/')) return 'image';
if(m.startsWith('video/')) return 'video';
if(m.startsWith('audio/')) return 'audio';
if(m.includes('pdf')) return 'pdf';
if(m.includes('torrent')) return 'torrent';
const ext=(enc.url||'').split('?')[0].split('.').pop();
return (ext && ext.length<=5) ? ext.toLowerCase() : 'file';
}
/// What a file is, as one icon coloured by whether it is here: green once downloaded, red when
/// the download failed, plain otherwise, so a file waiting and one deleted read alike. One icon
/// either way keeps the column lined up; the words are in its tooltip.
function kindIcon(enc){
const k=kindOf(enc);
const i={audio:ICON.audio,video:ICON.video,image:ICON.image,pdf:ICON.doc,torrent:ICON.torrent}[k]||ICON.file;
const [cls,label]=enc.path ? [' here',`${k}, downloaded`]
: enc.state==='error' ? [' bad',`${k}, download failed${enc.last_error?': '+enc.last_error:''}`]
: ['',k];
return `<span class="kind${cls}" title="${esc(label)}" aria-label="${esc(label)}">${i}</span>`;
}
function feedArt(id=S.feed){ const f=S.feeds.find(x=>x.id===id); return f&&f.image; }
const feedName=id=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id):id; };
/// Selecting an item shows it in the pane below, rather than expanding the row.
/// Replaces one row with a fresh one, leaving the rest of the list and its scroll alone.
function swapRow(e){
const row=$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`);
if(row) row.replaceWith(epEl(e));
}
/// Read and unread as this page last set them, and when the server had it. A list asked for
/// before then answers with the old state: it put the dot back on an item just read, until
/// the next refresh took it off again (loadEntries).
const readWrites=new Map();
const readKey=e=>e.feed_id+'\n'+e.guid;
function setRead(e,read){
e.read=read;
const w: {read: boolean, done?: number}={read}; readWrites.set(readKey(e),w);
return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
{method:'POST',body:JSON.stringify({read})})
.then(()=>{ w.done=performance.now(); loadFeeds(true); });
}
/// Opening an item is reading it. The row is redrawn where it stands rather than the list
/// reloaded, so an item does not vanish from under the pointer on the Unread tab.
function markRead(e){
if(e.read) return;
setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(err.message,true); });
}
function selectEntry(e){
// On the Unread tab the item you were reading goes as you move on, not whenever a refresh
// next happens to come along, which left a few read ones in the list for a while.
const prev=S.filter==='unread' && S.sel!==e.guid && S.entries.find(x=>x.guid===S.sel);
if(prev&&prev.read){
S.entries=S.entries.filter(x=>x!==prev); S.total--;
$(`#eps .ep[data-guid="${CSS.escape(prev.guid)}"]`)?.remove();
}
S.sel=e.guid;
markRead(e);
$$('#eps .ep').forEach(x=>x.classList.toggle('sel', x.dataset.guid===e.guid));
swapRow(e);
showDetail(e);
const d=$('#detail'); if(d) d.scrollTop=0;
}
/// Drag the divider between the item list and the item text.
function dragSplit(pane){
const grab=$('#grab',pane);
if(!grab) return;
const move=ev=>{
const box=pane.getBoundingClientRect();
const pct=Math.min(80,Math.max(12,((ev.clientY-box.top)/box.height)*100));
pane.style.setProperty('--listh',pct.toFixed(1)+'%');
};
const stop=()=>{
document.removeEventListener('mousemove',move);
document.removeEventListener('mouseup',stop);
document.body.style.userSelect='';
try{ localStorage.setItem('ipx.listh', pane.style.getPropertyValue('--listh')); }catch{}
};
grab.onmousedown=ev=>{
ev.preventDefault();
document.body.style.userSelect='none';
document.addEventListener('mousemove',move);
document.addEventListener('mouseup',stop);
};
}
/// Which item the toolbar's play, read and keep buttons act on.
// ponytail: matched by guid alone; two feeds sharing a guid in All Subscriptions would pick
// the first. Key rows by feed as well if that ever happens.
const cur=()=>S.entries.find(x=>x.guid===S.sel);
function syncTools(e){
$('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable));
$('#tbRead').disabled=$('#tbFlag').disabled=!e;
// The same icons as the item's own buttons beside its title, so the two never disagree.
$('#tbRead').innerHTML=e&&e.read?ICON.unread:ICON.check;
$('#tbFlag').innerHTML=e&&e.flagged?ICON.pinOn:ICON.pin;
if(e){
$('#tbRead').title=`Mark ${e.read?'unread':'read'}`;
$('#tbFlag').title=e.flagged?'Unpin':'Pin, so it is never deleted';
}
}
/// The item's text in the pane below the list, and its files in the pane beside it.
function showDetail(e){
const box=$('#detail'), files=$('#files'); if(!box) return;
// A pane that can only say "No files" gives its width to the list instead.
if(files) files.hidden=!(e&&e.enclosures.length);
document.body.classList.toggle('reading',!!e);
syncTools(e);
if(!e){
box.innerHTML='<p class="empty">Pick an item to read it.</p>';
if(files) files.innerHTML='<p class="empty">No files</p>';
return;
}
const encs=e.enclosures.map(encBox).join('')||'<p class="empty">No files</p>';
// A phone has no room for the files pane, so the files go above the text there instead.
// Below it, a long set of show notes pushed play and delete screens down, and on an iPhone
// it looked as if a downloaded file could not be deleted at all (issue #21).
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
const f=S.feeds.find(x=>x.id===e.feed_id);
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
box.innerHTML=`
<button class="btn ico" id="dback" title="Back to the items" aria-label="Back to the items">${ICON.left}</button>
<h3 class="dt">${esc(e.title||'(untitled)')}</h3>
<div class="dmeta">
${/* Joined, so a missing date or number leaves no stray dot behind. */
[f&&esc(f.title||f.id), num, dateOf(e.published), e.duration&&clock(e.duration)]
.filter(Boolean).map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}
<button class="btn ico" data-a="read" title="Mark ${e.read?'unread':'read'}"
aria-label="Mark ${e.read?'unread':'read'}">${e.read?ICON.unread:ICON.check}</button>
<button class="btn ico" data-a="flag" title="${e.flagged?'Pinned: never deleted. Unpin':'Pin, so it is never deleted'}"
aria-label="${e.flagged?'Unpin':'Pin'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
${e.link?`<a class="btn ico" href="${esc(e.link)}" target="_blank" rel="noopener noreferrer"
title="Open the original" aria-label="Open the original">${ICON.open}</a>`:''}
</div>
${narrow?encs:''}
<div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>`;
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
// description was sanitized server-side with ammonia before it ever reached here
$('#dback').onclick=()=>showDetail(null);
for(const root of [box,files]) if(root) $$('button[data-a]',root).forEach(b=>
b.onclick=()=>epAction(b.dataset.a,e,null,b.dataset.enc?Number(b.dataset.enc):null));
}
/// One enclosure: a play button when the file is here, otherwise what it is and a way to get it.
function encBox(x){
const size=x.length?mb(x.length):'';
// One file serves everyone reading the feed, so deleting is not a private act.
const f=S.feeds.find(y=>y.id===x.feed_id);
const shared=f&&f.subscribers>1;
// Icons, with the words in the tooltip and for screen readers.
const delLabel=shared
? `Delete for everyone (shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'} reading this feed)`
: '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 saveBtn=`<a class="btn ico" href="/media/${x.id}" download title="Save to this computer" aria-label="Save to this computer">${ICON.save}</a>`;
if(x.path && !isPlayable(x)){
// On disk, but not audio or video: view it, keep it, or remove it -- no player.
return `<div class="encbox">
${kindIcon(x)}
<span class="meta" style="flex:1">${size}</span>
<a class="btn ico" href="/media/${x.id}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>
${saveBtn}
${delBtn}
</div>`;
}
if(x.path){
// One player, the bar at the bottom. This pane had an <audio> of its own, and playing it
// started the bar as well, so the same file played twice at once.
return `<div class="encbox">
${kindIcon(x)}
<span class="meta" style="flex:1">${size}</span>
<button class="btn ico" data-a="play" data-enc="${x.id}" title="Play" aria-label="Play">${ICON.play}</button>
${saveBtn}
${delBtn}
</div>`;
}
// Nothing on disk. For an image or a PDF you usually just want to look at it, so link
// straight to the publisher's copy in a new tab -- no download, and nothing proxied
// through here, which would make ipx a fetch-anything relay.
const viewable = !isPlayable(x) && x.state !== 'pending';
return `<div class="encbox">
${kindIcon(x)}
<span class="meta" style="flex:1">${size}</span>
${x.state==='error'&&x.last_error?`<span class="err">${esc(x.last_error)}</span>`:''}
${viewable?`<a class="btn ico" href="${esc(x.url)}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>`:''}
<button class="btn ico" data-a="get" data-enc="${x.id}" title="Download to the server" aria-label="Download to the server">${ICON.download}</button>
</div>`;
}
async function epAction(a: string, e, el, 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];
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
try{
if(a==='play') play(e, encId!=null ? enc : undefined);
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); }
if(a==='read'){ await setRead(e,!e.read); redraw(); }
if(a==='get'){
if(!enc) return;
await api(`/api/enclosures/${enc.id}/download`,{method:'POST'});
toast('Queued: '+(e.title||'item'));
}
if(a==='del'){
const f=S.feeds.find(x=>x.id===e.feed_id);
const shared=f&&f.subscribers>1;
if(!confirm(shared
? `Delete this file?\n\nThere is one copy, shared with ${f.subscribers-1} other `
+`${f.subscribers===2?'person':'people'} reading this feed. The item stays listed `
+`and will not be downloaded again automatically.`
: 'Delete the downloaded file?\n\nThe item stays listed and will not be downloaded again automatically.')) return;
try{
await api(`/api/enclosures/${enc.id}`,{method:'DELETE'});
}catch(err){
// 409: somebody else has it starred or unplayed. Their reason, their words.
if(!/one copy of this file/.test(err.message)) throw err;
if(!confirm(err.message+'\n\nDelete it anyway?')) return;
await api(`/api/enclosures/${enc.id}?force=true`,{method:'DELETE'});
}
toast('Deleted'); loadEntries(); loadFeeds(true);
}
}catch(err){ toast(err.message,true); }
}