A Signal's own icon, from all of Font Awesome Free, searchable (#261)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 18:36:08 +00:00
parent 9e5dcbb8c4
commit b18c92d1f5
15 changed files with 269 additions and 31 deletions

View File

@@ -30,6 +30,15 @@ function feedRow(id: string, cls: string, html: string, open: ()=>void){
el.onclick=()=>{ open(); nav(false); };
return el;
}
/// A Signal's icon, drawn from /icons as a mask so it takes the theme's colour, or its initials.
/// The name goes into an address and a style: only a style and a plain name are drawn (the server
/// takes nothing else either).
const ICON_ID=/^(solid|regular|brands)\/[a-z0-9-]{1,64}$/;
const iconHTML=(icon: string)=>`<span class="ficon" style="--icon:url('/icons/${icon}.svg')"></span>`;
function signalTile(sg: {name: string, icon: string|null}){
return sg.icon&&ICON_ID.test(sg.icon)
? `<div class="art ini sig" style="--tint:${tint(sg.name)}">${iconHTML(sg.icon)}</div>` : tileHTML(sg.name);
}
const badgeHTML=(n: number)=>`<span class="badge${n?'':' zero'}" title="${n} unread">${n>999?'999+':n}</span>`;
function signalFolder(list: HTMLElement, q: string){
const mine=S.signals.filter(s=>!q||s.name.toLowerCase().includes(q));
@@ -48,7 +57,7 @@ function signalFolder(list: HTMLElement, q: string){
if(!open) return;
for(const sg of mine){
const el=feedRow(':signal:'+sg.id,'child',
`<span class="fart">${tileHTML(sg.name)}</span><div class="txt"><b>${esc(sg.name)}</b><small>${esc(sg.keywords.join(', '))}</small></div>`+badgeHTML(sg.unread),
`<span class="fart">${signalTile(sg)}</span><div class="txt"><b>${esc(sg.name)}</b><small>${esc(sg.keywords.join(', '))}</small></div>`+badgeHTML(sg.unread),
()=>selectFeed(':signal:'+sg.id));
el.onpointerenter=el.ontouchstart=()=>prefetch(':signal:'+sg.id);
list.appendChild(el);
@@ -84,7 +93,7 @@ function renderSignals(){
for(const sg of S.signals){
const el=document.createElement('div');
el.className='childrow';
el.innerHTML=tileHTML(sg.name)+`<div class="txt"><b>${esc(sg.name)}</b>`+
el.innerHTML=signalTile(sg)+`<div class="txt"><b>${esc(sg.name)}</b>`+
`<small class="meta">${esc(sg.keywords.join(', '))}</small></div><span class="badge${sg.unread?'':' zero'}">${sg.unread}</span>`;
el.onclick=()=>selectFeed(':signal:'+sg.id);
list.appendChild(el);
@@ -95,7 +104,7 @@ function signalHead(sg: Signal){
const nokey=S.me&&!S.me.typesafe_key_set;
return `
<div class="fhead slim">
<div class="art">${ICON.signal}</div>
${signalTile(sg)}
<div class="meta">
<h2>${esc(sg.name)}</h2>
<div class="sub">Picked out of the noise of your ${plural(S.feeds.length,'feed')}: ${esc(sg.description||sg.keywords.join(', '))} · ${sg.unread} unread</div>
@@ -124,7 +133,14 @@ function signalModal(sg?: Signal){
const nokey=S.me&&!S.me.typesafe_key_set;
openModal(`<h3>${sg?'Edit signal':'New signal'}</h3>
<div class="field"><label for="sgname">Name</label>
<input type="text" id="sgname" value="${esc(sg?.name||'')}" placeholder="Formula 1"></div>
<div class="inline"><button type="button" class="iconpick" id="sgicon" title="Choose an icon" aria-label="Choose an icon"
aria-expanded="false"></button>
<input type="text" id="sgname" value="${esc(sg?.name||'')}" placeholder="Formula 1"></div>
<div id="sgpicker" hidden>
<div class="inline"><input type="search" id="sgiconq" placeholder="Search icons: robot, car, music…" autocomplete="off">
<button type="button" class="dlink" id="sgnoicon">Initials</button></div>
<div class="icongrid" id="sgicons" role="listbox" aria-label="Icons"></div>
</div></div>
<div class="field"><label for="sgkwin">Listen for</label>
<div class="chips" id="sgkw">${chipsHTML(sg?.keywords||[],'sgkwin')}</div></div>
<div class="field"><label for="sgdesc">In your own words (optional)</label>
@@ -137,12 +153,38 @@ function signalModal(sg?: Signal){
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
<button class="btn ico primary" id="sgsave" title="${sg?'Save':'Make signal'}" aria-label="${sg?'Save':'Make signal'}">${sg?ICON.check:ICON.plus}</button></div>`);
wireChips($('#sgkw'));
let icon=sg?.icon??null;
const drawPick=()=>{ $('#sgicon').innerHTML=signalTile({name:$<HTMLInputElement>('#sgname').value||'?',icon}); };
drawPick();
$<HTMLInputElement>('#sgname').oninput=drawPick;
const grid=$('#sgicons');
const drawGrid=async()=>{
const all=await iconList();
const shown=iconMatches(all,$<HTMLInputElement>('#sgiconq').value,ICON_CELLS);
grid.innerHTML=shown.length ? shown.map(([id,label])=>`<button type="button" class="iconcell" role="option" data-ic="${id}"
aria-selected="${id===icon}" title="${esc(label)}" aria-label="${esc(label)}">${iconHTML(id)}</button>`).join('')
: '<p class="hint">No icon matches that.</p>';
};
$<HTMLButtonElement>('#sgicon').onclick=()=>{
const p=$('#sgpicker'), open=p.hidden;
p.hidden=!open; $('#sgicon').setAttribute('aria-expanded',String(open));
if(open){ drawGrid().catch(e=>toast(why(e),true)); $<HTMLInputElement>('#sgiconq').focus(); }
};
$<HTMLInputElement>('#sgiconq').oninput=()=>{ drawGrid().catch(()=>{}); };
grid.onclick=(e: MouseEvent)=>{
const b=(e.target as Element).closest<HTMLElement>('[data-ic]'); if(!b) return;
icon=b.dataset.ic!; drawPick(); $('#sgpicker').hidden=true; $('#sgicon').setAttribute('aria-expanded','false');
};
$<HTMLButtonElement>('#sgnoicon').onclick=()=>{ icon=null; drawPick(); $('#sgpicker').hidden=true; };
$<HTMLInputElement>('#sgname').focus();
$<HTMLButtonElement>('#sgsave').onclick=async()=>{
const name=$<HTMLInputElement>('#sgname').value.trim(), keywords=chipWords($('#sgkw'));
const name=$<HTMLInputElement>('#sgname').value.trim(), keywords=chipWords($('#sgkw')).filter((w): w is string=>!!w);
if(!name){ $<HTMLInputElement>('#sgname').focus(); return; }
if(!keywords.length){ $<HTMLInputElement>('#sgkwin').focus(); return; }
const body=JSON.stringify({name,keywords,description:$<HTMLInputElement>('#sgdesc').value.trim()||null});
const description=$<HTMLInputElement>('#sgdesc').value.trim()||null;
// Only what changed: a new name, words or sentence has Jev read everything again, and saving
// a new icon alone did that too while the dialog sent them all back each time.
const body=JSON.stringify(sg ? signalChanges(sg,{name,keywords,description,icon}) : {name,keywords,description,icon});
try{
const r=sg ? await api(`/api/signals/${sg.id}`,{method:'PATCH',body})
: await api('/api/signals',{method:'POST',body});
@@ -153,3 +195,38 @@ function signalModal(sg?: Signal){
}catch(e){ toast(why(e),true); }
};
}
/// What differs between a Signal and the dialog's fields, as PATCH takes it.
function signalChanges(sg: Signal, now: {name: string, keywords: string[], description: string|null, icon: string|null}){
const out: Record<string, unknown>={};
if(now.name!==sg.name) out.name=now.name;
if(now.keywords.join('\n')!==sg.keywords.join('\n')) out.keywords=now.keywords;
if(now.description!==sg.description) out.description=now.description;
if(now.icon!==sg.icon) out.icon=now.icon;
return out;
}
/// Font Awesome Free's index, `[style/name, label, terms]`, fetched once, when the picker first
/// opens: 2,883 icons are about 90 KB compressed, which the page has no other use for.
const ICON_CELLS=120;
let icons: Promise<[string,string,string][]>|null=null;
function iconList(){
if(!icons) icons=api('__ICONS__').catch((e: unknown)=>{ icons=null; throw e; });
return icons!;
}
/// The icons with a word starting with each word typed, in their name, label or search terms. The
/// first word typed ranks them: the icon named it, then one with it as a word of its name, then as
/// a search term, then the rest. Matching anywhere in a word, "car" filled the grid with caret and
/// cart (#261). At most `max`; nothing typed is the first `max`.
function iconMatches(all: [string,string,string][], q: string, max: number){
const words=q.toLowerCase().split(/\s+/).filter(Boolean);
if(!words.length) return all.slice(0,max);
const split=(s: string)=>s.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
const rank=([id,label,terms]: [string,string,string])=>{
const name=id.slice(id.indexOf('/')+1), own=split(name), hay=[...own,...split(label),...split(terms)];
if(!words.every(w=>hay.some(h=>h.startsWith(w)))) return -1;
const w=words[0];
return name===w ? 0 : own.includes(w) ? 1 : hay.includes(w) ? 2 : 3;
};
return all.map(i=>[rank(i),i] as const).filter(([r])=>r>=0).sort((a,b)=>a[0]-b[0]).slice(0,max).map(([,i])=>i);
}