Release 0.5.3: OPML orphan scan, feed error UI, small UI fixes
- Stop scanning an OPML/Patreon feed's derived rows once nobody subscribes to it; retire them (drop or orphan) the way sync_group already does when the list itself drops one. This is what let 922 defunct davewiner feeds keep scanning hourly after the OPML left config. - Repair feed XML with a bare `&`, and give a plain reason (moved web page with its new address when linked, or nothing yet for an empty body) instead of a raw parser error. - Show a failing feed's plain-English reason and next step (Unsubscribe / Use the new address) in the sidebar and on its own page, once it has been down a day. - Fix four small UI bugs: show-note links open in a new tab, video files play as video, an opened item no longer disappears from the Unread tab, and Subscribe/Unsubscribe get their own icons. - Fix Settings disappearing for non-admin accounts: it was hiding the whole modal instead of just the admin-only parts (Users, the editable schedule/quota, Save), which are the only parts the server actually refuses them. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DmQfE1eFPApnXWyPHBWqUA
This commit is contained in:
105
web/index.html
105
web/index.html
@@ -252,6 +252,8 @@ a.btn{text-decoration:none;color:inherit}
|
||||
.btn.danger:hover{border-color:var(--bad);color:var(--bad)}
|
||||
/* An icon in place of a word; the word is in its tooltip. */
|
||||
.btn.ico{padding:4px 9px;min-width:32px;font-size:14px;line-height:1.25;text-align:center}
|
||||
/* Inline in a sentence, next to a plain-word error explanation. */
|
||||
.btn.tiny{padding:2px 7px;font-size:11.5px;border-radius:6px;margin-left:2px}
|
||||
/* An icon (Font Awesome, embedded as SVG) in the button's own colour. */
|
||||
.i{display:inline-block;width:16px;height:16px;vertical-align:-3px;flex:none;fill:currentColor}
|
||||
/* A file's type as an icon, in place of the old DOWNLOADED / PENDING / audio chips: green once
|
||||
@@ -337,6 +339,15 @@ body.playing .eq i:nth-child(3){animation-delay:-.6s}
|
||||
padding:9px 16px;box-shadow:0 -6px 24px rgba(6,10,16,.4);
|
||||
}
|
||||
#player.on{display:grid}
|
||||
/* #audio is a <video> playing double duty as the audio element (see its tag). Only a video
|
||||
file needs to be seen, floated above the bar rather than laid into it, so an audio episode's
|
||||
layout is unchanged. */
|
||||
#audio{display:none}
|
||||
body.has-video #audio{
|
||||
display:block;position:fixed;z-index:45;right:16px;bottom:120px;
|
||||
width:360px;max-width:calc(100vw - 32px);aspect-ratio:16/9;background:#000;
|
||||
border-radius:10px;box-shadow:0 10px 30px rgba(0,0,0,.5);
|
||||
}
|
||||
#pnow{display:flex;gap:11px;align-items:center;min-width:0;width:250px}
|
||||
#pnow .art{width:44px;height:44px;font-size:13px}
|
||||
#pnow .txt{min-width:0}
|
||||
@@ -546,7 +557,7 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
||||
A phone hides the item group, which it has no table for. -->
|
||||
<div class="tgroup">
|
||||
<button id="addFeed" title="Add a feed" aria-label="Add a feed" data-icon="plus"></button>
|
||||
<button id="tbRemove" title="Unsubscribe from this feed" aria-label="Unsubscribe from this feed" data-icon="minus" disabled></button>
|
||||
<button id="tbRemove" title="Unsubscribe from this feed" aria-label="Unsubscribe from this feed" data-icon="circleMinus" disabled></button>
|
||||
<button id="scanAll" title="Check every feed for new items" aria-label="Check every feed for new items" data-icon="scan"></button>
|
||||
</div>
|
||||
<div class="tgroup item">
|
||||
@@ -610,7 +621,10 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
||||
|
||||
<div id="modal"><div class="card" id="modalCard"></div></div>
|
||||
<div id="toasts"></div>
|
||||
<audio id="audio" preload="metadata"></audio>
|
||||
<!-- One element for both: a <video> plays an audio-only file exactly like <audio> does (same
|
||||
HTMLMediaElement API), and it is the only tag that can also show a picture. Hidden unless
|
||||
the current file is video -- see body.has-video below. -->
|
||||
<video id="audio" preload="metadata" playsinline></video>
|
||||
|
||||
<script>
|
||||
'use strict';
|
||||
@@ -625,6 +639,7 @@ const fa=(box,body)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}
|
||||
const ICON={
|
||||
plus:fa('0 0 448 512','<path fill="currentColor" d="M256 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 160-160 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l160 0 0 160c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160 160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-160 0 0-160z"/>'), // solid/plus
|
||||
minus:fa('0 0 448 512','<path fill="currentColor" d="M0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32z"/>'), // solid/minus
|
||||
circleMinus:fa('0 0 512 512','<path fill="currentColor" d="M512 256A256 256 0 1 0 0 256a256 256 0 1 0 512 0zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/>'), // solid/circle-minus, for Unsubscribe
|
||||
play:fa('0 0 448 512','<path fill="currentColor" d="M91.2 36.9c-12.4-6.8-27.4-6.5-39.6 .7S32 57.9 32 72l0 368c0 14.1 7.5 27.2 19.6 34.4s27.2 7.5 39.6 .7l336-184c12.8-7 20.8-20.5 20.8-35.1s-8-28.1-20.8-35.1l-336-184z"/>'), // solid/play
|
||||
check:fa('0 0 448 512','<path fill="currentColor" d="M434.8 70.1c14.3 10.4 17.5 30.4 7.1 44.7l-256 352c-5.5 7.6-14 12.3-23.4 13.1s-18.5-2.7-25.1-9.3l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l101.5 101.5 234-321.7c10.4-14.3 30.4-17.5 44.7-7.1z"/>'), // solid/check
|
||||
checks:fa('0 0 384 512','<path fill="currentColor" d="M249.9 66.8c10.4-14.3 7.2-34.3-7.1-44.7s-34.3-7.2-44.7 7.1l-106 145.7-37.5-37.5c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c6.6 6.6 15.8 10 25.1 9.3s17.9-5.5 23.4-13.1l128-176zm128 136c10.4-14.3 7.2-34.3-7.1-44.7s-34.3-7.2-44.7 7.1l-170 233.7-69.5-69.5c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l96 96c6.6 6.6 15.8 10 25.1 9.3s17.9-5.5 23.4-13.1l192-264z"/>'), // solid/check-double
|
||||
@@ -828,6 +843,9 @@ function renderFeeds(){
|
||||
const [unread,eps,saved]=mine.length
|
||||
? [sum('unread'),sum('entries'),sum('downloaded')]
|
||||
: [f.unread,f.entries,f.downloaded];
|
||||
// A group's own row has no error of its own worth mentioning if the OPML itself
|
||||
// reads fine; it is failing when any feed inside it is.
|
||||
const failing=mine.length ? mine.find(c=>c.failing)?.failing : f.failing;
|
||||
const el=document.createElement('div');
|
||||
el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':'');
|
||||
el.tabIndex=0; el.dataset.id=f.id;
|
||||
@@ -839,6 +857,7 @@ function renderFeeds(){
|
||||
`${mine.length?plural(mine.length,'feed'):plural(eps,'item')} · ${saved} downloaded`+
|
||||
`</small></div>`+
|
||||
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+
|
||||
(failing?`<span class="tag" style="color:var(--bad)" title="${esc(failing.reason)}">Error</span>`:'')+
|
||||
`<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
|
||||
el.onclick=()=>{ selectFeed(f.id); nav(false); };
|
||||
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
|
||||
@@ -851,6 +870,21 @@ function selectFeed(id){
|
||||
renderFeeds(); renderFeed(); loadEntries();
|
||||
}
|
||||
|
||||
/// A failing feed's error, in plain words with something to do about it, once `failing` is
|
||||
/// set (it has been failing for a day and is a kind worth naming -- see `explain_failure` in
|
||||
/// src/feed.rs). Anything else still shows the raw error, as before.
|
||||
function failBannerHTML(f){
|
||||
if(f.failing) return `<div class="sub" style="color:var(--bad)">${esc(f.failing.reason)}
|
||||
<button type="button" class="btn tiny" data-ffail="unsub">Unsubscribe</button>${
|
||||
f.failing.new_url?` <button type="button" class="btn tiny" data-ffail="newurl">Use the new address</button>`:''}</div>`;
|
||||
if(f.last_error) return `<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`;
|
||||
return '';
|
||||
}
|
||||
function wireFailBanner(box,f){
|
||||
const un=$('[data-ffail="unsub"]',box); if(un) un.onclick=()=>removeFeed(f);
|
||||
const nu=$('[data-ffail="newurl"]',box); if(nu) nu.onclick=()=>settingsModal(f,f.failing.new_url);
|
||||
}
|
||||
|
||||
/* ---------------- feed page ---------------- */
|
||||
function renderFeed(){
|
||||
const box=$('#content');
|
||||
@@ -874,7 +908,7 @@ function renderFeed(){
|
||||
<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>
|
||||
${f.last_error?`<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</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>`:''}
|
||||
@@ -884,7 +918,7 @@ function renderFeed(){
|
||||
<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="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
||||
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.minus}</button>
|
||||
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
||||
</div>
|
||||
</div>` : `
|
||||
<div class="fhead slim">
|
||||
@@ -919,6 +953,7 @@ function renderFeed(){
|
||||
|
||||
$$('#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;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
|
||||
@@ -957,7 +992,7 @@ function renderGroup(f,kids){
|
||||
<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>
|
||||
${f.last_error?`<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</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>
|
||||
@@ -965,7 +1000,7 @@ function renderGroup(f,kids){
|
||||
<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="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
||||
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.minus}</button>
|
||||
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
@@ -974,6 +1009,7 @@ function renderGroup(f,kids){
|
||||
</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='';
|
||||
@@ -985,8 +1021,10 @@ function renderGroup(f,kids){
|
||||
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.last_error?` · <span style="color:var(--bad)">error</span>`:'')+`</small></div>`+
|
||||
(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);
|
||||
@@ -1027,7 +1065,15 @@ async function loadEntries(append){
|
||||
const r=await api(S.feed===':all' ? `/api/entries?${p}`
|
||||
: `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`);
|
||||
S.total=r.total;
|
||||
S.entries = append ? S.entries.concat(r.entries) : r.entries;
|
||||
let entries = append ? S.entries.concat(r.entries) : r.entries;
|
||||
// A background scan finishing refreshes the list from the server, which -- on the Unread
|
||||
// tab -- would drop the item you have open the moment reading it took it off the filter.
|
||||
// Keep it until you pick a different one; the next refresh after that no longer protects it.
|
||||
if(!append && S.sel && !entries.some(e=>e.guid===S.sel)){
|
||||
const open=S.entries.find(e=>e.guid===S.sel);
|
||||
if(open) entries=[open,...entries];
|
||||
}
|
||||
S.entries = entries;
|
||||
renderEntries();
|
||||
}
|
||||
function renderEntries(){
|
||||
@@ -1348,6 +1394,7 @@ function play(e,enc=e.enclosures.find(isPlayable)){
|
||||
const resuming = player.guid===e.guid && player.enc===enc.id;
|
||||
if(!resuming){
|
||||
player.guid=e.guid; player.feed=e.feed_id; player.entry=e; player.enc=enc.id;
|
||||
document.body.classList.toggle('has-video', kindOf(enc)==='video');
|
||||
audio.src=`/media/${enc.id}`;
|
||||
audio.currentTime=0;
|
||||
if(e.position>5) audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true});
|
||||
@@ -1402,7 +1449,7 @@ $('#pfwd').onclick=()=>audio.currentTime+=30;
|
||||
$('#seek').oninput=e=>{const d=audio.duration;if(d)audio.currentTime=d*e.target.value/1000};
|
||||
$('#rate').onchange=e=>{audio.playbackRate=+e.target.value;localStorage.setItem('ipx.rate',e.target.value)};
|
||||
$('#vol').oninput=e=>{audio.volume=e.target.value/100;localStorage.setItem('ipx.vol',e.target.value)};
|
||||
$('#pclose').onclick=()=>{savePos();audio.pause();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');renderEntries()};
|
||||
$('#pclose').onclick=()=>{savePos();audio.pause();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries()};
|
||||
(function restore(){
|
||||
const r=localStorage.getItem('ipx.rate'), v=localStorage.getItem('ipx.vol');
|
||||
if(r){$('#rate').value=r;audio.playbackRate=+r}
|
||||
@@ -1566,7 +1613,7 @@ async function listFeeds(url){
|
||||
`<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+
|
||||
// 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>`);
|
||||
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.subbed}</button>`);
|
||||
// Yours already: the row opens it instead.
|
||||
if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; box.appendChild(el); continue; }
|
||||
$('[data-a="sub"]',el).onclick=async()=>{
|
||||
@@ -1645,18 +1692,26 @@ function due(ts){
|
||||
return 'in '+Math.round(d/86400)+'d';
|
||||
}
|
||||
|
||||
/// Global settings. GET /api/settings is open to anyone signed in; only the PATCH, and the
|
||||
/// Users screen behind it, are the operator's alone (the server refuses both from anyone
|
||||
/// else). A non-admin gets the same modal minus those two parts, not no settings at all --
|
||||
/// Export/Import are theirs regardless, and seeing the schedule and quota explains why a
|
||||
/// feed is polled when it is.
|
||||
async function prefsModal(){
|
||||
const g = await api('/api/settings');
|
||||
const gs = splitEvery(g.every_mins);
|
||||
const admin = !!(S.me&&S.me.admin);
|
||||
openModal(`<h3>Settings</h3>
|
||||
<div class="field"><label>Check feeds every</label>
|
||||
<div class="inline">
|
||||
${admin?`<div class="inline">
|
||||
<input type="number" id="gnum" min="1" max="999" value="${gs.n}">
|
||||
<select id="gunit">${unitOptions(gs.u)}</select>
|
||||
</div>
|
||||
<span class="hint">Applies to every feed that does not set its own. A feed's suggested
|
||||
interval (its <b>ttl</b>) is still honoured when it asks to be polled less often.</span></div>
|
||||
<div class="field"><label>Max new downloads per scan, per feed</label>
|
||||
interval (its <b>ttl</b>) is still honoured when it asks to be polled less often.</span>`
|
||||
:`<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its
|
||||
own. Only an admin changes this.</span>`}</div>
|
||||
${admin?`<div class="field"><label>Max new downloads per scan, per feed</label>
|
||||
<input type="number" id="gmax" min="0" max="999" value="${g.max_new_per_check}">
|
||||
<span class="hint">Applies to any feed that does not set its own — including every feed
|
||||
inside an OPML subscription. <b>0 means unlimited</b>, which will pull a whole back
|
||||
@@ -1671,7 +1726,7 @@ async function prefsModal(){
|
||||
<span class="hint">Over this, the oldest played items are deleted first. Kept
|
||||
items are never touched.</span></div>
|
||||
<div class="field"><label>Delete items older than (days, 0 = keep)</label>
|
||||
<input type="number" id="gage" min="0" value="${g.max_age_days}"></div>
|
||||
<input type="number" id="gage" min="0" value="${g.max_age_days}"></div>`:''}
|
||||
<div class="field"><label>Download folder</label>
|
||||
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
|
||||
<div class="field"><label>Subscriptions</label>
|
||||
@@ -1682,12 +1737,13 @@ async function prefsModal(){
|
||||
</div>
|
||||
<span class="hint">Export saves your subscriptions as OPML for another podcast app. Import
|
||||
subscribes you to every feed in one.</span></div>
|
||||
<div class="field"><label>Users</label>
|
||||
${admin?`<div class="field"><label>Users</label>
|
||||
<div class="inline"><button class="btn ico" id="gusers" title="Manage users…" aria-label="Manage users">${ICON.users}</button></div>
|
||||
<span class="hint">Add and remove the people who can sign in, and choose who is an admin.</span></div>
|
||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||
<button class="btn ico primary" id="gsave" title="Save" aria-label="Save">${ICON.check}</button></div>`);
|
||||
<span class="hint">Add and remove the people who can sign in, and choose who is an admin.</span></div>`:''}
|
||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="${admin?'Cancel':'Close'}" aria-label="${admin?'Cancel':'Close'}">${ICON.close}</button>
|
||||
${admin?`<button class="btn ico primary" id="gsave" title="Save" aria-label="Save">${ICON.check}</button>`:''}</div>`);
|
||||
$('#gopml').onclick=opmlModal;
|
||||
if(!admin) return;
|
||||
$('#gusers').onclick=usersModal;
|
||||
$('#gsave').onclick=async()=>{
|
||||
try{
|
||||
@@ -1751,7 +1807,7 @@ async function usersModal(){
|
||||
};
|
||||
}
|
||||
|
||||
function settingsModal(f){
|
||||
function settingsModal(f,newUrl){
|
||||
const isGroup = S.feeds.some(c=>c.group===f.id);
|
||||
openModal(`<h3>${esc(f.title||f.id)}</h3>
|
||||
${isGroup?`<p class="hint" style="margin:-6px 0 12px">This is ${isPatreon(f)?'a Patreon creator':'an OPML subscription'}. These
|
||||
@@ -1772,7 +1828,7 @@ function settingsModal(f){
|
||||
<label class="check"><input type="checkbox" id="sexp" ${f.allow_explicit?'checked':''}> Allow items marked explicit</label>
|
||||
<div class="field"><label>Feed URL</label>
|
||||
<div class="inline">
|
||||
<input type="text" id="surl" value="${esc(f.url)}" spellcheck="false" ${S.me&&S.me.admin?'':'readonly'}>
|
||||
<input type="text" id="surl" value="${esc(newUrl||f.url)}" spellcheck="false" ${S.me&&S.me.admin?'':'readonly'}>
|
||||
<button type="button" class="btn ico" id="scopy" title="Copy the URL" aria-label="Copy the URL">${ICON.copy}</button>
|
||||
</div>
|
||||
<span class="hint">${S.me&&S.me.admin
|
||||
@@ -1829,7 +1885,7 @@ function removeFeed(f){
|
||||
reading it keeps it, along with their own read state.
|
||||
Downloaded files and history are kept, so re-adding it will not pull the back catalogue again.</p>
|
||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||
<button class="btn ico danger" id="rgo" title="Unsubscribe" aria-label="Unsubscribe">${ICON.minus}</button></div>`);
|
||||
<button class="btn ico danger" id="rgo" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button></div>`);
|
||||
$('#rgo').onclick=async()=>{
|
||||
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'DELETE'});
|
||||
closeModal(); toast('Unsubscribed'); S.feed=null;
|
||||
@@ -1875,9 +1931,10 @@ $('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); locat
|
||||
api('/api/me').then(u=>{
|
||||
S.me=u;
|
||||
$('#who').textContent=u.name+(u.admin?' · admin':'');
|
||||
// Scanning, quotas, accounts and the log are the operator's business. The server refuses
|
||||
// them too; hiding the buttons just stops offering what would fail.
|
||||
if(!u.admin) $('#admintools').hidden=true;
|
||||
// The log names every account and every failed sign-in; that alone is the operator's
|
||||
// business, and the server refuses it from anyone else. Settings stays -- it opens the
|
||||
// same modal for everyone, just without the admin-only fields (see prefsModal).
|
||||
if(!u.admin) $('#logs').hidden=true;
|
||||
}).catch(()=>{});
|
||||
$('#logs').onclick=logsModal;
|
||||
$('#feedFilter').oninput=renderFeeds;
|
||||
|
||||
Reference in New Issue
Block a user