Redesign the feed settings dialog (#167)

One column of label, box and grey paragraph, eight times at the same weight, three screens long on a
phone with Save at the bottom of the third. Approved from the mockups on #167.

- Download automatically comes first, as a switch; how many, the keywords and explicit, which only
  ever applied to automatic downloads, hang beneath it and fold away when it is off.
- How many is Default, Newest or All, said as a sentence with the number in it, over a strip of the
  feed's own newest items, each as tall as it is long and lit where it downloads; dragging along
  it sets Newest. All is 0, which now means all (#166).
- Keywords and hidden words are chips: Enter or a comma adds one, its x or Backspace removes it.
- The feed's own settings, the same for everyone and an admin's to change, sit apart in a box.
- The body scrolls and Cancel and Save stay at the foot.
- Theme colours only; iOS switches, but checkboxes in Classic. openModal takes a class for a
  dialog with a frame of its own, set whole each time so it never outlives it.

A browser test covers the strip, chips, the fold and a save that holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 12:25:57 +00:00
parent b4c115d79a
commit ecbc253894
4 changed files with 319 additions and 53 deletions

View File

@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- A feed's settings are redesigned: downloading on its own is a switch with what depends on it beneath, how many is Default, Newest or All shown on a strip of the show's own episodes, words to pick or hide are chips, the settings shared by everyone sit apart, and Save is always in reach.
- The feed list's traces show each of its database queries, so it can be told where its time goes. - The feed list's traces show each of its database queries, so it can be told where its time goes.
- Saving a feed's settings asks the database for your subscription once, not twice. - Saving a feed's settings asks the database for your subscription once, not twice.
- `ipx add` and Add feed in the page add a feed the same way, through one piece of code. - `ipx add` and Add feed in the page add a feed the same way, through one piece of code.

View File

@@ -1701,3 +1701,52 @@ test('a page brought back into view catches up with what was read elsewhere', as
}); });
await expect(badge).toHaveText(String(before - 1)); await expect(badge).toHaveText(String(before - 1));
}); });
test('a feed\'s settings say what downloads, take words as chips, and keep what is saved', async ({ page }) => {
// The redesigned dialog (#167): the download choice, word chips, the fold, and a save that holds.
const open = async () => {
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
await page.locator('#content .acts [data-a="settings"]').click();
await expect(page.locator('#modalCard.fset')).toBeVisible();
};
await open();
// The strip is the feed's own items, and Newest 5 lights every one of its two.
await expect(page.locator('#sepwin i')).toHaveCount(2);
await page.locator('.seg label', { hasText: 'Newest' }).click();
await page.locator('#smax').fill('5');
await expect(page.locator('#sepwin i.on')).toHaveCount(2);
await expect(page.locator('#swcap')).toContainText('Downloads the newest');
// Words become chips with Enter or a comma, and come off with their x.
await page.locator('#skwin').fill('first, second');
await page.locator('#skwin').press('Enter');
await expect(page.locator('#skw .chip')).toHaveCount(2);
await page.locator('#skw .chip', { hasText: 'second' }).locator('.chipx').click();
await expect(page.locator('#skw .chip')).toHaveCount(1);
await page.locator('#sblockin').fill('spoiler');
await page.locator('#sblockin').press('Enter');
// What only automatic downloads use folds away with them.
await page.locator('.switch', { hasText: 'Download automatically' }).click();
await expect(page.locator('#sdeps')).toHaveClass(/\boff\b/);
await page.locator('.switch', { hasText: 'Download automatically' }).click();
await expect(page.locator('#sdeps')).not.toHaveClass(/\boff\b/);
await page.locator('#ssave').click();
await expect(page.locator('#modal.on')).toBeHidden();
// Saved: open again and it is all there.
await open();
await expect(page.locator('[name=swin][value=newest]')).toBeChecked();
await expect(page.locator('#smax')).toHaveValue('5');
await expect(page.locator('#skw .chip')).toHaveText(['first']);
await expect(page.locator('#sblock .chip')).toHaveText(['spoiler']);
// Back as it was, for the tests after this one.
await page.locator('.seg label', { hasText: 'Default' }).click();
await page.locator('#skw .chipx').click();
await page.locator('#sblock .chipx').click();
await page.locator('#ssave').click();
await expect(page.locator('#modal.on')).toBeHidden();
await open();
await expect(page.locator('[name=swin][value=default]')).toBeChecked();
await expect(page.locator('#skw .chip, #sblock .chip')).toHaveCount(0);
await page.keyboard.press('Escape');
});

View File

