← back to Tools Dw Hub
public/index.html
260 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Designer Wallcoverings · Tools</title>
<style>
:root{--cols:5;--fs:1;--bg:#0c0d12;--panel:#15171f;--card:#12141b;--ink:#eef0f5;--mut:#9aa0b0;--acc:#5eead4;--line:#262a36;
--up:#22c55e;--down:#ef4444;--na:#6b7280}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:13px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
header{position:sticky;top:0;z-index:30;background:#0e0f15;border-bottom:1px solid var(--line);padding:10px 16px;
display:flex;gap:12px;align-items:center;flex-wrap:wrap}
header h1{font-size:14px;font-weight:300;letter-spacing:.14em;text-transform:uppercase;margin:0}
header h1 b{font-weight:600}
.sub{color:var(--mut);font-size:11px;margin-left:2px}
header label{font-size:11px;color:var(--mut);text-transform:uppercase;letter-spacing:.06em;display:flex;align-items:center;gap:6px}
select,input[type=text]{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:6px 9px;font-size:13px}
input[type=range]{accent-color:var(--acc)}
.btn{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:6px 12px;cursor:pointer;font-size:12px;text-decoration:none}
.btn:hover{border-color:var(--acc)}
.count{color:var(--mut);font-size:12px;margin-left:auto}
#burger{display:none}
#layout{display:flex;align-items:flex-start}
aside{position:sticky;top:52px;width:236px;min-width:236px;max-height:calc(100vh - 52px);overflow:auto;
padding:10px 10px 28px;border-right:1px solid var(--line);background:#0e0f15}
aside input[type=text]{width:100%;margin-bottom:7px;font-size:12.5px;padding:5px 8px}
#fclose{display:none}
aside details{border-bottom:1px solid var(--line)}
aside summary{cursor:pointer;list-style:none;display:flex;align-items:center;gap:6px;padding:8px 4px;
font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut)}
aside summary::-webkit-details-marker{display:none}
aside summary::before{content:'▸';font-size:9px;color:var(--mut);transition:transform .12s}
aside details[open] summary::before{transform:rotate(90deg)}
aside summary:hover{color:var(--ink)}
aside summary .cur{margin-left:auto;color:var(--acc);text-transform:none;letter-spacing:0;font-weight:600;font-size:11px;
max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fbody{padding:2px 0 8px}
.frow{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:3px 8px;border-radius:6px;cursor:pointer;color:var(--mut)}
.frow:hover{background:var(--panel);color:var(--ink)}
.frow.active{background:var(--panel);color:var(--ink);outline:1px solid var(--acc);outline-offset:-1px}
.frow .fl{display:inline-flex;align-items:center;gap:7px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.frow .n{font-variant-numeric:tabular-nums;font-size:11px;color:var(--mut);flex:none}
.frow .dot{width:9px;height:9px;border-radius:50%;flex:none}
#clearf{width:100%;margin-top:10px;display:none}
main{flex:1;min-width:0;padding:12px 16px 60px}
.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:8px}
.card{background:var(--card);border:1px solid var(--line);border-radius:2px;padding:calc(11px*var(--fs));
display:flex;flex-direction:column;gap:calc(7px*var(--fs));position:relative;cursor:pointer}
.card.launchable:hover{border-color:var(--acc)}
.ct{display:flex;align-items:flex-start;justify-content:space-between;gap:8px}
.name{font-weight:600;font-size:calc(13px*var(--fs));line-height:1.25}
.dot{width:9px;height:9px;border-radius:50%;flex:0 0 auto;margin-top:3px}
.dot.up{background:var(--up);box-shadow:0 0 0 3px rgba(34,197,94,.15)}
.dot.down{background:var(--down)}
.dot.na{background:var(--na)}
.desc{font-size:calc(11.5px*var(--fs));color:var(--mut);line-height:1.45;flex:1}
body.imgonly .desc{display:none}
.meta{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:calc(10px*var(--fs));color:var(--mut)}
.cat{text-transform:uppercase;letter-spacing:.07em;color:var(--acc);font-weight:600}
.badge{border:1px solid var(--line);border-radius:999px;padding:1px 7px}
.actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:2px}
.act{font-size:calc(11px*var(--fs));font-weight:600;border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:7px;padding:4px 10px;cursor:pointer;text-decoration:none}
.act:hover{border-color:var(--acc)}
.act.go{background:var(--ink);color:#0c0d12;border-color:var(--ink)}
.act.go.off{opacity:.4;pointer-events:none}
.act.start{border-color:var(--up);color:#6ee7b7}
.act.stop{border-color:var(--down);color:#fca5a5}
.act:disabled{opacity:.5;cursor:wait}
#empty{display:none;color:var(--mut);padding:40px;text-align:center}
.foot{color:var(--mut);font-size:11px;padding:18px 4px 0}
#toast{position:fixed;left:50%;bottom:18px;transform:translateX(-50%) translateY(20px);max-width:min(560px,92vw);
background:#1b1e24;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:10px 16px;font-size:13px;
box-shadow:0 8px 30px #0008;opacity:0;pointer-events:none;transition:.25s;z-index:60}
#toast.on{opacity:1;transform:translateX(-50%) translateY(0)}
@media(max-width:760px){
#burger{display:inline-block}
#layout{display:block}
aside{display:none;position:fixed;inset:0;top:0!important;z-index:40;width:auto;min-width:0;max-height:none!important;border-right:0;background:var(--bg)}
body.filters aside{display:block}
#fclose{display:block;position:sticky;top:0;width:100%;background:var(--acc);color:#062a24;border:none;border-radius:8px;padding:11px;font-weight:700;cursor:pointer;margin-bottom:10px}
}
</style>
<link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
</head>
<body>
<header>
<button id="burger" class="btn">☰ Filters</button>
<h1>Designer <b>Wallcoverings</b> · Tools</h1>
<span class="sub" id="sub"></span>
<label>Sort
<select id="sort">
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="name">Name A→Z</option>
<option value="status">Status (up first)</option>
</select>
</label>
<label>Density<input type="range" id="density" min="3" max="8" step="1" value="5"></label>
<label><input type="checkbox" id="imgonly"> Compact</label>
<span class="count" id="count"></span>
</header>
<div id="layout">
<aside aria-label="filters">
<button id="fclose" type="button">✕ Done — show tools</button>
<input type="text" id="q" placeholder="Search tools…">
<div id="facetbox"><!-- one collapsed <details> table per field --></div>
<button class="btn" id="clearf">✕ Clear filters</button>
</aside>
<main>
<div class="grid" id="grid"></div>
<div id="empty">No tools match.</div>
<div class="foot" id="foot"></div>
</main>
</div>
<div id="toast"></div>
<script>
const $=s=>document.querySelector(s);
const O=location.origin;
let TOOLS=[], STATUS={}, TOOLHOST='';
const esc=s=>String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const LS={get:(k,d)=>{try{return JSON.parse(localStorage.getItem('dwtools_'+k))??d}catch{return d}},set:(k,v)=>localStorage.setItem('dwtools_'+k,JSON.stringify(v))};
// facet fields — each a collapsed table in the left panel
const FIELDS=[['category','Category'],['status','Status'],['type','Type']];
const FIL={category:'',status:'',type:''};
// ── href-drill: FIL/search state is URL-addressable (shareable + deep-link + back-button) ──
const escU=s=>encodeURIComponent(String(s==null?'':s));
function qstr(over){ const f=Object.assign({},FIL,over||{}); const u=[]; for(const [k] of FIELDS) if(f[k]) u.push(k+'='+escU(f[k])); const q=($('#q')&&$('#q').value.trim())||''; if(q)u.push('q='+escU(q)); return u.length?('?'+u.join('&')):location.pathname; }
function drill(field,val,label){ if(val==null||val===''||val==='—')return esc(label!=null?label:val); return `<a class="drill" data-filter="${field}" data-v="${esc(val)}" href="${esc(qstr({[field]:String(val)}))}" title="Filter to ${esc(val)}">${esc(label!=null?label:val)}</a>`; }
function readURL(){ const u=new URLSearchParams(location.search); for(const [k] of FIELDS) FIL[k]=u.get(k)||''; if($('#q'))$('#q').value=u.get('q')||''; }
function writeURL(push){ history[push?'pushState':'replaceState']({},'',qstr()); }
const typeOf=t=>t.missingSource?'Missing source':t.cli?'CLI':t.static?'Static':t.onDemand?'On-demand':'Web app';
const statusOf=t=>{const s=STATUS[t.slug];return s==='up'?'Up':s==='down'?'Down':'Idle';};
const valOf=(t,k)=>k==='status'?statusOf(t):k==='type'?typeOf(t):(t[k]||'—');
function counts(k){
const m={};
for(const t of TOOLS){const v=valOf(t,k); m[v]=(m[v]||0)+1;}
return Object.entries(m).sort((a,b)=>b[1]-a[1]);
}
function fmtDate(iso){try{return new Date(iso+'T12:00:00').toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'})}catch{return iso}}
const rank=s=>s==='up'?0:s==='down'?1:2;
const SDOT={Up:'var(--up)',Down:'var(--down)',Idle:'var(--na)'};
function renderSide(){
const openNow=new Set([...document.querySelectorAll('#facetbox details[open]')].map(d=>d.dataset.f));
$('#facetbox').innerHTML=FIELDS.map(([k,label])=>{
const rows=counts(k).map(([v,n])=>{
const dot=k==='status'?`<span class="dot" style="background:${SDOT[v]||'var(--na)'}"></span>`:'';
return `<div class="frow${FIL[k]===String(v)?' active':''}" data-k="${k}" data-v="${esc(v)}">
<span class="fl">${dot}${esc(v)}</span><span class="n">${n}</span></div>`;
}).join('');
const cur=FIL[k]?`<span class="cur" title="${esc(FIL[k])}">${esc(FIL[k])}</span>`:'';
return `<details data-f="${k}"${openNow.has(k)?' open':''}><summary>${label}${cur}</summary><div class="fbody">${rows}</div></details>`;
}).join('');
$('#clearf').style.display=(Object.values(FIL).some(Boolean)||$('#q').value)?'block':'none';
}
function render(){
const q=$('#q').value.trim().toLowerCase();
const sort=$('#sort').value;
let list=TOOLS.filter(t=>
(!FIL.category||valOf(t,'category')===FIL.category)&&
(!FIL.status||valOf(t,'status')===FIL.status)&&
(!FIL.type||valOf(t,'type')===FIL.type)&&
(!q||(t.name+' '+t.desc+' '+t.category).toLowerCase().includes(q)));
list.sort((a,b)=>{
if(sort==='newest')return(b.created||'').localeCompare(a.created||'');
if(sort==='oldest')return(a.created||'').localeCompare(b.created||'');
if(sort==='name')return a.name.localeCompare(b.name);
if(sort==='status')return rank(STATUS[a.slug])-rank(STATUS[b.slug])||a.name.localeCompare(b.name);
return 0;});
$('#count').textContent=list.length+' / '+TOOLS.length;
$('#empty').style.display=list.length?'none':'block';
$('#grid').innerHTML=list.map(t=>{
// static/href tools are served BY this hub (same origin, behind the same auth) → always "up"
const st=t.href?'up':(STATUS[t.slug]||'na'), cls=st==='up'?'up':st==='down'?'down':'na';
const host=TOOLHOST||location.hostname;
const url=t.href?(O+t.href):(t.port?`http://${host}:${t.port}/`:null);
const launchable=st==='up'&&url;
const go=url?`<a class="act go ${st==='up'?'':'off'}" href="${url}" target="_blank" rel="noopener noreferrer">Launch ↗</a>`:'';
// Start/Stop only on the LOCAL Mac hub (TOOLHOST==='') — public site stays launch-only (no RCE surface)
const sb=(!TOOLHOST&&!t.cli&&!t.missingSource&&t.port)?(st==='up'
?`<button class="act stop" data-verb="stop" data-slug="${t.slug}">Stop ■</button>`
:`<button class="act start" data-verb="start" data-slug="${t.slug}">Start ▶</button>`):'';
const badges=[t.onDemand?'💤 on-demand':'',t.cli?'⌨️ CLI':'',t.static?'📄 static':'',t.missingSource?'⚠️ missing source':'']
.filter(Boolean).map(b=>`<span class="badge">${b}</span>`).join('');
return `<div class="card${launchable?' launchable':''}" ${launchable?`data-href="${url}"`:''}>
<div class="ct"><span class="name">${esc(t.name)}</span><span class="dot ${cls}" title="${st}"></span></div>
<div class="desc">${esc(t.desc)}</div>
<div class="meta"><span class="cat">${drill('category',t.category)}</span><span title="${t.created}">🕓 ${fmtDate(t.created)}</span>${t.port?`<span>:${t.port}</span>`:''}${badges}</div>
<div class="actions">${go}${sb}<button class="act copy" data-cmd="${esc(t.start||'')}">Copy start ⌘</button></div>
</div>`;
}).join('');
}
// ONE delegated click on the grid (never wrap cards in <a> — the checkerboard bug)
$('#grid').addEventListener('click',async e=>{
const btn=e.target.closest('button,a'); const card=e.target.closest('.card');
if(btn){
if(btn.classList.contains('copy')){navigator.clipboard.writeText(btn.dataset.cmd);toast('Start command copied');return;}
if(btn.dataset.verb){await toolAction(btn);return;}
return; // Launch anchor handles itself
}
if(card&&card.dataset.href) window.open(card.dataset.href,'_blank','noopener');
});
async function toolAction(btn){
const {verb,slug}=btn.dataset;
btn.disabled=true; const o=btn.textContent; btn.textContent=verb==='start'?'Starting…':'Stopping…';
try{const r=await(await fetch(`${O}/api/${verb}/${slug}`,{method:'POST'})).json();
if(!r.ok)toast(`${verb} failed: ${r.error||'unknown'}`);}
catch(e){toast(`${verb} failed: ${e.message}`);}
for(let i=0;i<8;i++){await new Promise(r=>setTimeout(r,3000));await loadStatus();
if((STATUS[slug]==='up')===(verb==='start'))break;}
btn.textContent=o;
}
function toast(m){const el=$('#toast');el.textContent=m;el.classList.add('on');clearTimeout(el._t);el._t=setTimeout(()=>el.classList.remove('on'),2600);}
// facet click (delegated)
$('#facetbox').addEventListener('click',e=>{const row=e.target.closest('.frow');if(!row)return;
const{k,v}=row.dataset; FIL[k]=FIL[k]===v?'':v; writeURL(true); renderSide(); render();});
$('#clearf').onclick=()=>{FIELDS.forEach(([k])=>FIL[k]='');$('#q').value='';writeURL(true);renderSide();render();};
// a card's category is an href-drill atom → filter in place + reflect into the URL (cmd/ctrl-click opens the shareable href)
document.addEventListener('click',e=>{ const d=e.target.closest('a.drill[data-filter]'); if(!d)return; if(e.metaKey||e.ctrlKey||e.shiftKey||e.button===1)return; e.preventDefault(); e.stopPropagation();
const k=d.dataset.filter,v=d.dataset.v; FIL[k]=(FIL[k]===v?'':v); writeURL(true); renderSide(); render(); });
addEventListener('popstate',()=>{ readURL(); renderSide(); render(); }); // back/forward restores filter state
async function loadStatus(){try{STATUS=await(await fetch(O+'/api/status')).json();renderSide();render();}catch{}}
async function init(){
const m=await(await fetch(O+'/api/tools')).json();
TOOLS=m.tools; TOOLHOST=m.hub.toolHost||''; $('#sub').textContent=m.hub.subtitle||'';
// prefs
$('#sort').value=LS.get('sort','newest');
const dens=LS.get('density',5); $('#density').value=dens; document.documentElement.style.setProperty('--cols',dens); setFs(dens);
if(LS.get('imgonly',false)){$('#imgonly').checked=true;document.body.classList.add('imgonly');}
$('#q').oninput=()=>{writeURL(false);renderSide();render();};
$('#sort').onchange=()=>{LS.set('sort',$('#sort').value);render();};
$('#density').oninput=()=>{const v=+$('#density').value;document.documentElement.style.setProperty('--cols',v);setFs(v);LS.set('density',v);};
$('#imgonly').onchange=()=>{document.body.classList.toggle('imgonly',$('#imgonly').checked);LS.set('imgonly',$('#imgonly').checked);};
$('#burger').onclick=()=>document.body.classList.add('filters');
$('#fclose').onclick=()=>document.body.classList.remove('filters');
readURL(); renderSide(); render(); await loadStatus(); setInterval(loadStatus,20000); // land in URL's filter state
$('#foot').textContent='Live status refreshes every 20s. Green = running (marker-verified over tailnet) — click a card or Launch to open it. Red = stopped; use Start.';
}
// density → type scale (fewer/larger cards → bigger type)
function setFs(cols){const fs=Math.max(.7,Math.min(1.25,cols<=5?1+(5-cols)*.06:1-(cols-5)*.05));document.documentElement.style.setProperty('--fs',fs.toFixed(3));}
init();
</script>
<script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
</body>
</html>