Phone reader: back and the item's actions float over the text (#456)

On a phone they sat above the title and scrolled away with it, so
marking, pinning or sharing a long post meant scrolling back to its
top. Now back sits alone on the left and the actions in a capsule on
the right, sticky at the top of the reader, as Gmail's iOS reader does.
Each theme's own panel, frosted, so no theme changes colour; Glass gets
its sheen and a denser fill, since the text scrolls right under them,
and Classic its toolbar metal. Read and pin redraw the reader, which
now keeps its scroll position, since they are pressed mid-post. The
desktop reader is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVA8TdTxe556JB3sR1n
This commit is contained in:
2026-11-13 17:43:45 +00:00
parent 757ce5fbb3
commit 47e093e313
4 changed files with 74 additions and 10 deletions

View File

@@ -429,9 +429,19 @@ function detailHtml(e: Entry){
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('');
const back=`<button class="btn ico" id="dback" title="Back to the items" aria-label="Back to the items">${ICON.left}</button>`;
const acts=`<span class="dacts"><button class="btn ico" data-a="read" title="Mark ${e.read?'unread':'read'}"
aria-label="Mark ${e.read?'unread':'read'}" aria-pressed="${!!e.read}">${e.read?ICON.check:ICON.unread}</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'}" aria-pressed="${!!e.flagged}">${e.flagged?ICON.pinOn:ICON.pin}</button>
${webUrl(e.link)?`<a class="btn ico" href="${esc(webUrl(e.link))}" target="_blank" rel="noopener noreferrer"
title="Open the original" aria-label="Open the original">${ICON.open}</a>
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>`:''}</span>`;
// description was sanitized server-side with ammonia before it ever reached here
return `
<button class="btn ico" id="dback" title="Back to the items" aria-label="Back to the items">${ICON.left}</button>
${/* On a phone, back and the item's actions float over the text as it scrolls, so they are
at hand anywhere in a long post, not only back at its top. */
narrow ? `<div class="dfloat">${back}${acts}</div>` : back}
${/* A post without a title starts with its text: its own first words as a heading above
themselves would read as a mistake. */ e.title&&e.title.trim() ? `<h3 class="dt">${esc(e.title)}</h3>` : ''}
<div class="dmeta">
@@ -441,13 +451,7 @@ function detailHtml(e: Entry){
.filter(Boolean).map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}
${e.markdown?`<select class="tview" aria-label="Show the text as">${TEXT_VIEWS.map(([v,label])=>
`<option value="${v}"${v===textView?' selected':''}>${label}</option>`).join('')}</select>`:''}
<span class="dacts"><button class="btn ico" data-a="read" title="Mark ${e.read?'unread':'read'}"
aria-label="Mark ${e.read?'unread':'read'}" aria-pressed="${!!e.read}">${e.read?ICON.check:ICON.unread}</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'}" aria-pressed="${!!e.flagged}">${e.flagged?ICON.pinOn:ICON.pin}</button>
${webUrl(e.link)?`<a class="btn ico" href="${esc(webUrl(e.link))}" target="_blank" rel="noopener noreferrer"
title="Open the original" aria-label="Open the original">${ICON.open}</a>
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>`:''}</span>
${narrow?'':acts}
</div>
${narrow?encs:''}
<div class="dbody">${textOf(e)}</div>`;
@@ -575,7 +579,14 @@ async function shareEpisode(e: Entry, encId: number|null){
async function epAction(a: string, e: Entry, el: HTMLElement|null, 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===entryKey(e)) showDetail(e); };
// The reader keeps its place: on a phone read and pin are pressed anywhere in a long post.
const redraw=()=>{
swapRow(e);
if(el && S.sel!==entryKey(e)) return;
const box=$('#detail'), top=box?.scrollTop??0;
showDetail(e);
if(box) box.scrollTop=top;
};
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{