Sortable item table, Size in its own column, Subscribed as an icon

- Every column heading sorts (kept, title, feed, file type, size,
  published); a second click reverses it. The server sorts through a
  fixed whitelist (order_sql), so it covers the whole list, not the
  fifty loaded; the choice is remembered in the browser.
- Size is its own column and shows KB for small files instead of
  "0 MB". The Item heading is Title.
- Popular/Directory/Add feed: Subscribed is a green circle-check.
- Tests: every sort column runs and orders both ways (db); the table
  sorts by title both ways and remembers across a reload (browser).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173mGu6rK18Ne7UGTwAaVJV
This commit is contained in:
2026-09-11 17:17:16 +00:00
parent 0efc49519c
commit 5362436766
6 changed files with 152 additions and 16 deletions

View File

@@ -242,6 +242,8 @@ a.btn{text-decoration:none;color:inherit}
it is here, red when the download failed. */
.kind{display:inline-grid;place-items:center;color:var(--dim)}
.kind.here{color:var(--good)}
.subbed{display:inline-flex;padding:0 9px;color:var(--good)}
.subbed .i{width:18px;height:18px}
.kind.bad{color:var(--bad)}
.fhead .art .i{width:22px;height:22px}
.toolbar{
@@ -254,16 +256,24 @@ a.btn{text-decoration:none;color:inherit}
.toolbar .grow{flex:1;min-width:150px;max-width:320px}
/* ---------- items ---------- */
/* A table, as the original's was: unread, kept, the item, its feed, its file, when. */
/* A table, as the original's was: unread, kept, title, feed, file, size, published. */
.ephead,.ep{
display:grid;gap:8px;align-items:center;
grid-template-columns:22px 22px minmax(120px,1fr) minmax(0,160px) minmax(0,112px) minmax(0,92px) 64px;
grid-template-columns:22px 22px minmax(120px,1fr) minmax(0,160px) 56px minmax(0,72px) minmax(0,92px) 64px;
}
.ephead{
position:sticky;top:0;z-index:2;background:var(--bg);padding:7px 10px 5px;
border-bottom:1px solid var(--line);margin-bottom:3px;
font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--faint);
}
/* Each heading sorts by its column; the caret says which way. */
.ephead .hs{display:flex;align-items:center;gap:4px;min-width:0;padding:0;border:0;background:none;
font:inherit;color:inherit;text-transform:inherit;letter-spacing:inherit;text-align:left;cursor:pointer}
.ephead .hs:hover,.ephead .hs.on{color:var(--fg)}
.ephead .hs .arr{display:inline-flex}
.ephead .hs .arr .i{width:8px;height:8px}
.ephead .hs .arr.asc{transform:rotate(-90deg)}
.ephead .hs .arr.desc{transform:rotate(90deg)}
.ep{padding:4px 10px;border-radius:7px;border:1px solid transparent;cursor:pointer;margin-bottom:1px}
.ep>*{min-width:0}
.ep.sel{background:var(--raise);border-color:var(--line)}
@@ -277,12 +287,12 @@ a.btn{text-decoration:none;color:inherit}
.ep.read .t{color:var(--dim);font-weight:500}
.ep .line{display:flex;gap:9px;align-items:center;flex-wrap:wrap;color:var(--faint);font-size:11.5px}
.ep .line:empty{display:none}
.ep .fd,.ep .date{color:var(--dim);font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ep .fd,.ep .size,.ep .date{color:var(--dim);font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ep .file{display:flex;gap:6px;align-items:center;flex-wrap:wrap;color:var(--faint);font-size:11.5px}
.ep .file .dlbar{flex-basis:100%;margin-top:2px}
.ep .rowacts{justify-content:flex-end}
/* One feed's own table has no need of a Feed column; All Subscriptions does. */
#split.one .ephead,#split.one .ep{grid-template-columns:22px 22px minmax(120px,1fr) minmax(0,112px) minmax(0,92px) 64px}
#split.one .ephead,#split.one .ep{grid-template-columns:22px 22px minmax(120px,1fr) 56px minmax(0,72px) minmax(0,92px) 64px}
#split.one .h-fd,#split.one .ep .fd{display:none}
.dot{width:3px;height:3px;border-radius:50%;background:var(--faint);flex:none}
.chip{
@@ -422,7 +432,7 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
/* One pane at a time: the list, then the item over it. */
#split{grid-template-columns:1fr;grid-template-rows:1fr;grid-template-areas:"list"}
/* Title and date only; the files follow the item's text in the reader instead. */
#files,.ephead,.ep .fd,.ep .file,.ep .rowacts{display:none}
#files,.ephead,.ep .fd,.ep .file,.ep .size,.ep .rowacts{display:none}
.ep,#split.one .ep{grid-template-columns:20px 20px minmax(0,1fr) auto}
#grab{display:none}
#detail{position:fixed;inset:0;z-index:38;display:none;border-top:0;padding:12px 16px 90px}
@@ -498,6 +508,7 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
:root[data-theme="classic"] #eps .ep.sel{background:#3875d7;border-color:#3875d7}
:root[data-theme="classic"] .ep.sel .t,
:root[data-theme="classic"] .ep.sel .fd,
:root[data-theme="classic"] .ep.sel .size,
:root[data-theme="classic"] .ep.sel .date,
:root[data-theme="classic"] .ep.sel .line,
:root[data-theme="classic"] .ep.sel .st,
@@ -631,6 +642,7 @@ const ICON={
pause:fa('0 0 384 512','<path fill="currentColor" d="M48 32C21.5 32 0 53.5 0 80L0 432c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48L48 32zm224 0c-26.5 0-48 21.5-48 48l0 352c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48l-64 0z"/>'), // solid/pause
caret:fa('0 0 256 512','<path fill="currentColor" d="M249.3 235.8c10.2 12.6 9.5 31.1-2.2 42.8l-128 128c-9.2 9.2-22.9 11.9-34.9 6.9S64.5 396.9 64.5 384l0-256c0-12.9 7.8-24.6 19.8-29.6s25.7-2.2 34.9 6.9l128 128 2.2 2.4z"/>'), // solid/caret-right
left:fa('0 0 512 512','<path fill="currentColor" d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l160 160c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.3 288 480 288c17.7 0 32-14.3 32-32s-14.3-32-32-32l-370.7 0 105.4-105.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-160 160z"/>'), // solid/arrow-left
subbed:fa('0 0 512 512','<path fill="currentColor" d="M256 512a256 256 0 1 1 0-512 256 256 0 1 1 0 512zM374 145.7c-10.7-7.8-25.7-5.4-33.5 5.3L221.1 315.2 169 263.1c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l72 72c5 5 11.8 7.5 18.8 7s13.4-4.1 17.5-9.8L379.3 179.2c7.8-10.7 5.4-25.7-5.3-33.5z"/>'), // solid/circle-check
};
// One meaning per icon: minus unsubscribes, x closes or cancels, plus adds or subscribes, and a
// dialog's confirm button carries the icon of what it does. Words go in the tooltip.
@@ -693,7 +705,9 @@ const ago = t => {
return new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'});
};
const dateOf = t => t?new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}):'';
const mb = n => n?(n/1048576).toFixed(0)+' MB':'';
// A podcast episode is tens of MB, an article's image a few KB: whole MB made the small ones "0 MB".
const mb = n => !n?'' : n<1048576?Math.max(1,Math.round(n/1024))+' KB'
: n<1073741824?Math.round(n/1048576)+' MB' : (n/1073741824).toFixed(1)+' GB';
const initials = s => (s||'?').replace(/[^A-Za-z0-9 ]/g,'').split(/\s+/).filter(Boolean).slice(0,2).map(w=>w[0]).join('').toUpperCase()||'?';
function artHTML(url,name,cls){
return url
@@ -708,6 +722,9 @@ function nav(on){ $('#sidebar').classList.toggle('open',on); $('#scrim').hidden=
const S = {
feeds:[], feed:null, entries:[], total:0, offset:0, limit:50,
filter:'all', q:'', sel:null, busy:new Set(), 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'},
};
const LIMIT = 50;
@@ -849,9 +866,9 @@ function renderFeed(){
const pane=document.createElement('div');
pane.id='split';
if(f) pane.className='one';
pane.innerHTML='<div id="list"><div class="ephead"><span></span><span title="Kept">'+ICON.flag+'</span>'+
'<span>Item</span><span class="h-fd">Feed</span><span>File</span><span>Published</span><span></span></div>'+
pane.innerHTML='<div id="list">'+sortHead()+
'<div id="eps"></div></div><div id="files"></div><div id="grab"></div><div id="detail"></div>';
$$('.ephead [data-sort]',pane).forEach(b=>b.onclick=()=>sortBy(b.dataset.sort));
box.appendChild(pane);
pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '40%');
dragSplit(pane);
@@ -861,6 +878,24 @@ function renderFeed(){
$$('#content .tabs button').forEach(b=>b.onclick=()=>{S.filter=b.dataset.f;S.offset=0;renderFeed();loadEntries()});
}
/// The item table's headings, each a button that sorts by its column. The first click goes the
/// natural way round (A to Z; newest, largest and kept first) and the next one reverses it.
const COLS=[['kept','Kept',ICON.flag],['title','Title'],['feed','Feed'],['type','File'],['size','Size'],['published','Published']];
function sortHead(){
return '<div class="ephead"><span></span>'+COLS.map(([k,label,icon])=>{
const on=S.sort.col===k;
return `<button class="hs${on?' on':''}${k==='feed'?' h-fd':''}" data-sort="${k}"`+
` title="Sort by ${label.toLowerCase()}" aria-label="Sort by ${label.toLowerCase()}">${icon||label}`+
`${on?`<span class="arr ${S.sort.dir}">${ICON.caret}</span>`:''}</button>`;
}).join('')+'<span></span></div>';
}
function sortBy(col){
const first=['published','size','kept'].includes(col)?'desc':'asc';
S.sort={col,dir:S.sort.col===col?(S.sort.dir==='asc'?'desc':'asc'):first};
try{ localStorage.setItem('ipx.sort',JSON.stringify(S.sort)); }catch{}
S.offset=0; renderFeed(); loadEntries();
}
/// An OPML subscription's page lists the feeds inside it rather than items, but keeps
/// every action a normal feed has -- it is still an ordinary feed entry underneath.
function renderGroup(f,kids){
@@ -942,7 +977,7 @@ async function allAction(a){
async function loadEntries(append){
// Directory and Popular list feeds, not items.
if(!S.feed || VIEWS[S.feed]?.url) return;
const p=new URLSearchParams({offset:S.offset,limit:LIMIT,filter:S.filter});
const p=new URLSearchParams({offset:S.offset,limit:LIMIT,filter:S.filter,sort:S.sort.col,dir:S.sort.dir});
if(S.q) p.set('q',S.q);
const r=await api(S.feed===':all' ? `/api/entries?${p}`
: `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`);
@@ -1002,9 +1037,9 @@ function epEl(e){
<span class="fd">${esc(feedName(e.feed_id))}</span>
<div class="file">
${enc?kindIcon(enc):''}
${enc&&enc.length?`<span>${mb(enc.length)}</span>`:''}
${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>`:
@@ -1480,7 +1515,8 @@ async function listFeeds(url){
el.innerHTML=artHTML(p.image,p.title||p.id)+
`<div class="txt"><b>${esc(p.title||p.id)}</b>`+
`<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+
(p.subscribed?'<span class="tag">Subscribed</span>'
// Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one.
(p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>`
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button>`);
// Yours already: the row opens it instead.
if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; box.appendChild(el); continue; }