Release 0.5.4: remembered view, Auto theme, Currently Listening

- Remember the feed/place and tab across a reload or new visit; an
  unknown or unsubscribed one lands on All Subscriptions instead of the
  first feed alphabetically.
- Add an Auto theme that follows the system's light/dark setting, and
  move Dark/Light/Classic/Auto into Settings as a dropdown alongside the
  header button's toggle.
- Add Currently Listening below Popular: episodes started and not
  finished, across every subscribed feed, one tap to resume. Reuses the
  existing entries/filter machinery (Filter::InProgress) rather than a
  new endpoint.
- Likely fix for the iOS bug where the topbar stopped responding to taps
  until a hard refresh (100vh -> 100dvh); unverified on a real device.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmQfE1eFPApnXWyPHBWqUA
This commit is contained in:
2026-09-14 15:38:03 +00:00
parent be3820bbbd
commit a30edff248
7 changed files with 222 additions and 33 deletions

View File

@@ -43,6 +43,29 @@
--bad:#b3402f;
--shadow:0 8px 28px rgba(45,83,145,.14);
}
/* Auto: the same palette as Light, but only while the system is set to light -- the default
:root above is already dark, so nothing is needed for the dark half of Auto. Duplicated
rather than shared with [data-theme="light"], the same way Classic repeats its own values;
CSS custom properties have no way to say "these vars, but only under this media query". */
@media (prefers-color-scheme: light) {
:root[data-theme="auto"] {
--bg:#f2f4f7;
--panel:#ffffff;
--panel2:#e9edf3;
--raise:#dde3ec;
--line:#d6d6d6;
--fg:#1a1a1a;
--dim:#606060;
--faint:#767676;
--accent:#2d5391;
--accent2:#9a5f0a;
--ink:#ffffff;
--good:#2f7d4f;
--warn:#b06f10;
--bad:#b3402f;
--shadow:0 8px 28px rgba(45,83,145,.14);
}
}
/* Classic: the 2004 Mac app. Colours here; the chrome it needs is at the end of the sheet. */
:root[data-theme="classic"] {
color-scheme:light;
@@ -69,7 +92,14 @@ html,body{height:100%}
body{
margin:0;background:var(--bg);color:var(--fg);
font:14.5px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
display:grid;grid-template-rows:auto 1fr auto auto;height:100vh;overflow:hidden;
display:grid;grid-template-rows:auto 1fr auto auto;overflow:hidden;
/* iOS Safari's address bar collapses and expands without firing a resize, so 100vh is
measured against whichever state happened to be current -- sized too tall while the bar
is showing, which puts the topbar (the hamburger included) under Safari's own chrome,
where a tap never reaches the page. Only a hard refresh reset it, forcing 100vh to be
recomputed. 100dvh tracks the real visible viewport as the bar moves; the 100vh above is
the fallback for a browser that does not know dvh. */
height:100vh;height:100dvh;
}
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
a{color:var(--accent)}
@@ -768,8 +798,13 @@ function nav(on){ $('#sidebar').classList.toggle('open',on); $('#scrim').hidden=
/* ---------------- state ---------------- */
const S = {
feeds:[], feed:null, entries:[], total:0, offset:0,
filter:'all', q:'', sel:null, me:null,
feeds:[],
// Which feed (or place) and which tab were open last time, so a refresh lands back where
// you were instead of jumping to the first feed alphabetically.
feed:(()=>{ try{ return localStorage.getItem('ipx.feed'); }catch{ return null; } })(),
entries:[], total:0, offset:0,
filter:(()=>{ try{ return localStorage.getItem('ipx.filter'); }catch{ return null; } })()||'all',
q:'', sel:null, me:null,
// 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; } })()
||{col:'published',dir:'desc'},
@@ -781,7 +816,13 @@ async function loadFeeds(keepSel){
S.feeds = await api('/api/feeds');
api('/api/settings').then(g=>{globalMax=g.max_new_per_check}).catch(()=>{});
renderFeeds();
if(!keepSel && !S.feed && S.feeds.length) selectFeed(S.feeds[0].id);
// Land back where you were; a feed you no longer subscribe to, or a first visit, goes to
// All Subscriptions rather than picking one alphabetically. Nothing to land on at all (a
// brand new account) leaves S.feed alone, so the empty state's own message shows instead.
if(!keepSel && S.feeds.length){
const known = S.feed && (VIEWS[S.feed] || S.feeds.some(f=>f.id===S.feed));
selectFeed(known ? S.feed : ':all');
}
}
// 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.
@@ -866,7 +907,8 @@ function renderFeeds(){
done();
}
function selectFeed(id){
S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value='';
S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value='';
try{ localStorage.setItem('ipx.feed',id); }catch{}
renderFeeds(); renderFeed(); loadEntries();
}
@@ -952,7 +994,11 @@ function renderFeed(){
showDetail(null);
$$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a,f):allAction(b.dataset.a));
$$('#content .tabs button').forEach(b=>b.onclick=()=>{S.filter=b.dataset.f;S.offset=0;renderFeed();loadEntries()});
$$('#content .tabs button').forEach(b=>b.onclick=()=>{
S.filter=b.dataset.f; S.offset=0;
try{ localStorage.setItem('ipx.filter',S.filter); }catch{}
renderFeed(); loadEntries();
});
if(f) wireFailBanner(box,f);
}
@@ -1635,16 +1681,42 @@ async function renderListed(v){
$('#tbRemove').disabled=true;
syncTools(null);
$('#epSearch').placeholder='Search items…';
const listening=v===VIEWS[':popular'];
box.innerHTML=`
<div class="fhead slim">
<div class="art">${v.icon}</div>
<div class="meta"><h2>${v.title}</h2>
<div class="sub">${v.blurb} Everyone counts, you included. Private feeds are never listed.</div></div>
</div>
<div class="childlist" id="popular"><p class="hint">Loading…</p></div>`;
<div class="childlist" id="popular"><p class="hint">Loading…</p></div>
${listening?`<div class="sub" style="margin:18px 0 8px;font-weight:600;color:var(--fg)">Currently Listening</div>
<div class="childlist" id="listening"><p class="hint">Loading…</p></div>`:''}`;
$('#count').textContent=v.title;
const n=await listFeeds(v.url);
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${n} feed${n===1?'':'s'}`;
if(listening) renderListening();
}
/// Below Popular: 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.
async function renderListening(){
const box=$('#listening');
let rows=[];
try{ rows=(await api('/api/entries?filter=in_progress&limit=10')).entries||[]; }catch{}
box.innerHTML=rows.length?'':'<p class="hint">Nothing in progress. Episodes you start and do not finish show up here.</p>';
for(const e of rows){
const el=document.createElement('div');
el.className='childrow';
const pct=e.duration?Math.min(100,Math.round(e.position/e.duration*100)):0;
el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+
`<div class="txt"><b>${esc(e.title||'(untitled)')}</b>`+
`<small class="meta">${esc(feedName(e.feed_id))} · ${clock(e.position)} of ${e.duration?clock(e.duration):'?'}</small>`+
`<div class="dlbar live"><i style="width:${pct}%"></i></div></div>`+
`<button class="btn ico primary" title="Resume" aria-label="Resume">${ICON.play}</button>`;
el.onclick=()=>play(e);
box.appendChild(el);
}
}
// The toolbar acts on whatever is selected: the feed on the left, the item in the table.
@@ -1702,6 +1774,11 @@ async function prefsModal(){
const gs = splitEvery(g.every_mins);
const admin = !!(S.me&&S.me.admin);
openModal(`<h3>Settings</h3>
<div class="field"><label>Theme</label>
<select id="stheme">${THEME_ORDER.map(t=>
`<option value="${t}"${document.documentElement.dataset.theme===t?' selected':''}>${THEMES[t]}</option>`).join('')}</select>
<span class="hint">Auto follows your system's light/dark setting. The same toggle is in
the header, one click at a time; this jumps straight to the one you want.</span></div>
<div class="field"><label>Check feeds every</label>
${admin?`<div class="inline">
<input type="number" id="gnum" min="1" max="999" value="${gs.n}">
@@ -1742,6 +1819,7 @@ async function prefsModal(){
<span class="hint">Add and remove the people who can sign in, and choose who is an admin.</span></div>`:''}
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="${admin?'Cancel':'Close'}" aria-label="${admin?'Cancel':'Close'}">${ICON.close}</button>
${admin?`<button class="btn ico primary" id="gsave" title="Save" aria-label="Save">${ICON.check}</button>`:''}</div>`);
$('#stheme').onchange=e=>setTheme(e.target.value);
$('#gopml').onclick=opmlModal;
if(!admin) return;
$('#gusers').onclick=usersModal;
@@ -1951,14 +2029,18 @@ $('#feedlist').onkeydown=ev=>{
};
$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
$('#scrim').onclick=()=>nav(false);
// Dark, light, and the 2004 Mac app. The button steps through them; the choice is remembered.
const THEMES={dark:'Dark',light:'Light',classic:'Classic, the 2004 Mac app'};
// Dark, light, the 2004 Mac app, and following the system. The header button steps through
// them and Settings offers the same four as a dropdown; either one sets ipx.theme and both
// read it, so they never disagree. Auto last in the cycle keeps the existing three clicks in
// "steps through dark, light and classic" reaching classic exactly where that test expects.
const THEMES={dark:'Dark',light:'Light',classic:'Classic, the 2004 Mac app',auto:'Auto (matches your system)'};
const THEME_ORDER=Object.keys(THEMES);
const nextTheme=t=>THEME_ORDER[(THEME_ORDER.indexOf(t)+1)%THEME_ORDER.length];
function setTheme(t){
if(!THEMES[t]) t='dark';
document.documentElement.dataset.theme=t;
$('#theme').title=`Theme: ${THEMES[t]}. Click for ${THEMES[nextTheme(t)]}`;
const sel=$('#stheme'); if(sel) sel.value=t;
try{ localStorage.setItem('ipx.theme',t); }catch{}
}
$('#theme').onclick=()=>setTheme(nextTheme(document.documentElement.dataset.theme));