hide_from_signals on a subscription, on an OPML or creator covering every show in it, as hide_from_all does for All Subscriptions: the judge does not put its items to Jev, and each Signal's list and counts leave them out. shown_in() is the one SQL rule for both. The settings dialog had grown one captioned setting at a time into a single column, one part boxed and the rest not. It is in named parts now, the name in a column beside the controls and above them on a phone, ruled apart; All Subscriptions and Signals carry the sidebar's icons. The address, the same for everyone, moves to the foot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,9 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- A feed's settings are in parts, each named beside its controls: Downloads, Playback, Where it shows, Directory and Address, with All Subscriptions and Signals drawn as the sidebar draws them (#290).
|
||||
|
||||
### Added
|
||||
|
||||
- A feed's, an OPML subscription's or a Patreon creator's settings have In All Subscriptions: off keeps its items out of All Subscriptions and its unread count, still there on its own page, in Signals and in Currently Listening (#288).
|
||||
- A feed's settings have a Signals switch beside All Subscriptions, to keep its items out of every Signal (#289).
|
||||
- An item sent as both HTML and Markdown, as Scripting News's are, has a choice beside its buttons to show it as the publisher's HTML or as the Markdown, kept from one item to the next (#285).
|
||||
- A show inside an OPML subscription has a Keep button, a plus, on its page, so it stays on your list as a show of its own if the OPML drops it or you unsubscribe from the OPML (#280).
|
||||
- Admin, Server: a setting to give everyone Signals on the server's TypeSafe key (#271).
|
||||
|
||||
38
web/app.css
38
web/app.css
@@ -1095,14 +1095,30 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
||||
/* ---------- a feed's settings (#167) ---------- */
|
||||
/* A frame of its own: the body scrolls and Save stays at the foot, where on a phone it had been
|
||||
three screens down. */
|
||||
.card.fset{padding:0;display:flex;flex-direction:column;overflow:hidden;width:min(520px,100%)}
|
||||
.card.fset{padding:0;display:flex;flex-direction:column;overflow:hidden;width:min(580px,100%);container-type:inline-size}
|
||||
.fsetbody{overflow:auto;min-height:0;padding:22px 24px 24px}
|
||||
.card.fset .fsetfoot{flex:none;margin:0;padding:12px 24px;border-top:1px solid var(--line)}
|
||||
.fsethead{display:flex;gap:14px;align-items:center;margin-bottom:18px}
|
||||
.fsethead{display:flex;gap:14px;align-items:center;margin-bottom:20px}
|
||||
.fsethead .art{width:56px;height:56px;border-radius:12px;font-size:18px}
|
||||
.fsethead h3{margin:0;font-size:19px;line-height:1.25;letter-spacing:-.01em;overflow-wrap:anywhere}
|
||||
.fsethead p{margin:3px 0 0;font-size:12.5px;line-height:1.45;color:var(--dim)}
|
||||
.faddr{margin-bottom:18px}
|
||||
/* A part of the settings, named in a column of its own beside its controls (#290); on a phone the
|
||||
name sits above them. Ruled apart rather than boxed: the boxes, one round downloads and none
|
||||
round the rest, read as a difference between the settings that was not there. */
|
||||
.fsec{display:grid;grid-template-columns:104px minmax(0,1fr);gap:6px 20px;padding:14px 0 16px;border-top:1px solid var(--line)}
|
||||
.fsec h4{margin:0;padding-top:10px;font-size:13px;font-weight:600;line-height:1.3;color:var(--dim)}
|
||||
.fsecin{display:grid;gap:6px;min-width:0}
|
||||
@container (max-width:480px){
|
||||
.fsec{grid-template-columns:minmax(0,1fr);gap:4px}
|
||||
.fsec h4{padding-top:0}
|
||||
}
|
||||
/* All Subscriptions and Signals with the icons the sidebar gives them, so the switch reads as that place. */
|
||||
.switch.fplace>span:first-child{display:flex;align-items:center;gap:10px}
|
||||
.fico{display:inline-flex;justify-content:center;width:18px;flex:none;color:var(--accent)}
|
||||
.fico svg{width:15px;height:15px}
|
||||
.fsig img{display:block;width:18px;height:18px;border-radius:5px}
|
||||
/* Set on the OPML it came from, not here: shown, and plainly out of reach. */
|
||||
.fplace:has(input:disabled){opacity:.5;cursor:default}
|
||||
.moved{margin:2px 0 0;font-size:13px;color:var(--accent)}
|
||||
/* On or off, drawn as iOS draws it, as the app is on a phone (#53). The box is the real control,
|
||||
unseen over the track, so a click, a tap, the keyboard and a screen reader all reach it. */
|
||||
@@ -1117,16 +1133,14 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
||||
.sw input:checked+i{background:var(--accent);border-color:var(--accent)}
|
||||
.sw input:checked+i::after{transform:translateX(16px)}
|
||||
.sw input:focus-visible+i{outline:2px solid var(--accent);outline-offset:2px}
|
||||
/* The switch and what only matters while it downloads on its own, in one box; the rest folds away
|
||||
when it is off. In --line, not --edge, so the box shows on a phone, which draws no outlines. The
|
||||
rows in a grid of one row, 1fr open and 0fr shut, so the fold has a height to move. */
|
||||
.fdl{border:1px solid var(--line);border-radius:12px;padding:4px 14px}
|
||||
/* What only matters while it downloads on its own folds away when it is off: the rows in a grid of
|
||||
one row, 1fr open and 0fr shut, so the fold has a height to move. */
|
||||
/* The same box round a part of a dialog that stands apart: API tokens in Settings. */
|
||||
.field.boxed{border:1px solid var(--line);border-radius:12px;padding:12px 14px}
|
||||
.fdeps{display:grid;grid-template-rows:1fr;transition:grid-template-rows .22s ease}
|
||||
.fdeps.off{grid-template-rows:0fr}
|
||||
.fdeps>div{min-height:0;overflow:hidden}
|
||||
.fdepsin{padding:6px 2px 10px}
|
||||
.fdepsin{padding:6px 0 2px}
|
||||
/* The app's own segmented control, with radio buttons in it, for the arrow keys. */
|
||||
.seg{margin-bottom:12px}
|
||||
.seg label{flex:1;position:relative;text-align:center;white-space:nowrap;padding:5px 10px;border-radius:7px;font-size:13px;color:var(--dim);cursor:pointer}
|
||||
@@ -1146,6 +1160,8 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
||||
.wcap{margin:0 0 16px;font-size:13px;line-height:1.55;color:var(--dim)}
|
||||
.wcap input[type=number]{display:inline-block;width:4.2em;margin:0 2px;padding:1px 4px;font-size:13px;text-align:center;font-variant-numeric:tabular-nums;color:var(--fg)}
|
||||
.ffield{display:grid;gap:6px;margin-bottom:14px}
|
||||
.fsecin>.ffield:last-child{margin-bottom:0}
|
||||
.fsecin>.cap+.ffield{margin-top:10px}
|
||||
/* DeadAir's pause: four stops on one scale, a dot at each so it reads as four places to stop. */
|
||||
.dpause{display:grid;gap:6px;margin-top:6px}
|
||||
.dphead{display:flex;justify-content:space-between;align-items:baseline}
|
||||
@@ -1165,9 +1181,6 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
||||
.dpends{display:flex;justify-content:space-between;font-size:12.5px;color:var(--dim)}
|
||||
.dpause:has(input:disabled){opacity:.45}
|
||||
.dpslide input[type=range]:disabled{cursor:default}
|
||||
.fdl+.ffield{margin-top:18px}
|
||||
/* DeadAir shares the box but not the fold: it applies whether or not this show downloads on its own. */
|
||||
.fdead{border-top:1px solid var(--line);padding-top:10px;margin-top:4px}
|
||||
.flabel{font-size:14px;font-weight:500}
|
||||
.cap{margin:0;font-size:12.5px;line-height:1.45;color:var(--dim)}
|
||||
/* Words and phrases as chips: in one comma-separated box, one ran into the next. */
|
||||
@@ -1398,6 +1411,7 @@ body:has(#player.on) #status{padding-bottom:3px}
|
||||
.fsetbody{padding:20px 16px 24px}
|
||||
.card.fset .fsetfoot{padding:12px 16px calc(12px + var(--safe-b))}
|
||||
.fsethead .art{width:64px;height:64px;border-radius:14px}
|
||||
.fsec h4{font-size:14px}
|
||||
.fsethead p,.wcap,.cap{font-size:14px}
|
||||
.switch,.flabel{font-size:17px}
|
||||
.switch{min-height:44px}
|
||||
@@ -1413,7 +1427,7 @@ body:has(#player.on) #status{padding-bottom:3px}
|
||||
.chips{background:var(--panel2);border-color:var(--edge);border-radius:10px}
|
||||
.chip{background:var(--panel);font-size:15px;padding:5px 4px 5px 12px}
|
||||
.chipx{width:26px;height:26px}
|
||||
.fdl,.field.boxed{border-radius:14px}
|
||||
.field.boxed{border-radius:14px}
|
||||
.toast{border-radius:14px}
|
||||
}
|
||||
@keyframes sheet{from{transform:translateY(100%)}}
|
||||
|
||||
@@ -578,8 +578,6 @@ function settingsModal(f: Feed, newUrl?: string){
|
||||
const kids=S.feeds.filter(c=>c.group===f.id).length;
|
||||
const isGroup=kids>0;
|
||||
const parent=f.managed?S.feeds.find(p=>p.id===f.group):null;
|
||||
// Hidden from All Subscriptions with the OPML it is in, whatever its own setting (#288).
|
||||
const byList=f.group?S.feeds.find(g=>g.id===f.group&&g.hide_from_all):undefined;
|
||||
const admin=!!(S.me&&S.me.admin);
|
||||
const name=f.title||f.id;
|
||||
// Blank follows the server's default; 0 is every item (#166).
|
||||
@@ -591,13 +589,20 @@ function settingsModal(f: Feed, newUrl?: string){
|
||||
? `It comes from ${esc(parent.title||parent.id)}, and follows your settings there until you save these.`
|
||||
: 'Your settings for this feed. Everyone else who reads it keeps their own.';
|
||||
const sw=(input: string, text: string, cls='')=>`<label class="switch${cls}"><span>${text}</span><span class="sw">${input}<i></i></span></label>`;
|
||||
// Each part named in a column of its own, the controls beside it (#290): the dialog had grown one
|
||||
// captioned setting at a time into a single column three screens long on a phone.
|
||||
const sect=(title: string, body: string)=>`<section class="fsec"><h4>${title}</h4><div class="fsecin">${body}</div></section>`;
|
||||
const these=isGroup?'these shows':'this show';
|
||||
// The places in the sidebar this show's items go to, drawn as the sidebar draws them (#288, #289).
|
||||
const place=(id: string, key: 'hide_from_all'|'hide_from_signals', icon: string, title: string)=>{
|
||||
const byList=f.group?S.feeds.find(g=>g.id===f.group&&g[key]):undefined;
|
||||
return sw(`<input type="checkbox" role="switch" id="${id}"${shownIn(f,key)?' checked':''}${byList?' disabled':''}>`,
|
||||
`<span class="fico" aria-hidden="true">${icon}</span>${title}`,' fplace');
|
||||
};
|
||||
const listHides=f.group?S.feeds.find(g=>g.id===f.group&&(g.hide_from_all||g.hide_from_signals)):undefined;
|
||||
openModal(`<div class="fsetbody">
|
||||
<header class="fsethead">${artHTML(f.image,name)}<div><h3>${esc(name)}</h3><p>${whose}</p></div></header>
|
||||
<div class="field faddr"><label for="surl">Address</label>
|
||||
<div class="inline"><input type="text" id="surl" value="${esc(newUrl||f.url)}" spellcheck="false"${admin?'':' readonly'}>
|
||||
<button type="button" class="btn ico" id="scopy" title="Copy the address" aria-label="Copy the address">${ICON.copy}</button></div>
|
||||
${newUrl?'<p class="moved">The feed says it has moved here. Save to follow it.</p>':''}</div>
|
||||
<div class="fdl">${sw(`<input type="checkbox" role="switch" id="sauto"${f.auto_download?' checked':''}>`,'Download automatically',' lead')}
|
||||
${sect('Downloads',`${sw(`<input type="checkbox" role="switch" id="sauto"${f.auto_download?' checked':''}>`,'Download automatically',' lead')}
|
||||
<div class="fdeps${f.auto_download?'':' off'}" id="sdeps"><div><div class="fdepsin">
|
||||
<div class="tabs seg" role="radiogroup" aria-label="How many to download">${
|
||||
[['default',`Default (${globalMax||'all'})`],['newest','Newest'],['all','All']].map(([v,t])=>
|
||||
@@ -608,31 +613,32 @@ function settingsModal(f: Feed, newUrl?: string){
|
||||
<div class="chips" id="skw">${chipsHTML(f.keywords,'skwin')}</div>
|
||||
<p class="cap">Any one of these will do, and a phrase needs all its words. Empty takes everything.</p></div>
|
||||
${sw(`<input type="checkbox" role="switch" id="sexp"${f.allow_explicit?' checked':''}>`,'Include explicit items')}
|
||||
</div></div></div>
|
||||
<div class="ffield fdead">${sw(`<input type="checkbox" role="switch" id="sdead"${f.deadair?' checked':''}>`,'DeadAir',' lead')}
|
||||
<p class="cap">Cuts the dead air: long silences in ${isGroup?'these shows':'this show'} shrink to a beat, so
|
||||
episodes finish sooner and every voice keeps its own pace. Leave it off for stories whose pauses
|
||||
are part of the telling.</p>
|
||||
</div></div></div>`)}
|
||||
${sect('Playback',`${sw(`<input type="checkbox" role="switch" id="sdead"${f.deadair?' checked':''}>`,'DeadAir',' lead')}
|
||||
<p class="cap">Shrinks long silences in ${these} to a beat, so episodes finish sooner. Leave it off
|
||||
for stories whose pauses are part of the telling.</p>
|
||||
<div class="dpause">
|
||||
<div class="dphead"><label class="flabel" for="sdpause">Breathing room</label><output id="sdpauseout" for="sdpause"></output></div>
|
||||
<div class="dpslide"><input type="range" id="sdpause" min="200" max="500" step="100" value="${f.deadair_pause||300}"${f.deadair?'':' disabled'}></div>
|
||||
<div class="dpends" aria-hidden="true"><span>Brisk</span><span>Relaxed</span></div>
|
||||
<p class="cap">The silence kept where a long one is cut. More is easier on the ear; less saves more time.</p></div></div></div>
|
||||
${isGroup?'':`<div class="ffield"><label class="flabel" for="sblockin">Hide items that mention</label>
|
||||
<div class="chips" id="sblock">${chipsHTML(f.blocked||[],'sblockin')}</div>
|
||||
<p class="cap">Whole words or phrases. Hidden from your list and not downloaded for you, along
|
||||
with what Settings hides everywhere.</p></div>`}
|
||||
<div class="ffield">${sw(`<input type="checkbox" role="switch" id="sinall"${inAll(f)?' checked':''}${byList?' disabled':''}>`,'In All Subscriptions',' lead')}
|
||||
<p class="cap">${byList
|
||||
?`Left out with the rest of ${esc(byList.title||byList.id)}. Its own settings bring them back.`
|
||||
:`Off keeps ${isGroup?'these shows':'this show'} out of All Subscriptions and its unread count. ${isGroup?'They are':'It is'} still here, in Signals and in Currently Listening.`}</p></div>
|
||||
<div class="ffield">${sw(`<input type="checkbox" role="switch" id="sprivate"${f.private||f.private_by_address?' checked':''}${
|
||||
<div class="dpends" aria-hidden="true"><span>Brisk</span><span>Relaxed</span></div></div>`)}
|
||||
${sect('Where it shows',`<div class="fplaces">${place('sinall','hide_from_all',ICON.all,'All Subscriptions')}${
|
||||
signalsOn()?place('sinsig','hide_from_signals',`<span class="fsig">${tile('signals')}</span>`,'Signals'):''}</div>
|
||||
<p class="cap">${listHides
|
||||
?`Off where ${esc(listHides.title||listHides.id)} is off; its settings bring them back.`
|
||||
:`Off leaves ${these} out of that list and its count. ${isGroup?'Their':'Its'} own page still has everything.`}</p>
|
||||
${isGroup?'':`<div class="ffield"><label class="flabel" for="sblockin">Hide items that mention</label>
|
||||
<div class="chips" id="sblock">${chipsHTML(f.blocked||[],'sblockin')}</div>
|
||||
<p class="cap">Whole words or phrases, hidden everywhere and not downloaded for you.</p></div>`}`)}
|
||||
${sect('Directory',`${sw(`<input type="checkbox" role="switch" id="sprivate"${f.private||f.private_by_address?' checked':''}${
|
||||
f.private_by_address||(f.private&&!admin)?' disabled':''}>`,'Private',' lead')}
|
||||
<p class="cap">${f.private_by_address
|
||||
?'Its address or login shows it is someone’s own, so it is never shared with the directory.'
|
||||
:f.private&&!admin
|
||||
?'Never shared with the directory. Only an admin can share it again.'
|
||||
:`Never shared with the directory, for ${isGroup?'shows':'a show'} you pay for. iPX knows most paid feeds by their address; this is for the ones it does not.`}</p></div>
|
||||
:`Never shared with the directory, for ${isGroup?'shows':'a show'} you pay for. iPX knows most paid feeds by their address; this is for the ones it does not.`}</p>`)}
|
||||
${sect('Address',`<div class="inline"><input type="text" id="surl" value="${esc(newUrl||f.url)}" spellcheck="false" aria-label="Address"${admin?'':' readonly'}>
|
||||
<button type="button" class="btn ico" id="scopy" title="Copy the address" aria-label="Copy the address">${ICON.copy}</button></div>
|
||||
${newUrl?'<p class="moved">The feed says it has moved here. Save to follow it.</p>':''}`)}
|
||||
</div>
|
||||
<div class="cardacts fsetfoot"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||
<button class="btn ico primary" id="ssave" title="Save" aria-label="Save">${ICON.check}</button></div>`, false, 'fset');
|
||||
@@ -722,8 +728,11 @@ function settingsModal(f: Feed, newUrl?: string){
|
||||
// Sent only when it changed: anyone may mark a feed private, but only an admin unmark it.
|
||||
const priv=$<HTMLInputElement>('#sprivate');
|
||||
if(!priv.disabled&&priv.checked!==!!f.private) patch.private=priv.checked;
|
||||
const inall=$<HTMLInputElement>('#sinall');
|
||||
if(!inall.disabled&&inall.checked===!!f.hide_from_all) patch.hide_from_all=!inall.checked;
|
||||
// Sent only when changed, and not while the OPML decides it.
|
||||
for(const [id,key] of [['#sinall','hide_from_all'],['#sinsig','hide_from_signals']] as const){
|
||||
const x=document.querySelector<HTMLInputElement>(id);
|
||||
if(x&&!x.disabled&&x.checked===!!f[key]) patch[key]=!x.checked;
|
||||
}
|
||||
// The address is the same for everyone: an admin's to change, for a feed that moved, and the
|
||||
// API refuses it from anyone else. The folder and category are not this dialog's at all.
|
||||
if(admin) patch.url=$<HTMLInputElement>('#surl').value.trim();
|
||||
|
||||
@@ -35,9 +35,10 @@ const VIEWS: Record<string,{title: string, icon: string, url?: string, blurb?: s
|
||||
blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'},
|
||||
':all':{title:'All Subscriptions',icon:ICON.all},
|
||||
};
|
||||
/// Whether a feed's items are in All Subscriptions: not when it, or the OPML or creator it came
|
||||
/// from, is hidden from it (#288). The server leaves out the same.
|
||||
const inAll=(f: Feed)=>!f.hide_from_all&&!(f.group&&S.feeds.some(g=>g.id===f.group&&g.hide_from_all));
|
||||
/// Whether a feed's items are in All Subscriptions, or in Signals: not when it, or the OPML or
|
||||
/// creator it came from, is hidden from them (#288, #289). The server leaves out the same.
|
||||
const shownIn=(f: Feed, key: 'hide_from_all'|'hide_from_signals')=>!f[key]&&!(f.group&&S.feeds.some(g=>g.id===f.group&&g[key]));
|
||||
const inAll=(f: Feed)=>shownIn(f,'hide_from_all');
|
||||
/// All Subscriptions' unread count.
|
||||
const unreadInAll=()=>S.feeds.reduce((n,f)=>n+(inAll(f)?f.unread||0:0),0);
|
||||
/// Feeds being checked right now, from the event stream, and since when: their rows, and the row
|
||||
|
||||
@@ -39,6 +39,8 @@ interface Feed {
|
||||
kept: boolean;
|
||||
/** Left out of All Subscriptions by its own setting; a show is also when its OPML is (#288). */
|
||||
hide_from_all: boolean;
|
||||
/** The same for Signals (#289). */
|
||||
hide_from_signals: boolean;
|
||||
private: boolean;
|
||||
private_by_address: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user