subscriptions.pinned, per person, set by PATCH /api/feeds/{id} {pinned} and
returned as FeedRow.pinned. Kept out of Sub, which the scanner merges into its
policy; set_subscription names its columns, so saving a feed's settings leaves
the pin alone (tested).
Pinned feeds come first in the list, a pin before the name and a rule under the
block: a pinned folder with its feeds under it, a feed from inside one lifted out
of it. The pin button is on both the feed and the folder page.
Closes #33.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
186 lines
10 KiB
TypeScript
186 lines
10 KiB
TypeScript
/* ---------------- feed page ---------------- */
|
|
function renderFeed(){
|
|
const box=$('#content');
|
|
box.classList.remove('plain');
|
|
const v=VIEWS[S.feed];
|
|
if(v&&v.url) return renderListed(v);
|
|
const f=v?null:S.feeds.find(x=>x.id===S.feed);
|
|
$('#tbRemove').disabled=!f;
|
|
syncTools(null);
|
|
if(!v&&!f){ box.innerHTML='<p class="empty">Add a feed to get started.</p>'; $('#count').textContent=''; return; }
|
|
const name=f?(f.title||f.id):v.title;
|
|
$('#epSearch').placeholder=`Search ${name}…`;
|
|
const kids=f?S.feeds.filter(c=>c.group===f.id):[];
|
|
if(kids.length){ box.classList.add('plain'); renderGroup(f,kids); return; }
|
|
const unreadAll=S.feeds.reduce((n,x)=>n+(x.unread||0),0);
|
|
box.innerHTML = (f ? `
|
|
<div class="fhead slim">
|
|
${artHTML(f.image,name)}
|
|
<div class="meta">
|
|
<h2>${esc(name)}</h2>
|
|
<div class="sub stat" title="Checked every ${everyText(f.every_mins)}${f.next_check?`, next ${due(f.next_check)}`:''}">${
|
|
plural(f.entries,'item')}, ${f.downloaded} downloaded · checked ${ago(f.last_checked)}${
|
|
f.subscribers>1?` · shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'}`:''}</div>
|
|
${failBannerHTML(f)}
|
|
${f.orphaned?`<div class="sub" style="color:var(--warn)">This feed is no longer listed in its
|
|
OPML subscription. It was kept rather than removed because it has downloaded items.</div>`:''}
|
|
${f.group?`<div class="sub">From the OPML subscription <b>${esc(f.group)}</b></div>`:''}
|
|
</div>
|
|
<div class="acts">
|
|
<button class="btn ico primary" data-a="scan" title="Check this feed now" aria-label="Check this feed now">${ICON.scan}</button>
|
|
<button class="btn ico" data-a="dl" title="Download latest…" aria-label="Download latest">${ICON.download}</button>
|
|
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
|
|
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
|
|
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
|
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
|
</div>
|
|
</div>` : `
|
|
<div class="fhead slim">
|
|
<div class="art">${v.icon}</div>
|
|
<div class="meta">
|
|
<h2>${v.title}</h2>
|
|
<div class="sub">Every item from the ${S.feeds.length} feed${S.feeds.length===1?'':'s'} you
|
|
subscribe to, newest first · ${unreadAll} unread</div>
|
|
</div>
|
|
<div class="acts">
|
|
<button class="btn ico primary" data-a="scanall" title="Check every feed now" aria-label="Check every feed now">${ICON.scan}</button>
|
|
<button class="btn ico" data-a="readall" title="Mark everything read" aria-label="Mark everything read">${ICON.checks}</button>
|
|
</div>
|
|
</div>`) + `
|
|
<div class="toolbar">
|
|
<div class="tabs">
|
|
${[['all','All'],['unread','Unread'],['downloaded','Downloaded'],['flagged','Pinned']].map(([t,label])=>
|
|
`<button data-f="${t}" class="${S.filter===t?'on':''}">${label}</button>`).join('')}
|
|
</div>
|
|
</div>`;
|
|
|
|
const pane=document.createElement('div');
|
|
pane.id='split';
|
|
if(f) pane.className='one';
|
|
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') || '60%');
|
|
dragSplit(pane);
|
|
// Nothing is open any more, so nothing is kept on the Unread tab for being open.
|
|
S.sel=null;
|
|
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;
|
|
try{ localStorage.setItem('ipx.filter',S.filter); }catch{}
|
|
renderFeed(); loadEntries();
|
|
});
|
|
if(f) wireFailBanner(box,f);
|
|
}
|
|
|
|
/// 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','Pinned',ICON.pin],['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':''}${icon?' h-ic':''}" 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.
|
|
// A Patreon creator split into its shows is drawn like an OPML, and named for what it is.
|
|
const isPatreon=f=>/patreon\.com\//.test(f&&f.url||'');
|
|
|
|
function renderGroup(f,kids){
|
|
const unread=kids.reduce((n,c)=>n+c.unread,0);
|
|
const saved=kids.reduce((n,c)=>n+c.downloaded,0);
|
|
const gone=kids.filter(c=>c.orphaned).length;
|
|
$('#count').textContent=`${f.title||f.id}: ${kids.length} feed${kids.length===1?'':'s'}, ${unread} unread`;
|
|
// The same header as a feed's, buttons in the same places: it is a feed underneath.
|
|
$('#content').innerHTML = `
|
|
<div class="fhead slim">
|
|
${folderArt(f,kids)}
|
|
<div class="meta">
|
|
<h2>${esc(f.title||f.id)}</h2>
|
|
<div class="sub stat" title="Checked every ${everyText(f.every_mins)}">${isPatreon(f)?'Patreon creator':'OPML subscription'}
|
|
· ${plural(kids.length,'feed')}, ${unread} unread, ${saved} downloaded · checked ${ago(f.last_checked)}</div>
|
|
${failBannerHTML(f)}
|
|
${gone?`<div class="sub" style="color:var(--warn)">${gone} feed${gone===1?' is':'s are'} no longer
|
|
listed but kept because ${gone===1?'it has':'they have'} downloads.</div>`:''}
|
|
</div>
|
|
<div class="acts">
|
|
<button class="btn ico primary" data-a="scan" title="Re-read the OPML now" aria-label="Re-read the OPML now">${ICON.scan}</button>
|
|
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
|
|
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
|
|
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
|
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
|
</div>
|
|
</div>
|
|
<div class="toolbar">
|
|
<input type="search" class="grow" id="kidSearch" placeholder="Search these feeds…">
|
|
<span style="color:var(--faint);font-size:12.5px">${esc(f.url)}</span>
|
|
</div>
|
|
<div class="childlist" id="kidlist"></div>`;
|
|
$$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a,f));
|
|
wireFailBanner($('#content'),f);
|
|
const draw=()=>{
|
|
const q=($('#kidSearch').value||'').trim().toLowerCase();
|
|
const box=$('#kidlist'); box.innerHTML='';
|
|
const rows=kids.filter(c=>!q||(c.title||c.id).toLowerCase().includes(q)).sort(unreadFirst);
|
|
if(!rows.length){ box.innerHTML='<p class="empty">Nothing matches.</p>'; return; }
|
|
for(const c of rows){
|
|
const el=document.createElement('div');
|
|
el.className='childrow';
|
|
el.innerHTML = artHTML(c.image,c.title||c.id)+
|
|
`<div class="txt"><b>${esc(c.title||c.id)}</b>`+
|
|
`<small class="meta">${plural(c.entries,'item')} · ${c.downloaded} downloaded`+
|
|
(c.failing?` · <span style="color:var(--bad)" title="${esc(c.failing.reason)}">error</span>`
|
|
:c.last_error?` · <span style="color:var(--bad)">error</span>`:'')+`</small></div>`+
|
|
(c.orphaned?'<span class="tag">Gone</span>':'')+
|
|
(c.failing?`<span class="tag" style="color:var(--bad)" title="${esc(c.failing.reason)}">Error</span>`:'')+
|
|
`<span class="badge${c.unread?'':' zero'}">${c.unread}</span>`;
|
|
el.onclick=()=>selectFeed(c.id);
|
|
box.appendChild(el);
|
|
}
|
|
};
|
|
$('#kidSearch').oninput=draw;
|
|
draw();
|
|
}
|
|
|
|
async function feedAction(a,f){
|
|
if(a==='scan'){ toast('Scanning '+(f.title||f.id)+'…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
|
|
if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); }
|
|
if(a==='rm') removeFeed(f);
|
|
if(a==='pin'){
|
|
try{
|
|
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({pinned:!f.pinned})});
|
|
await loadFeeds(true); renderFeed();
|
|
}catch(e){ toast(e.message,true); }
|
|
}
|
|
if(a==='settings') settingsModal(f);
|
|
if(a==='dl') downloadLatestModal(f);
|
|
}
|
|
/// All Subscriptions' own buttons: a feed's, across every feed you read.
|
|
async function allAction(a){
|
|
if(a==='scanall') return scanAll();
|
|
if(a==='readall'){
|
|
const n=S.feeds.reduce((k,x)=>k+(x.unread||0),0);
|
|
if(!n){ toast('Nothing unread'); return; }
|
|
// One click across every feed is a lot to take back, so this one asks first.
|
|
if(!confirm(`Mark all ${n} unread item${n===1?'':'s'} read, in every feed you subscribe to?`)) return;
|
|
try{
|
|
const r=await api('/api/read-all',{method:'POST'});
|
|
toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries();
|
|
}catch(e){ toast(e.message,true); }
|
|
}
|
|
}
|
|
|