Accounts and sign-in, and fix the read toggle

epAction's redraw closure called itself when handed a row, so Mark read
recursed until the stack blew; it now swaps that row in place. Opening an
item also marks it read, redrawn where it stands so nothing vanishes from
under the pointer on the Unread tab.

Step A of multi-user: users and sessions tables, Argon2id, a session
cookie, ipx user subcommands, and a trusted proxy header for Cloudflare
Zero Trust -- honoured only from a trusted_proxies address. The shared
token still works and is the admin. A new database starts with
admin/ipodderx.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
This commit is contained in:
2026-09-10 23:11:20 +00:00
parent ed26fa2061
commit 06f182b555
12 changed files with 876 additions and 53 deletions

View File

@@ -80,7 +80,15 @@ a{color:var(--accent)}
}
.sidetools button:hover,.sidefoot button:hover{border-color:var(--accent);color:var(--fg)}
/* Settings and the log are housekeeping: they sit under the feeds, out of the way. */
.sidefoot{display:flex;gap:6px;padding:8px 12px;border-top:1px solid var(--line);flex:none}
.sidefoot{
display:flex;flex-direction:column;gap:7px;padding:8px 12px;
border-top:1px solid var(--line);flex:none;
}
.sidefoot .row{display:flex;gap:6px}
.who{display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--faint)}
.who span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.who button{flex:none;background:none;border:0;padding:2px 4px;color:var(--faint);text-decoration:underline}
.who button:hover{color:var(--fg)}
.sidefoot button{display:inline-flex;align-items:center;justify-content:center;gap:6px}
.sidefoot i{font-style:normal;opacity:.75}
.searchwrap{padding:0 12px 8px}
@@ -368,8 +376,11 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
<div class="searchwrap"><input type="search" id="feedFilter" placeholder="Filter feeds…"></div>
<div id="feedlist"></div>
<div class="sidefoot">
<button id="prefs" title="Settings"><i></i>Settings</button>
<button id="logs" title="Daemon and web log"><i></i>Log</button>
<div class="who"><span id="who"></span><button id="signout" title="Sign out">Sign out</button></div>
<div class="row">
<button id="prefs" title="Settings"><i></i>Settings</button>
<button id="logs" title="Daemon and web log"><i></i>Log</button>
</div>
</div>
</aside>
<div id="main">
@@ -422,6 +433,7 @@ const esc = s => (s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt
async function api(url,opts){
const r = await fetch(url,{headers:{'Content-Type':'application/json'},...opts});
if(r.status===401){ location.href='/login'; throw new Error('signed out'); }
if(!r.ok) throw new Error(await r.text().catch(()=>r.status)||r.status);
return r.status===204?null:r.json().catch(()=>null);
}
@@ -765,9 +777,27 @@ function kindOf(enc){
function feedArt(){ const f=S.feeds.find(x=>x.id===S.feed); return f&&f.image; }
/// 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));
}
/// 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;
e.read=true;
api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
{method:'POST',body:JSON.stringify({read:true})})
.then(()=>loadFeeds(true)).catch(err=>{ e.read=false; toast(err.message,true); });
}
function selectEntry(e){
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;
}
@@ -861,8 +891,8 @@ function encBox(x){
}
async function epAction(a,e,el,encId){
// From a row we have the element to swap; from the detail pane we do not, so redraw.
const redraw=()=>{ if(el) redraw(); else renderEntries(); showDetail(e); };
// 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{
@@ -1304,6 +1334,8 @@ function on(sel,ev,fn){
}
$('#scanAll').onclick=async()=>{ toast('Scanning all feeds…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); };
on('#prefs','onclick',prefsModal);
on('#signout','onclick',async()=>{ await api('/api/logout',{method:'POST'}); location.href='/login'; });
api('/api/me').then(u=>{ $('#who').textContent=u.name+(u.admin?' · admin':''); }).catch(()=>{});
on('#logs','onclick',logsModal);
$('#feedFilter').oninput=renderFeeds;
$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));