Prototype: the feed settings dialog redesigned (#167), for approval
This commit is contained in:
96
web/app.css
96
web/app.css
@@ -1052,6 +1052,73 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
||||
.inline input[type=number]{flex:none;width:90px}
|
||||
.check input{width:16px;height:16px;accent-color:var(--accent)}
|
||||
.cardacts{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
|
||||
|
||||
/* ---------- 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%)}
|
||||
.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 .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)}
|
||||
/* 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. */
|
||||
.switch{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:38px;font-size:14px;font-weight:500;cursor:pointer}
|
||||
/* The one switch the rest hang from. */
|
||||
.switch.lead{font-weight:600}
|
||||
.sw{position:relative;flex:none;width:42px;height:26px}
|
||||
.sw input{position:absolute;inset:0;width:100%;height:100%;margin:0;opacity:0;cursor:pointer}
|
||||
.sw i{position:absolute;inset:0;border-radius:999px;background:var(--raise);border:1px solid var(--line);pointer-events:none;transition:background-color .18s,border-color .18s}
|
||||
/* White, as iOS has it, in every theme: in --ink, the dark themes' knob was a black hole in the track. */
|
||||
.sw i::after{content:"";position:absolute;top:2px;left:2px;width:20px;height:20px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.35);transition:transform .18s}
|
||||
.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}
|
||||
/* What only matters while it downloads on its own hangs from that switch, and 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. */
|
||||
.fdeps{display:grid;grid-template-rows:1fr;transition:grid-template-rows .22s ease;margin:2px 0 6px 20px;border-left:2px solid var(--line)}
|
||||
.fdeps.off{grid-template-rows:0fr;border-left-color:transparent}
|
||||
.fdeps>div{min-height:0;overflow:hidden}
|
||||
.fdepsin{padding:8px 2px 6px 18px}
|
||||
/* 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}
|
||||
.seg input{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none}
|
||||
.seg label:has(input:checked){background:var(--raise);color:var(--fg);font-weight:500}
|
||||
.seg label:has(input:focus-visible){outline:2px solid var(--accent);outline-offset:-2px}
|
||||
/* The feed's newest items, newest first, each as tall as it is long; lit, those that download on
|
||||
their own. Dragged along, it sets how many. Only drawing: the number in the sentence below is
|
||||
what a keyboard and a screen reader use. */
|
||||
.epwin{display:flex;align-items:flex-end;gap:2px;height:38px;margin-bottom:8px;cursor:ew-resize;touch-action:pan-y;user-select:none;-webkit-user-select:none}
|
||||
.epwin:empty{display:none}
|
||||
.epwin i{flex:1 1 0;min-width:2px;max-width:10px;height:var(--h);border-radius:2px;background:var(--line);transition:background-color .2s calc(var(--n) * 12ms)}
|
||||
.epwin i.on{background:var(--accent)}
|
||||
.epwin i.nofile{height:4px}
|
||||
.epwin small{flex:none;align-self:center;margin-left:8px;font-size:12px;color:var(--faint);font-variant-numeric:tabular-nums}
|
||||
.epwin.all small{color:var(--accent)}
|
||||
.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}
|
||||
.fdeps+.ffield{margin-top:18px}
|
||||
.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. */
|
||||
.chips{display:flex;flex-wrap:wrap;gap:6px;align-items:center;min-height:38px;padding:5px;border:1px solid var(--line);border-radius:9px;background:var(--bg);cursor:text}
|
||||
.chips:focus-within{border-color:var(--accent)}
|
||||
.chip{display:inline-flex;align-items:center;gap:2px;max-width:100%;padding:3px 3px 3px 10px;border-radius:999px;background:var(--panel2);color:var(--fg);font-size:13px;line-height:1.3;overflow-wrap:anywhere}
|
||||
.chipx{display:grid;place-items:center;flex:none;width:20px;height:20px;padding:0;border-radius:50%;color:var(--dim)}
|
||||
.chipx:hover{background:var(--raise);color:var(--fg)}
|
||||
.chipx .i{width:9px;height:9px}
|
||||
.chips .chipin{flex:1 0 8ch;width:auto;min-width:0;padding:4px 6px;border:0;background:none}
|
||||
/* The feed's own settings, one copy for everyone and an admin's to change, set apart on the
|
||||
page's own ground. */
|
||||
.fshared{margin-top:20px;padding:14px 16px 4px;border:1px solid var(--line);border-radius:10px;background:var(--bg)}
|
||||
.fshared h4{margin:0 0 12px;font-size:13px;font-weight:600}
|
||||
.fshared>.hint{margin:-6px 0 12px;font-size:12.5px;color:var(--dim)}
|
||||
.fshared .field input{background:var(--panel)}
|
||||
.moved{margin:-4px 0 12px;font-size:13px;color:var(--accent)}
|
||||
#toasts{position:fixed;bottom:88px;right:18px;display:flex;flex-direction:column;gap:8px;z-index:60}
|
||||
.toast{
|
||||
background:var(--raise);border:1px solid var(--line);border-radius:9px;
|
||||
@@ -1264,6 +1331,27 @@ body:has(#player.on) #status{padding-bottom:3px}
|
||||
.card h3{font-size:22px}
|
||||
.field label,.field .hint{font-size:13px}
|
||||
.check{font-size:17px}
|
||||
/* A feed's settings, at a phone's sizes. */
|
||||
.card.fset{padding:0}
|
||||
.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}
|
||||
.fsethead p,.wcap,.cap,.fshared>.hint{font-size:14px}
|
||||
.switch,.flabel{font-size:17px}
|
||||
.switch{min-height:44px}
|
||||
.sw{width:51px;height:31px}
|
||||
.sw i::after{width:25px;height:25px}
|
||||
.sw input:checked+i::after{transform:translateX(20px)}
|
||||
.seg label{padding:7px 4px;border-radius:8px;font-size:14px}
|
||||
.seg label:has(input:checked){box-shadow:0 1px 3px rgba(0,0,0,.22)}
|
||||
/* 16px or more, or Safari zooms the page in on it. */
|
||||
.wcap input[type=number]{font-size:16px;padding:3px 4px}
|
||||
.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}
|
||||
.fshared{border-color:var(--edge);border-radius:14px}
|
||||
.fdeps{margin-left:12px}
|
||||
.fdepsin{padding-left:14px}
|
||||
.toast{border-radius:14px}
|
||||
}
|
||||
@keyframes sheet{from{transform:translateY(100%)}}
|
||||
@@ -1299,6 +1387,11 @@ body:has(#player.on) #status{padding-bottom:3px}
|
||||
:root[data-theme="classic"] .feed.sel .txt small,
|
||||
:root[data-theme="classic"] .place.sel .ico{color:#e6eeff}
|
||||
:root[data-theme="classic"] #epSearch{border-radius:14px}
|
||||
/* The 2004 app had checkboxes, not switches. */
|
||||
:root[data-theme="classic"] .sw{width:auto;height:auto}
|
||||
:root[data-theme="classic"] .sw input{position:static;width:14px;height:14px;opacity:1}
|
||||
:root[data-theme="classic"] .sw i{display:none}
|
||||
:root[data-theme="classic"] .seg label:has(input:checked){background:linear-gradient(#86b0ec,#3a73cf);color:#fff}
|
||||
:root[data-theme="classic"] .badge{background:#c9302c;color:#fff}
|
||||
:root[data-theme="classic"] .badge.zero{background:#b4bdc9;color:#fff}
|
||||
:root[data-theme="classic"] .ephead,
|
||||
@@ -1394,6 +1487,9 @@ body:has(#player.on) #status{padding-bottom:3px}
|
||||
:root[data-theme="glass"] .sidefoot button{border-radius:10px}
|
||||
}
|
||||
:root[data-theme="glass"] .btn.primary{box-shadow:inset 0 1px 0 rgba(255,255,255,.35)}
|
||||
/* A feed's shared settings let the glass through, a shade deeper than the card. */
|
||||
:root[data-theme="glass"] .fshared{background:color-mix(in srgb,var(--bg) 55%,transparent);border-color:var(--glass-edge)}
|
||||
:root[data-theme="glass"] .card.fset .fsetfoot{border-top-color:var(--glass-edge);background:color-mix(in srgb,var(--panel) 60%,transparent)}
|
||||
/* On a phone the rim all round a small capsule or bar read as the outline #53 took away, so the
|
||||
light only catches the top edge there, and the bars at the top and bottom have none. */
|
||||
@media (max-width:820px){
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* ---------------- modals ---------------- */
|
||||
function openModal(html: string, wide?: boolean){
|
||||
/// `cls` names a dialog with a frame of its own. Set whole each time, so it never outlives its dialog.
|
||||
function openModal(html: string, wide?: boolean, cls?: string){
|
||||
$('#modalCard').innerHTML=html;
|
||||
$('#modalCard').classList.toggle('wide',!!wide);
|
||||
$('#modalCard').className='card'+(wide?' wide':'')+(cls?' '+cls:'');
|
||||
$('#modal').classList.add('on');
|
||||
}
|
||||
function closeModal(){
|
||||
@@ -440,73 +441,192 @@ async function drawTokens(){
|
||||
|
||||
const splitWords=(s: string)=>s.split(',').map(w=>w.trim()).filter(Boolean);
|
||||
|
||||
/// Words as chips, for the words that pick or hide items: each word or phrase ends with Enter or a
|
||||
/// comma, and comes off with its x or Backspace. One comma-separated box ran one phrase into the
|
||||
/// next (#167).
|
||||
const chipHTML=(w: string)=>`<span class="chip" data-w="${esc(w)}">${esc(w)}<button type="button" class="chipx"
|
||||
title="Remove ${esc(w)}" aria-label="Remove ${esc(w)}">${ICON.close}</button></span>`;
|
||||
/// What goes inside a chip box. The box itself is written out where it is used, id and all: the page
|
||||
/// check finds ids by reading them, and one put together here would read as missing.
|
||||
const CHIP_HINT='Add a word or phrase';
|
||||
function chipsHTML(words: string[], input: string){
|
||||
return `${words.map(chipHTML).join('')}<input type="text" class="chipin" id="${input}"
|
||||
placeholder="${words.length?'':CHIP_HINT}" enterkeyhint="done" autocomplete="off" spellcheck="false">`;
|
||||
}
|
||||
/// The words in a chip box, with whatever is typed and not yet a chip.
|
||||
function chipWords(box){
|
||||
return [...$$('.chip',box).map(c=>c.dataset.w), ...splitWords($('.chipin',box).value)];
|
||||
}
|
||||
function wireChips(box){
|
||||
const input=$('.chipin',box);
|
||||
// The hint only in an empty box: beside chips it reads as one more.
|
||||
const hint=()=>{ input.placeholder=$$('.chip',box).length?'':CHIP_HINT; };
|
||||
const add=()=>{
|
||||
const have=$$('.chip',box).map(c=>c.dataset.w.toLowerCase());
|
||||
for(const w of splitWords(input.value)){
|
||||
if(have.includes(w.toLowerCase())) continue;
|
||||
input.insertAdjacentHTML('beforebegin',chipHTML(w)); have.push(w.toLowerCase());
|
||||
}
|
||||
input.value=''; hint();
|
||||
};
|
||||
input.onkeydown=e=>{
|
||||
if(e.key==='Enter'||e.key===','){ e.preventDefault(); add(); }
|
||||
else if(e.key==='Backspace'&&!input.value){ input.previousElementSibling?.remove(); hint(); }
|
||||
};
|
||||
// Typed and left without Enter is still meant: Save takes it either way.
|
||||
input.onblur=add;
|
||||
box.onclick=e=>{
|
||||
const x=e.target.closest('.chipx');
|
||||
if(x){ x.parentElement.remove(); hint(); }
|
||||
input.focus();
|
||||
};
|
||||
}
|
||||
|
||||
/// Your settings for one feed, and the feed's own, which are everyone's and an admin's to change
|
||||
/// (#167). What it says is what will happen, in this feed's numbers: the download limit was a bare
|
||||
/// box whose blank, number and 0 were explained in bold small print.
|
||||
function settingsModal(f, newUrl?: string){
|
||||
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
|
||||
settings apply to it and are inherited by every feed inside it.</p>`:''}
|
||||
${f.managed?`<p class="hint" style="margin:-6px 0 12px">This feed comes from
|
||||
${isPatreon(S.feeds.find(p=>p.id===f.group))?'a Patreon creator':'an OPML subscription'} and follows its settings. Saving anything here gives it its own entry in
|
||||
config.toml, and it stops following the subscription's settings.</p>`:''}
|
||||
<p class="hint" style="margin:-4px 0 10px">These are <b>your</b> settings for this feed.
|
||||
Everyone else keeps their own.</p>
|
||||
<div class="field"><label>Keywords</label>
|
||||
<input type="text" id="skw" value="${esc(f.keywords.join(', '))}">
|
||||
<span class="hint">Comma separated. Empty takes everything.</span></div>
|
||||
${isGroup?'':`<div class="field"><label for="sblock">Hide items mentioning</label>
|
||||
<input type="text" id="sblock" value="${esc((f.blocked||[]).join(', '))}">
|
||||
<span class="hint">Comma separated words or phrases. An item with one in its title or text
|
||||
is hidden from you and not downloaded for you, as well as those your Settings hide
|
||||
everywhere.</span></div>`}
|
||||
<div class="field"><label>Newest episodes to download</label>
|
||||
<input type="number" id="smax" min="0" value="${f.max_new_per_check??''}">
|
||||
<span class="hint">Blank follows the global default (${globalMax}). Older episodes stay
|
||||
listed to download by hand. <b>0 means every episode</b>, the whole back catalogue.</span></div>
|
||||
<label class="check"><input type="checkbox" id="sauto" ${f.auto_download?'checked':''}> Download new items automatically</label>
|
||||
<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(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
|
||||
? `Shared with everyone reading this feed. Editing it keeps every item and download —
|
||||
handy when an auth token in the URL is rotated. The feed is re-checked from scratch
|
||||
on the next scan.`
|
||||
: `The same for everyone reading this feed, so only an admin can change it.`}</span></div>
|
||||
${S.me&&S.me.admin?`<div class="field"><label>Download folder (shared)</label>
|
||||
<input type="text" id="sfolder" value="${esc(f.folder||'')}" placeholder="${esc(f.title||f.id)}">
|
||||
<span class="hint">Where the files land. There is one copy however many people
|
||||
subscribe, so this is the same for everyone.</span></div>`:''}
|
||||
${S.me&&S.me.admin?`<div class="field"><label>Directory category (shared)</label>
|
||||
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;
|
||||
const admin=!!(S.me&&S.me.admin);
|
||||
const name=f.title||f.id;
|
||||
// Blank follows the server's default; 0 is every item (#166).
|
||||
let mode=f.max_new_per_check==null?'default':f.max_new_per_check===0?'all':'newest';
|
||||
let newest=f.max_new_per_check>0?f.max_new_per_check:(globalMax||3);
|
||||
const whose=isGroup
|
||||
? `Your settings for the ${plural(kids,'show')} in this ${isPatreon(f)?'Patreon creator':'OPML subscription'}. A show you set on its own keeps its own.`
|
||||
: parent
|
||||
? `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>`;
|
||||
openModal(`<div class="fsetbody">
|
||||
<header class="fsethead">${artHTML(f.image,name)}<div><h3>${esc(name)}</h3><p>${whose}</p></div></header>
|
||||
${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])=>
|
||||
`<label><input type="radio" name="swin" value="${v}"${mode===v?' checked':''}>${t}</label>`).join('')}</div>
|
||||
<div class="epwin" id="sepwin" aria-hidden="true"></div>
|
||||
<p class="wcap" id="swcap"></p>
|
||||
<div class="ffield"><label class="flabel" for="skwin">Only items that mention</label>
|
||||
<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>
|
||||
${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>`}
|
||||
<section class="fshared"><h4>The same for everyone who reads it</h4>
|
||||
${newUrl?'<p class="moved">The feed says it has moved here. Save to follow it.</p>':''}
|
||||
<div class="field"><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>
|
||||
${admin?'<span class="hint">Changing it keeps every item and download, for when a key in it changes.</span>':''}</div>
|
||||
${admin?`<div class="field"><label for="sfolder">Folder</label>
|
||||
<input type="text" id="sfolder" value="${esc(f.folder||'')}" placeholder="${esc(name)}">
|
||||
<span class="hint">Where its files are saved. One copy serves everyone.</span></div>
|
||||
<div class="field"><label for="scat">Directory category</label>
|
||||
<input type="text" id="scat" list="scats" value="${esc(f.category||'')}" placeholder="${esc(f.feed_category||'None')}">
|
||||
<datalist id="scats"></datalist>
|
||||
<span class="hint">${f.feed_category
|
||||
? `The feed names its own, ${esc(f.feed_category)}, and the Directory uses that.`
|
||||
: `The feed names none, so the Directory files it under this. Pick one already listed where it fits.`}</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="ssave" title="Save" aria-label="Save">${ICON.check}</button></div>`);
|
||||
: 'The feed names none, so the Directory files it here. Pick one already listed where it fits.'}</span></div>`
|
||||
:'<p class="hint">Only an admin changes these.</p>'}
|
||||
</section></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');
|
||||
|
||||
// What only matters while it downloads on its own folds away, out of reach, when it does not.
|
||||
const deps=$('#sdeps'), inner=$('.fdepsin',deps);
|
||||
inner.inert=!f.auto_download;
|
||||
$('#sauto').onchange=e=>{ deps.classList.toggle('off',!e.target.checked); inner.inert=!e.target.checked; };
|
||||
|
||||
// The download limit, said as what it does. A number in a box hid that it is a window on the
|
||||
// newest items, not a pace through the back catalogue (#97).
|
||||
const each=isGroup?' of each show':'';
|
||||
const older=' Older ones stay listed, to download by hand.';
|
||||
const caption=()=>mode==='newest'
|
||||
? `Downloads the newest <input type="number" id="smax" min="1" value="${newest}"
|
||||
aria-label="How many of the newest to download">${each} on its own.${older}`
|
||||
: mode==='all'
|
||||
? `Downloads every item${each}: the whole back catalogue${!isGroup&&f.entries?`, ${f.entries} so far`:''}, and each new one.`
|
||||
: globalMax
|
||||
? `The server's default: downloads the newest ${globalMax}${each} on its own.${older}`
|
||||
: `The server's default: downloads every item${each}, the whole back catalogue.`;
|
||||
const limit=()=>mode==='all'||(mode==='default'&&!globalMax)?Infinity:mode==='default'?globalMax:newest;
|
||||
let bars=[];
|
||||
const draw=(words: boolean)=>{
|
||||
// The newest items with a file, as hold_back counts them: one whose files a filter all
|
||||
// skipped holds no place.
|
||||
let left=limit();
|
||||
// Everything: the older ones past the strip are lit too, in words.
|
||||
$('#sepwin')?.classList.toggle('all',left===Infinity);
|
||||
for(const b of bars){
|
||||
const on=!b.classList.contains('nofile')&&left>0;
|
||||
if(on) left--;
|
||||
b.classList.toggle('on',on);
|
||||
}
|
||||
if(!words) return;
|
||||
$('#swcap').innerHTML=caption();
|
||||
const n=$('#smax');
|
||||
if(n) n.oninput=()=>{ newest=Math.max(1,Math.floor(Number(n.value))||1); draw(false); };
|
||||
};
|
||||
for(const r of $$('[name=swin]')) r.onchange=()=>{ mode=r.value; draw(true); };
|
||||
draw(true);
|
||||
// The feed's own newest items, each as tall as it is long, so the strip reads as this show.
|
||||
if(!isGroup) api(`/api/feeds/${encodeURIComponent(f.id)}/entries?limit=40`).then(page=>{
|
||||
const items=page?.entries||[];
|
||||
const strip=$('#sepwin'); if(!items.length||!strip) return;
|
||||
const longest=Math.max(1,...items.map(e=>e.duration||0));
|
||||
strip.innerHTML=items.map((e,i)=>{
|
||||
const file=e.enclosures.some(x=>x.state!=='skipped');
|
||||
const h=e.duration?Math.round(30+70*Math.sqrt(e.duration/longest)):60;
|
||||
return `<i class="${file?'':'nofile'}" style="--h:${h}%;--n:${i}"></i>`;
|
||||
}).join('')+(f.entries>items.length?`<small>+${f.entries-items.length} older</small>`:'');
|
||||
bars=$$('i',strip);
|
||||
draw(false);
|
||||
// Drag along it to set how many: as far as the finger, the newest that far down.
|
||||
const pick=(x: number)=>{
|
||||
let n=0;
|
||||
for(const b of bars){
|
||||
if(!b.classList.contains('nofile')) n++;
|
||||
if(x<=b.getBoundingClientRect().right) break;
|
||||
}
|
||||
newest=Math.max(1,n);
|
||||
if(mode!=='newest'){ mode='newest'; $('[name=swin][value=newest]').checked=true; draw(true); }
|
||||
else { $('#smax').value=String(newest); draw(false); }
|
||||
};
|
||||
strip.onpointerdown=e=>{ strip.setPointerCapture(e.pointerId); pick(e.clientX); };
|
||||
strip.onpointermove=e=>{ if(strip.hasPointerCapture(e.pointerId)) pick(e.clientX); };
|
||||
}).catch(()=>{});
|
||||
|
||||
wireChips($('#skw'));
|
||||
if($('#sblock')) wireChips($('#sblock'));
|
||||
$('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy'));
|
||||
// Offer the categories the Directory already shows, so a blog about games joins Games rather
|
||||
// than starting a second chip beside it.
|
||||
if($('#scats')) api('/api/directory').then(rows=>{ $('#scats').innerHTML=[...new Set((rows||[])
|
||||
.flatMap(p=>[p.category,p.subcategory]).filter(Boolean))].sort().map(c=>`<option value="${esc(c)}">`).join(''); }).catch(()=>{});
|
||||
// Opened to follow a feed that moved: the new address is what there is to look at.
|
||||
if(newUrl){ $('#surl').scrollIntoView({block:'center'}); $('#surl').focus(); }
|
||||
|
||||
$('#ssave').onclick=async()=>{
|
||||
const max=$('#smax').value;
|
||||
try{
|
||||
const patch: Record<string, unknown>={
|
||||
keywords:splitWords($('#skw').value),
|
||||
max_new_per_check:max===''?null:Number(max),
|
||||
keywords:chipWords($('#skw')),
|
||||
max_new_per_check:mode==='default'?null:mode==='all'?0:newest,
|
||||
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked};
|
||||
if($('#sblock')) patch.blocked=splitWords($('#sblock').value);
|
||||
if($('#sblock')) patch.blocked=chipWords($('#sblock'));
|
||||
// The shared half is an admin's to change, and the API refuses it from anyone else.
|
||||
if(S.me&&S.me.admin){
|
||||
if(admin){
|
||||
patch.url=$('#surl').value.trim();
|
||||
patch.folder=$('#sfolder').value.trim()||null;
|
||||
patch.category=$('#scat').value.trim()||null;
|
||||
}
|
||||
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify(patch)});
|
||||
closeModal(); toast('Saved — applies on the next scan');
|
||||
closeModal(); toast('Saved. It applies from the next check.');
|
||||
await loadFeeds(true); renderFeed(); loadEntries();
|
||||
}catch(e){ toast(e.message,true); }
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user