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:
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user