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:
100
web/index.html
100
web/index.html
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user