From ecbc2538946b8bc521d6fefb45a9d2c87766e24b Mon Sep 17 00:00:00 2001 From: rays Date: Fri, 9 Oct 2026 12:25:57 +0000 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + tests/ui/app.spec.js | 49 ++++++++++ web/app.css | 96 ++++++++++++++++++ web/src/dialogs.ts | 226 +++++++++++++++++++++++++++++++++---------- 4 files changed, 319 insertions(+), 53 deletions(-) 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(`

${esc(f.title||f.id)}

- ${isGroup?`

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.

-
- - Comma separated. Empty takes everything.
- ${isGroup?'':`
- - 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.
`} -
- - Blank follows the global default (${globalMax}). Older episodes stay - listed to download by hand. 0 means every episode, the whole back catalogue.
- - -
-
- - -
- ${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.`}
- ${S.me&&S.me.admin?`
- - Where the files land. There is one copy however many people - subscribe, so this is the same for everyone.
`:''} - ${S.me&&S.me.admin?`
- - - ${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.`}
`:''} -
-
`); + 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='')=>``; + openModal(`
+
${artHTML(f.image,name)}

${esc(name)}

${whose}

+ ${sw(``,'Download automatically',' lead')} +
+
${ + [['default',`Default (${globalMax||'all'})`],['newest','Newest'],['all','All']].map(([v,t])=> + ``).join('')}
+ +

+
+
${chipsHTML(f.keywords,'skwin')}
+

Any one of these will do, and a phrase needs all its words. Empty takes everything.

+ ${sw(``,'Include explicit items')} +
+ ${isGroup?'':`
+
${chipsHTML(f.blocked||[],'sblockin')}
+

Whole words or phrases. Hidden from your list and not downloaded for you, along + with what Settings hides everywhere.

`} +

The same for everyone who reads it

+ ${newUrl?'

The feed says it has moved here. Save to follow it.

':''} +
+
+
+ ${admin?'Changing it keeps every item and download, for when a key in it changes.':''}
+ ${admin?`
+ + Where its files are saved. One copy serves everyone.
+
+ + + ${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.'}
` + :'

Only an admin changes these.

'} +
+
+
`, 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 ${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 ``; + }).join('')+(f.entries>items.length?`+${f.entries-items.length} older`:''); + 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=>`