@@ -1055,6 +1055,73 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
.inline input[type=number]{flex:none;width:90px} .inline input[type=number]{flex:none;width:90px}
.check input{width:16px;height:16px;accent-color:var(--accent)} .check input{width:16px;height:16px;accent-color:var(--accent)}
.cardacts{display:flex;gap:8px;justify-content:flex-end;margin-top:16px} .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} #toasts{position:fixed;bottom:88px;right:18px;display:flex;flex-direction:column;gap:8px;z-index:60}
.toast{ .toast{
background:var(--raise);border:1px solid var(--line);border-radius:9px; background:var(--raise);border:1px solid var(--line);border-radius:9px;
@@ -1267,6 +1334,27 @@ body:has(#player.on) #status{padding-bottom:3px}
.card h3{font-size:22px} .card h3{font-size:22px}
.field label,.field .hint{font-size:13px} .field label,.field .hint{font-size:13px}
.check{font-size:17px} .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} .toast{border-radius:14px}
} }
@keyframes sheet{from{transform:translateY(100%)}} @keyframes sheet{from{transform:translateY(100%)}}
@@ -1302,6 +1390,11 @@ body:has(#player.on) #status{padding-bottom:3px}
:root[data-theme="classic"] .feed.sel .txt small, :root[data-theme="classic"] .feed.sel .txt small,
:root[data-theme="classic"] .place.sel .ico{color:#e6eeff} :root[data-theme="classic"] .place.sel .ico{color:#e6eeff}
:root[data-theme="classic"] #epSearch{border-radius:14px} :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{background:#c9302c;color:#fff}
:root[data-theme="classic"] .badge.zero{background:#b4bdc9;color:#fff} :root[data-theme="classic"] .badge.zero{background:#b4bdc9;color:#fff}
:root[data-theme="classic"] .ephead, :root[data-theme="classic"] .ephead,
@@ -1397,6 +1490,9 @@ body:has(#player.on) #status{padding-bottom:3px}
:root[data-theme="glass"] .sidefoot button{border-radius:10px} :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)} :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 /* 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. */ light only catches the top edge there, and the bars at the top and bottom have none. */
@media (max-width:820px){ @media (max-width:820px){

View File

@@ -1,7 +1,8 @@
/* ---------------- modals ---------------- */ /* ---------------- 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').innerHTML=html;
$('#modalCard').classList.toggle('wide',!!wide); $('#modalCard').className='card'+(wide?' wide':'')+(cls?' '+cls:'');
$('#modal').classList.add('on'); $('#modal').classList.add('on');
} }
function closeModal(){ function closeModal(){
@@ -440,73 +441,192 @@ async function drawTokens(){
const splitWords=(s: string)=>s.split(',').map(w=>w.trim()).filter(Boolean); 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){ function settingsModal(f, newUrl?: string){
const isGroup = S.feeds.some(c=>c.group===f.id); const kids=S.feeds.filter(c=>c.group===f.id).length;
openModal(`<h3>${esc(f.title||f.id)}</h3> const isGroup=kids>0;
${isGroup?`<p class="hint" style="margin:-6px 0 12px">This is ${isPatreon(f)?'a Patreon creator':'an OPML subscription'}. These const parent=f.managed?S.feeds.find(p=>p.id===f.group):null;
settings apply to it and are inherited by every feed inside it.</p>`:''} const admin=!!(S.me&&S.me.admin);
${f.managed?`<p class="hint" style="margin:-6px 0 12px">This feed comes from const name=f.title||f.id;
${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 // Blank follows the server's default; 0 is every item (#166).
config.toml, and it stops following the subscription's settings.</p>`:''} let mode=f.max_new_per_check==null?'default':f.max_new_per_check===0?'all':'newest';
<p class="hint" style="margin:-4px 0 10px">These are <b>your</b> settings for this feed. let newest=f.max_new_per_check>0?f.max_new_per_check:(globalMax||3);
Everyone else keeps their own.</p> const whose=isGroup
<div class="field"><label>Keywords</label> ? `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.`
<input type="text" id="skw" value="${esc(f.keywords.join(', '))}"> : parent
<span class="hint">Comma separated. Empty takes everything.</span></div> ? `It comes from ${esc(parent.title||parent.id)}, and follows your settings there until you save these.`
${isGroup?'':`<div class="field"><label for="sblock">Hide items mentioning</label> : 'Your settings for this feed. Everyone else who reads it keeps their own.';
<input type="text" id="sblock" value="${esc((f.blocked||[]).join(', '))}"> const sw=(input: string, text: string, cls='')=>`<label class="switch${cls}"><span>${text}</span><span class="sw">${input}<i></i></span></label>`;
<span class="hint">Comma separated words or phrases. An item with one in its title or text openModal(`<div class="fsetbody">
is hidden from you and not downloaded for you, as well as those your Settings hide <header class="fsethead">${artHTML(f.image,name)}<div><h3>${esc(name)}</h3><p>${whose}</p></div></header>
everywhere.</span></div>`} ${sw(`<input type="checkbox" role="switch" id="sauto"${f.auto_download?' checked':''}>`,'Download automatically',' lead')}
<div class="field"><label>Newest episodes to download</label> <div class="fdeps${f.auto_download?'':' off'}" id="sdeps"><div><div class="fdepsin">
<input type="number" id="smax" min="0" value="${f.max_new_per_check??''}"> <div class="tabs seg" role="radiogroup" aria-label="How many to download">${
<span class="hint">Blank follows the global default (${globalMax}). Older episodes stay [['default',`Default (${globalMax||'all'})`],['newest','Newest'],['all','All']].map(([v,t])=>
listed to download by hand. <b>0 means every episode</b>, the whole back catalogue.</span></div> `<label><input type="radio" name="swin" value="${v}"${mode===v?' checked':''}>${t}</label>`).join('')}</div>
<label class="check"><input type="checkbox" id="sauto" ${f.auto_download?'checked':''}> Download new items automatically</label> <div class="epwin" id="sepwin" aria-hidden="true"></div>
<label class="check"><input type="checkbox" id="sexp" ${f.allow_explicit?'checked':''}> Allow items marked explicit</label> <p class="wcap" id="swcap"></p>
<div class="field"><label>Feed URL</label> <div class="ffield"><label class="flabel" for="skwin">Only items that mention</label>
<div class="inline"> <div class="chips" id="skw">${chipsHTML(f.keywords,'skwin')}</div>
<input type="text" id="surl" value="${esc(newUrl||f.url)}" spellcheck="false" ${S.me&&S.me.admin?'':'readonly'}> <p class="cap">Any one of these will do, and a phrase needs all its words. Empty takes everything.</p></div>
<button type="button" class="btn ico" id="scopy" title="Copy the URL" aria-label="Copy the URL">${ICON.copy}</button> ${sw(`<input type="checkbox" role="switch" id="sexp"${f.allow_explicit?' checked':''}>`,'Include explicit items')}
</div> </div></div></div>
<span class="hint">${S.me&&S.me.admin ${isGroup?'':`<div class="ffield"><label class="flabel" for="sblockin">Hide items that mention</label>
? `Shared with everyone reading this feed. Editing it keeps every item and download — <div class="chips" id="sblock">${chipsHTML(f.blocked||[],'sblockin')}</div>
handy when an auth token in the URL is rotated. The feed is re-checked from scratch <p class="cap">Whole words or phrases. Hidden from your list and not downloaded for you, along
on the next scan.` with what Settings hides everywhere.</p></div>`}
: `The same for everyone reading this feed, so only an admin can change it.`}</span></div> <section class="fshared"><h4>The same for everyone who reads it</h4>
${S.me&&S.me.admin?`<div class="field"><label>Download folder (shared)</label> ${newUrl?'<p class="moved">The feed says it has moved here. Save to follow it.</p>':''}
<input type="text" id="sfolder" value="${esc(f.folder||'')}" placeholder="${esc(f.title||f.id)}"> <div class="field"><label for="surl">Address</label>
<span class="hint">Where the files land. There is one copy however many people <div class="inline"><input type="text" id="surl" value="${esc(newUrl||f.url)}" spellcheck="false"${admin?'':' readonly'}>
subscribe, so this is the same for everyone.</span></div>`:''} <button type="button" class="btn ico" id="scopy" title="Copy the address" aria-label="Copy the address">${ICON.copy}</button></div>
${S.me&&S.me.admin?`<div class="field"><label>Directory category (shared)</label> ${admin?'<span class="hint">Changing it keeps every item and download, for when a key in it changes.</span>':''}</div>
<input type="text" id="scat" list="scats" value="${esc(f.category||'')}" placeholder="${esc(f.feed_category||'None')}"> ${admin?`<div class="field"><label for="sfolder">Folder</label>
<datalist id="scats"></datalist> <input type="text" id="sfolder" value="${esc(f.folder||'')}" placeholder="${esc(name)}">
<span class="hint">${f.feed_category <span class="hint">Where its files are saved. One copy serves everyone.</span></div>
? `The feed names its own, ${esc(f.feed_category)}, and the Directory uses that.` <div class="field"><label for="scat">Directory category</label>
: `The feed names none, so the Directory files it under this. Pick one already listed where it fits.`}</span></div>`:''} <input type="text" id="scat" list="scats" value="${esc(f.category||'')}" placeholder="${esc(f.feed_category||'None')}">
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button> <datalist id="scats"></datalist>
<button class="btn ico primary" id="ssave" title="Save" aria-label="Save">${ICON.check}</button></div>`); <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 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')); $('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy'));
// Offer the categories the Directory already shows, so a blog about games joins Games rather // Offer the categories the Directory already shows, so a blog about games joins Games rather
// than starting a second chip beside it. // than starting a second chip beside it.
if($('#scats')) api('/api/directory').then(rows=>{ $('#scats').innerHTML=[...new Set((rows||[]) 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(()=>{}); .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()=>{ $('#ssave').onclick=async()=>{
const max=$('#smax').value;
try{ try{
const patch: Record<string, unknown>={ const patch: Record<string, unknown>={
keywords:splitWords($('#skw').value), keywords:chipWords($('#skw')),
max_new_per_check:max===''?null:Number(max), max_new_per_check:mode==='default'?null:mode==='all'?0:newest,
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked}; 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. // 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.url=$('#surl').value.trim();
patch.folder=$('#sfolder').value.trim()||null; patch.folder=$('#sfolder').value.trim()||null;
patch.category=$('#scat').value.trim()||null; patch.category=$('#scat').value.trim()||null;
} }
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify(patch)}); 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(); await loadFeeds(true); renderFeed(); loadEntries();
}catch(e){ toast(e.message,true); } }catch(e){ toast(e.message,true); }
}; };