diff --git a/CHANGELOG.md b/CHANGELOG.md index cdbcc54..cd5da25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. - 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. diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index 0d7dc87..39e4671 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -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)); }); + +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'); +}); diff --git a/web/app.css b/web/app.css index e17007a..ccfc7aa 100644 --- a/web/app.css +++ b/web/app.css @@ -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} .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; @@ -1267,6 +1334,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%)}} @@ -1302,6 +1390,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, @@ -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"] .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){ diff --git a/web/src/dialogs.ts b/web/src/dialogs.ts index 4a1614e..0e43056 100644 --- a/web/src/dialogs.ts +++ b/web/src/dialogs.ts @@ -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)=>`${esc(w)}`; +/// 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('')}`; +} +/// 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(`
This is ${isPatreon(f)?'a Patreon creator':'an OPML subscription'}. These - settings apply to it and are inherited by every feed inside it.
`:''} - ${f.managed?`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.
`:''} -These are your settings for this feed. - Everyone else keeps their own.
-${whose}
Any one of these will do, and a phrase needs all its words. Empty takes everything.
Whole words or phrases. Hidden from your list and not downloaded for you, along + with what Settings hides everywhere.
The feed says it has moved here. Save to follow it.
':''} +Only an admin changes these.
'} +