← 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>