Three-pane layout: feeds, items, item text
Feeds beside, the feed's items above, and the selected item's text with its enclosures below -- the shape iPodderX used. Selecting a row fills the pane below instead of expanding inline; enclosures render there as a player when the file is present and a labelled download when it is not. The divider drags and its position is remembered. The archived site kept no usable screenshot of the original window, only marketing panels, so this follows the description rather than reference art. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
This commit is contained in:
167
web/index.html
167
web/index.html
@@ -128,8 +128,31 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
||||
.badge.zero{background:var(--raise);color:var(--faint)}
|
||||
|
||||
/* ---------- main ---------- */
|
||||
#main{overflow-y:auto;min-height:0;scroll-behavior:smooth}
|
||||
.wrap{max-width:1000px;margin:0 auto;padding:18px 22px 40px}
|
||||
#main{overflow:hidden;min-height:0;display:flex;flex-direction:column}
|
||||
.wrap{flex:1;min-height:0;display:flex;flex-direction:column}
|
||||
.wrap.plain{overflow-y:auto;padding:18px 22px 40px;display:block}
|
||||
#content{display:flex;flex-direction:column;min-height:0;flex:1}
|
||||
#content>.fhead,#content>.toolbar{padding-left:20px;padding-right:20px;flex:none}
|
||||
#content>.fhead{padding-top:16px}
|
||||
|
||||
/* Three panes, as the original had: feeds beside, items above, the item below. */
|
||||
#split{display:grid;grid-template-rows:minmax(90px,var(--listh,40%)) 7px 1fr;flex:1;min-height:0}
|
||||
#list{overflow-y:auto;padding:0 14px 10px}
|
||||
#grab{cursor:row-resize;background:var(--line)}
|
||||
#grab:hover{background:var(--accent)}
|
||||
#detail{overflow-y:auto;padding:16px 20px 28px;background:var(--panel);border-top:1px solid var(--line)}
|
||||
.dt{font-size:18px;font-weight:650;margin:0 0 5px;line-height:1.3}
|
||||
.dmeta{color:var(--faint);font-size:12.5px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:14px}
|
||||
.dmeta .btn{padding:3px 9px;font-size:12px}
|
||||
.dbody{font-size:14.5px;line-height:1.65;overflow-wrap:anywhere;color:var(--fg)}
|
||||
.dbody img{max-width:100%;height:auto;border-radius:6px}
|
||||
.dbody a{color:var(--accent)}
|
||||
.dbody pre{overflow-x:auto;background:var(--panel2);padding:10px;border-radius:8px}
|
||||
.encbox{
|
||||
display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:14px;
|
||||
padding:10px 12px;border:1px solid var(--line);border-radius:9px;background:var(--panel2);
|
||||
}
|
||||
.encbox audio{flex:1;min-width:200px;margin:0}
|
||||
.fhead{display:flex;gap:18px;margin-bottom:18px}
|
||||
.fhead .art{width:118px;height:118px;font-size:34px;box-shadow:var(--shadow)}
|
||||
.fhead .meta{min-width:0;flex:1;display:flex;flex-direction:column}
|
||||
@@ -157,9 +180,11 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
||||
|
||||
/* ---------- episodes ---------- */
|
||||
.ep{
|
||||
display:flex;gap:12px;padding:11px;border-radius:var(--r);
|
||||
border:1px solid transparent;margin-bottom:3px;position:relative;
|
||||
display:flex;gap:11px;padding:8px 10px;border-radius:var(--r);align-items:center;
|
||||
border:1px solid transparent;margin-bottom:2px;position:relative;cursor:pointer;
|
||||
}
|
||||
.ep.sel{background:var(--raise);border-color:var(--line)}
|
||||
.ep .art{width:34px;height:34px;font-size:12px}
|
||||
.ep:hover{background:var(--panel)}
|
||||
.ep.playing{background:var(--panel);border-color:var(--accent)}
|
||||
.ep .art{width:52px;height:52px;font-size:16px;cursor:pointer;position:relative}
|
||||
@@ -183,12 +208,6 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
||||
.chip.new{background:var(--accent);color:var(--ink)}
|
||||
.ep .rowacts{display:flex;gap:2px;align-items:flex-start;opacity:0;transition:opacity .12s}
|
||||
.ep:hover .rowacts,.ep.open .rowacts{opacity:1}
|
||||
.notes{
|
||||
margin-top:10px;padding:11px 13px;background:var(--panel2);border-radius:9px;
|
||||
color:var(--dim);font-size:13.5px;overflow-wrap:anywhere;
|
||||
}
|
||||
.notes img{max-width:100%;height:auto;border-radius:6px}
|
||||
.notes p:first-child{margin-top:0}.notes p:last-child{margin-bottom:0}
|
||||
.dlbar{height:3px;background:transparent;border-radius:2px;overflow:hidden;margin-top:7px}
|
||||
.dlbar.live{background:var(--raise)}
|
||||
.dlbar i{display:block;height:100%;width:0;background:var(--accent);transition:width .25s}
|
||||
@@ -409,7 +428,7 @@ function artHTML(url,name,cls){
|
||||
/* ---------------- state ---------------- */
|
||||
const S = {
|
||||
feeds:[], feed:null, entries:[], total:0, offset:0, limit:50,
|
||||
filter:'unread', q:'', open:new Set(), busy:new Set(),
|
||||
filter:'unread', q:'', sel:null, busy:new Set(),
|
||||
};
|
||||
const LIMIT = 50;
|
||||
|
||||
@@ -457,7 +476,7 @@ function renderFeeds(){
|
||||
}
|
||||
}
|
||||
function selectFeed(id){
|
||||
S.feed=id; S.offset=0; S.open.clear(); S.q='';
|
||||
S.feed=id; S.offset=0; S.sel=null; S.q='';
|
||||
renderFeeds(); renderFeed(); loadEntries();
|
||||
}
|
||||
|
||||
@@ -465,8 +484,9 @@ function selectFeed(id){
|
||||
function renderFeed(){
|
||||
const f=S.feeds.find(x=>x.id===S.feed);
|
||||
if(!f){ $('#content').innerHTML='<p class="empty">Add a feed to get started.</p>'; return; }
|
||||
$('#main .wrap').classList.remove('plain');
|
||||
const kids=S.feeds.filter(c=>c.group===f.id);
|
||||
if(kids.length){ renderGroup(f,kids); return; }
|
||||
if(kids.length){ $('#main .wrap').classList.add('plain'); renderGroup(f,kids); return; }
|
||||
$('#content').innerHTML = `
|
||||
<div class="fhead">
|
||||
${artHTML(f.image,f.title||f.id)}
|
||||
@@ -495,7 +515,15 @@ function renderFeed(){
|
||||
<input type="search" class="grow" id="epSearch" placeholder="Search episodes…" value="${esc(S.q)}">
|
||||
<span style="color:var(--faint);font-size:12.5px" id="count"></span>
|
||||
</div>
|
||||
<div id="eps"></div>`;
|
||||
`;
|
||||
|
||||
const pane=document.createElement('div');
|
||||
pane.id='split';
|
||||
pane.innerHTML='<div id="list"><div id="eps"></div></div><div id="grab"></div><div id="detail"></div>';
|
||||
$('#content').appendChild(pane);
|
||||
pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '40%');
|
||||
dragSplit();
|
||||
showDetail(null);
|
||||
|
||||
$$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a,f));
|
||||
$$('#content .tabs button').forEach(b=>b.onclick=()=>{S.filter=b.dataset.f;S.offset=0;renderFeed();loadEntries()});
|
||||
@@ -592,7 +620,7 @@ function epEl(e){
|
||||
const enc=e.enclosures[0];
|
||||
const has=!!(enc&&enc.path);
|
||||
const el=document.createElement('div');
|
||||
el.className='ep'+(e.read?' read':'')+(S.open.has(e.guid)?' open':'')+
|
||||
el.className='ep'+(e.read?' read':'')+(S.sel===e.guid?' sel':'')+
|
||||
(player.guid===e.guid?' playing':'');
|
||||
el.dataset.guid=e.guid;
|
||||
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
|
||||
@@ -621,15 +649,15 @@ function epEl(e){
|
||||
<button class="iconbtn" data-a="flag" title="${e.flagged?'Stop keeping':'Keep (never auto-delete)'}">${e.flagged?'★':'☆'}</button>
|
||||
<button class="iconbtn" data-a="read" title="Mark ${e.read?'unread':'read'}">${e.read?'○':'●'}</button>
|
||||
${has?`<button class="iconbtn" data-a="del" title="Delete file">🗑</button>`:''}
|
||||
<button class="iconbtn" data-a="notes" title="Show notes">≡</button>
|
||||
</div>`;
|
||||
|
||||
const art=$('.art',el);
|
||||
if(art&&has) art.insertAdjacentHTML('beforeend','<span class="ovl">▶</span>');
|
||||
if(art&&has) art.onclick=()=>play(e);
|
||||
$('.t',el).onclick=()=>toggleNotes(e,el);
|
||||
if(art&&has){
|
||||
art.insertAdjacentHTML('beforeend','<span class="ovl">▶</span>');
|
||||
art.onclick=ev=>{ ev.stopPropagation(); play(e); };
|
||||
}
|
||||
el.onclick=()=>selectEntry(e);
|
||||
$$('.rowacts .iconbtn',el).forEach(b=>b.onclick=ev=>{ev.stopPropagation();epAction(b.dataset.a,e,el)});
|
||||
if(S.open.has(e.guid)) showNotes(e,el);
|
||||
return el;
|
||||
}
|
||||
/// What an enclosure is, for a row that is not an episode: "image", "pdf", "document".
|
||||
@@ -646,28 +674,93 @@ function kindOf(enc){
|
||||
|
||||
function feedArt(){ const f=S.feeds.find(x=>x.id===S.feed); return f&&f.image; }
|
||||
|
||||
function toggleNotes(e,el){
|
||||
if(S.open.has(e.guid)){ S.open.delete(e.guid); const n=$('.notes',el); if(n)n.remove(); el.classList.remove('open'); }
|
||||
else { S.open.add(e.guid); showNotes(e,el); el.classList.add('open'); }
|
||||
}
|
||||
function showNotes(e,el){
|
||||
if($('.notes',el)) return;
|
||||
const d=document.createElement('div');
|
||||
d.className='notes';
|
||||
// Sanitized server-side with ammonia before it ever reaches the browser.
|
||||
d.innerHTML=(e.description&&e.description.trim())||'<em>No show notes.</em>';
|
||||
if(e.link) d.insertAdjacentHTML('beforeend',`<p><a href="${esc(e.link)}" target="_blank" rel="noopener noreferrer">Open original ↗</a></p>`);
|
||||
$('.body',el).appendChild(d);
|
||||
/// Selecting an item shows it in the pane below, rather than expanding the row.
|
||||
function selectEntry(e){
|
||||
S.sel=e.guid;
|
||||
$$('#eps .ep').forEach(x=>x.classList.toggle('sel', x.dataset.guid===e.guid));
|
||||
showDetail(e);
|
||||
const d=$('#detail'); if(d) d.scrollTop=0;
|
||||
}
|
||||
|
||||
async function epAction(a,e,el){
|
||||
const enc=e.enclosures[0];
|
||||
/// Drag the divider between the item list and the item text.
|
||||
function dragSplit(){
|
||||
const grab=$('#grab'), pane=$('#split');
|
||||
if(!grab||!pane) return;
|
||||
const move=ev=>{
|
||||
const box=pane.getBoundingClientRect();
|
||||
const pct=Math.min(80,Math.max(12,((ev.clientY-box.top)/box.height)*100));
|
||||
pane.style.setProperty('--listh',pct.toFixed(1)+'%');
|
||||
};
|
||||
const stop=()=>{
|
||||
document.removeEventListener('mousemove',move);
|
||||
document.removeEventListener('mouseup',stop);
|
||||
document.body.style.userSelect='';
|
||||
try{ localStorage.setItem('ipx.listh', pane.style.getPropertyValue('--listh')); }catch{}
|
||||
};
|
||||
grab.onmousedown=ev=>{
|
||||
ev.preventDefault();
|
||||
document.body.style.userSelect='none';
|
||||
document.addEventListener('mousemove',move);
|
||||
document.addEventListener('mouseup',stop);
|
||||
};
|
||||
}
|
||||
|
||||
/// The item's text and its enclosures, in the pane below the list.
|
||||
function showDetail(e){
|
||||
const box=$('#detail'); if(!box) return;
|
||||
if(!e){ box.innerHTML='<p class="empty">Pick an episode to read it.</p>'; return; }
|
||||
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('');
|
||||
box.innerHTML=`
|
||||
<h3 class="dt">${esc(e.title||'(untitled)')}</h3>
|
||||
<div class="dmeta">
|
||||
${f?`<span>${esc(f.title||f.id)}</span><span class="dot"></span>`:''}
|
||||
${num?`<span>${num}</span><span class="dot"></span>`:''}
|
||||
<span>${dateOf(e.published)}</span>
|
||||
${e.duration?`<span class="dot"></span><span>${clock(e.duration)}</span>`:''}
|
||||
<button class="btn" data-a="read">Mark ${e.read?'unread':'read'}</button>
|
||||
<button class="btn" data-a="flag">${e.flagged?'\u2605 Kept':'\u2606 Keep'}</button>
|
||||
${e.link?`<a class="btn" href="${esc(e.link)}" target="_blank" rel="noopener noreferrer">Open original \u2197</a>`:''}
|
||||
</div>
|
||||
<div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>
|
||||
${e.enclosures.map(encBox).join('')}`;
|
||||
// description was sanitized server-side with ammonia before it ever reached here
|
||||
$$('button[data-a]',box).forEach(b=>
|
||||
b.onclick=()=>epAction(b.dataset.a,e,null,b.dataset.enc?Number(b.dataset.enc):null));
|
||||
for(const x of e.enclosures){
|
||||
const a=$(`#audio-${x.id}`,box);
|
||||
if(a) a.onplay=()=>play(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// One enclosure: a player when the file is here, otherwise what it is and a way to get it.
|
||||
function encBox(x){
|
||||
const size=x.length?mb(x.length):'';
|
||||
if(x.path){
|
||||
return `<div class="encbox">
|
||||
<audio id="audio-${x.id}" controls preload="none" src="/media/${x.id}"></audio>
|
||||
<span class="meta">${size}</span>
|
||||
<a class="btn" href="/media/${x.id}" download>Save</a>
|
||||
<button class="btn danger" data-a="del" data-enc="${x.id}">Delete file</button>
|
||||
</div>`;
|
||||
}
|
||||
return `<div class="encbox">
|
||||
<span class="chip ${esc(x.state)}">${x.state==='skipped'?kindOf(x):esc(x.state)}</span>
|
||||
<span class="meta" style="flex:1">${esc(kindOf(x))}${size?' \u00b7 '+size:''}</span>
|
||||
${x.last_error?`<span class="err">${esc(x.last_error)}</span>`:''}
|
||||
<button class="btn" data-a="get" data-enc="${x.id}">Download</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function epAction(a,e,el,encId){
|
||||
// From a row we have the element to swap; from the detail pane we do not, so redraw.
|
||||
const redraw=()=>{ if(el) redraw(); else renderEntries(); showDetail(e); };
|
||||
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{
|
||||
if(a==='play') play(e);
|
||||
if(a==='notes') toggleNotes(e,el);
|
||||
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); el.replaceWith(epEl(e)); }
|
||||
if(a==='read'){ e.read=!e.read; await api(path+'/flags',{method:'POST',body:JSON.stringify({read:e.read})}); el.replaceWith(epEl(e)); loadFeeds(true); }
|
||||
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); }
|
||||
if(a==='read'){ e.read=!e.read; await api(path+'/flags',{method:'POST',body:JSON.stringify({read:e.read})}); redraw(); loadFeeds(true); }
|
||||
if(a==='get'){
|
||||
if(!enc) return;
|
||||
S.busy.add(enc.id);
|
||||
|
||||
Reference in New Issue
Block a user