← back to Fix Live Board

public/board.html

224 lines

<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Fix Live Board</title><style>
/* ---- light base (day) ---- */
:root{
  --bg:#f4f5f8;--card:#ffffff;--line:#e2e5ee;--txt:#1b1e28;--mut:#5c6274;
  --ok:#1f9d57;--bad:#d63d4c;--warn:#c98a12;--accent:#4a5bd6;--head:#ffffffee;--shadow:0 1px 3px #0000001a;
}
/* ---- dark (night): system default unless toggled to light ---- */
@media (prefers-color-scheme: dark){:root:not([data-theme="light"]){
  --bg:#0e0f13;--card:#171922;--line:#252838;--txt:#e7e9f0;--mut:#8b90a6;
  --ok:#25c26e;--bad:#ff5c6c;--warn:#f2b134;--accent:#7c8cff;--head:#0e0f13ee;--shadow:0 1px 3px #0000;
}}
:root[data-theme="dark"]{
  --bg:#0e0f13;--card:#171922;--line:#252838;--txt:#e7e9f0;--mut:#8b90a6;
  --ok:#25c26e;--bad:#ff5c6c;--warn:#f2b134;--accent:#7c8cff;--head:#0e0f13ee;--shadow:0 1px 3px #0000;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--txt);font:14px/1.4 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}
header{position:sticky;top:0;background:var(--head);backdrop-filter:blur(6px);padding:12px 18px;border-bottom:1px solid var(--line);z-index:20}
h1{margin:0;font-size:16px;letter-spacing:.3px;font-weight:600;display:flex;align-items:center;gap:8px}
.sub{color:var(--mut);font-size:12px;margin-top:3px}
/* green->red usage meter: fill width = fixed%, hue = green(fixed) -> red(backlog) */
.meter{height:9px;background:var(--line);border-radius:9px;margin-top:9px;overflow:hidden;position:relative}
.meter>i{display:block;height:100%;width:0;border-radius:9px;transition:width .5s,background .5s}
.controls{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:10px}
.controls label{color:var(--mut);font-size:11px;margin-right:4px}
select,input[type=range],button.tg{background:var(--card);color:var(--txt);border:1px solid var(--line);border-radius:7px;padding:5px 8px;font-size:12px}
button.tg{cursor:pointer}button.tg.on{border-color:var(--accent);color:var(--accent)}
.seg{display:inline-flex;border:1px solid var(--line);border-radius:7px;overflow:hidden}
.seg button{background:var(--card);color:var(--mut);border:0;padding:5px 10px;font-size:12px;cursor:pointer}
.seg button.on{background:var(--accent);color:#fff}
/* grid */
.grid{display:grid;grid-template-columns:repeat(var(--cols,3),1fr);gap:12px;padding:16px 18px}
.ghead{grid-column:1/-1;color:var(--mut);font-size:12px;font-weight:600;padding:6px 2px;border-bottom:1px solid var(--line);margin-top:6px}
/* hootsuite-style columns */
.streams{display:flex;gap:14px;padding:16px 18px;overflow-x:auto;align-items:flex-start}
.stream{flex:0 0 300px;background:color-mix(in srgb,var(--card) 60%,transparent);border:1px solid var(--line);border-radius:12px;max-height:calc(100vh - 190px);display:flex;flex-direction:column}
.stream>h3{margin:0;padding:10px 12px;font-size:12.5px;font-weight:600;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;position:sticky;top:0;background:var(--card);border-radius:12px 12px 0 0;z-index:1}
.stream>h3 .cnt{color:var(--mut);font-weight:500}
.stream>.col-body{overflow-y:auto;padding:10px;display:flex;flex-direction:column;gap:10px}
/* card */
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:var(--shadow);cursor:pointer;transition:border-color .15s,transform .05s}
.card:hover{border-color:var(--accent)}.card:active{transform:scale(.997)}
.card.fixed{border-color:color-mix(in srgb,var(--ok) 55%,var(--line))}
.thumb{height:120px;background:#0000000d center/cover no-repeat;border-bottom:1px solid var(--line)}
.body{padding:10px 11px;display:flex;flex-direction:column;gap:7px}
.ttl{font-size:12.5px;font-weight:600;line-height:1.25;max-height:2.5em;overflow:hidden}
.meta{display:flex;justify-content:space-between;color:var(--mut);font-size:11px}
.price{color:var(--txt);font-weight:600}
.chips{display:flex;flex-wrap:wrap;gap:5px}
.chip{font-size:10.5px;padding:2px 7px;border-radius:999px;border:1px solid var(--line);display:inline-flex;gap:4px;align-items:center}
.chip.ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 45%,var(--line))}
.chip.bad{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,var(--line))}
.chip.warn{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 45%,var(--line))}
.when{color:var(--mut);font-size:10.5px}
.pill{font-size:10px;padding:1px 7px;border-radius:6px;border:1px solid var(--line)}
.dot{width:7px;height:7px;border-radius:50%;display:inline-block;margin-right:4px}
.fixers{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}
.fx{font-size:12px;padding:5px 10px;border-radius:8px;border:1px solid var(--line);background:var(--card);color:var(--txt);cursor:pointer}
.fx.gated{color:var(--mut);cursor:not-allowed;border-style:dashed}
.err{color:var(--bad);padding:12px 18px}
/* detail drawer */
.scrim{position:fixed;inset:0;background:#0006;z-index:40;opacity:0;pointer-events:none;transition:opacity .2s}
.scrim.open{opacity:1;pointer-events:auto}
.drawer{position:fixed;top:0;right:0;height:100%;width:min(440px,92vw);background:var(--card);border-left:1px solid var(--line);z-index:41;transform:translateX(100%);transition:transform .22s;overflow-y:auto;box-shadow:-8px 0 30px #0003}
.drawer.open{transform:translateX(0)}
.drawer .dh{position:sticky;top:0;background:var(--card);padding:12px 16px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;gap:10px}
.drawer .dh b{font-size:14px}.drawer .x{cursor:pointer;color:var(--mut);font-size:20px;line-height:1;border:0;background:none}
.drawer .dimg{width:100%;height:200px;background:#0000000d center/cover no-repeat}
.drawer .dbody{padding:14px 16px;display:flex;flex-direction:column;gap:12px}
.kv{display:grid;grid-template-columns:110px 1fr;gap:4px 10px;font-size:12.5px}
.kv .k{color:var(--mut)}
.fieldrow{display:flex;justify-content:space-between;padding:6px 0;border-bottom:1px solid var(--line);font-size:12.5px}
.alink{color:var(--accent);text-decoration:none;font-size:12px}
/* movable / dockable nav (header) */
.grip{cursor:move;color:var(--mut);user-select:none;font-size:13px;padding:0 6px;letter-spacing:-1px}
header.floating{position:fixed;width:auto;max-width:94vw;border:1px solid var(--line);border-radius:12px;box-shadow:0 10px 34px #0004;z-index:30}
header.dock-left{position:fixed;left:0;top:0;height:100vh;width:250px;overflow-y:auto;border-bottom:0;border-right:1px solid var(--line);z-index:30}
header.dock-left .controls{flex-direction:column;align-items:flex-start;gap:10px}
header.dock-left .controls span,header.dock-left .seg,header.dock-left select{width:100%}
header.dock-left .meter{width:100%}
body.nav-left .grid,body.nav-left .streams,body.nav-left .err{margin-left:266px}
</style></head><body>
<header>
  <h1><span id="h1">🛠️ Fix Live Board</span></h1>
  <div class="sub" id="sub">loading…</div>
  <div class="meter"><i id="meter"></i></div>
  <div class="controls">
    <span><label>Sort</label>
      <select id="sort">
        <option value="broken">Broken first</option><option value="fixed">Fixed first</option>
        <option value="id">ID</option><option value="price">Price ↓</option><option value="created">Newest</option>
      </select></span>
    <span><label>Group</label>
      <select id="group"><option value="none">None</option><option value="state">State</option><option value="status">Status</option><option value="vendor">Vendor</option></select></span>
    <span><label>View</label>
      <span class="seg"><button id="vGrid" data-v="grid">▦ Grid</button><button id="vCols" data-v="columns">▤ Columns</button></span></span>
    <span id="densWrap"><label>Density</label> <input type="range" id="dens" min="2" max="6" value="3"></span>
    <button class="tg" id="theme">🌙 Night</button>
    <button class="tg" id="dock">⇤ Dock left</button>
    <span class="grip" id="grip" title="Drag to move (up/down / anywhere)">⠿ drag</span>
    <span class="sub" id="upd"></span>
  </div>
  <div class="fixers" id="fixers"></div>
</header>
<div class="grid" id="grid"></div>
<div class="streams" id="streams" hidden></div>
<div class="err" id="err" style="display:none"></div>
<div class="scrim" id="scrim"></div>
<aside class="drawer" id="drawer" aria-hidden="true"></aside>
<script>
// creds-in-URL fetch guard: relative fetch() breaks when the page is opened with user:pass@host
// (poisoned baseURI). Resolve relative URLs against a credential-free base instead.
(function(){var _f=window.fetch,BASE=location.origin+location.pathname;
 window.fetch=function(u,o){try{if(typeof u==='string'&&!/^[a-z]+:\/\//i.test(u))u=new URL(u,BASE).href;}catch(e){}return _f.call(this,u,o);};})();
let META={fields:[],fixers:[],name:'Fix Live Board'}, DATA=[], SEL=null;
const KEY='flb_'+location.port;
const $=id=>document.getElementById(id);
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function get(k,d){try{const v=localStorage.getItem(KEY+'_'+k);return v==null?d:v}catch(e){return d}}
function set(k,v){try{localStorage.setItem(KEY+'_'+k,v)}catch(e){}}
function vendorOf(r){const m=String(r.title||'').match(/^\[([^\]]*)\]/);return m?m[1]:'—';}
function fmtWhen(iso){if(!iso)return '';try{return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})}catch(e){return iso}}
function chip(f,val){var ok=!!val;var c=ok?'ok':(f.warn?'warn':'bad');var t=ok?(f.ok||f.key):(f.bad||('no '+f.key));return '<span class="chip '+c+'"><span class="dot" style="background:currentColor"></span>'+esc(t)+'</span>';}
function groupKey(r){const g=$('group').value;if(g==='state')return r.fixed?'Fixed':'To follow';if(g==='status')return r.status||'—';if(g==='vendor')return vendorOf(r);return '';}
function sorted(){const s=$('sort').value;let d=DATA.slice();const rank=r=>r.fixed?1:0;
  if(s==='broken')d.sort((a,b)=>rank(a)-rank(b)||String(a.id).localeCompare(String(b.id)));
  else if(s==='fixed')d.sort((a,b)=>rank(b)-rank(a)||String(a.id).localeCompare(String(b.id)));
  else if(s==='id')d.sort((a,b)=>String(a.id).localeCompare(String(b.id)));
  else if(s==='price')d.sort((a,b)=>(b.price||0)-(a.price||0));
  else if(s==='created')d.sort((a,b)=>new Date(b.created||0)-new Date(a.created||0));
  return d;}
function cardHTML(r){const fields=(r.fields||{});const chips=META.fields.map(f=>chip(f,fields[f.key])).join('');
  return '<div class="card '+(r.fixed?'fixed':'')+'" data-id="'+esc(r.id)+'">'
    +(r.img?'<div class="thumb" style="background-image:url(\''+esc(r.img)+'\')"></div>':'')
    +'<div class="body"><div class="ttl" title="'+esc(r.title||r.id)+'">'+esc(r.title||r.id)+'</div>'
    +'<div class="meta"><span>'+esc(r.sku||r.id)+'</span><span class="price">'+(r.price>0?('$'+Number(r.price).toFixed(2)):'—')+'</span></div>'
    +'<div class="chips">'+(r.status?'<span class="pill">'+esc(r.status)+'</span>':'')+(r.note?'<span class="pill" style="border-color:var(--warn);color:var(--warn)">'+esc(r.note)+'</span>':'')+chips+'</div>'
    +(r.created?'<div class="when">🕓 '+fmtWhen(r.created)+'</div>':'')+'</div></div>';}
function render(){
  const d=sorted(), g=$('group').value, view=$('view')&&0, cols=($('vCols').classList.contains('on'))?'columns':'grid';
  const grid=$('grid'), streams=$('streams');
  if(cols==='columns'){
    grid.hidden=true;streams.hidden=false;$('densWrap').style.display='none';
    // build groups (default group by state if none chosen, so columns are meaningful like Hootsuite streams)
    const gb=(g==='none')?'state':g; const buckets={};
    d.forEach(r=>{const k=(gb==='state')?(r.fixed?'Fixed':'To follow'):(gb==='status'?(r.status||'—'):vendorOf(r));(buckets[k]=buckets[k]||[]).push(r);});
    const order=Object.keys(buckets).sort((a,b)=>{if(gb==='state')return a==='To follow'?-1:1;return buckets[b].length-buckets[a].length;});
    streams.innerHTML=order.map(k=>'<div class="stream"><h3>'+esc(k)+' <span class="cnt">'+buckets[k].length+'</span></h3><div class="col-body">'+buckets[k].map(cardHTML).join('')+'</div></div>').join('');
  }else{
    streams.hidden=true;grid.hidden=false;$('densWrap').style.display='';
    if(g==='none'){grid.innerHTML=d.map(cardHTML).join('');}
    else{const buckets={};d.forEach(r=>{const k=groupKey(r);(buckets[k]=buckets[k]||[]).push(r);});
      grid.innerHTML=Object.keys(buckets).map(k=>'<div class="ghead">'+esc(k)+' · '+buckets[k].length+'</div>'+buckets[k].map(cardHTML).join('')).join('');}
  }
  bindCards();
  const fixed=DATA.filter(r=>r.fixed).length,total=DATA.length,frac=total?fixed/total:1;
  $('sub').textContent=total+' items · '+fixed+' fixed · '+(total-fixed)+' to follow';
  // usage meter: fill = fixed%, hue green(120,all fixed) -> red(0,all backlog "used")
  const hue=Math.round(120*frac);$('meter').style.width=(frac*100)+'%';$('meter').style.background='hsl('+hue+',70%,45%)';
}
function bindCards(){document.querySelectorAll('.card').forEach(c=>c.addEventListener('click',()=>openDrawer(c.dataset.id)));}
function openDrawer(id){SEL=id;const r=DATA.find(x=>String(x.id)===String(id));if(!r)return;
  const fields=(r.fields||{});
  const rows=META.fields.map(f=>{const ok=!!fields[f.key];return '<div class="fieldrow"><span>'+esc(f.key)+'</span><span style="color:var(--'+(ok?'ok':(f.warn?'warn':'bad'))+')">'+(ok?(f.ok||'ok'):(f.bad||'missing'))+'</span></div>';}).join('');
  const admin='https://admin.shopify.com/store/designer-laboratory-sandbox/products?query='+encodeURIComponent(r.handle||r.sku||'');
  $('drawer').innerHTML='<div class="dh"><b>'+esc(r.title||r.id)+'</b><button class="x" onclick="closeDrawer()">×</button></div>'
    +(r.img?'<div class="dimg" style="background-image:url(\''+esc(r.img)+'\')"></div>':'')
    +'<div class="dbody">'
    +'<div class="kv"><span class="k">SKU</span><span>'+esc(r.sku||'—')+'</span>'
    +'<span class="k">Vendor</span><span>'+esc(vendorOf(r))+'</span>'
    +'<span class="k">Status</span><span>'+esc(r.status||'—')+(r.note?' · '+esc(r.note):'')+'</span>'
    +'<span class="k">Price</span><span>'+(r.price>0?('$'+Number(r.price).toFixed(2)):'—')+'</span>'
    +'<span class="k">Fixed</span><span style="color:var(--'+(r.fixed?'ok':'bad')+')">'+(r.fixed?'yes':'no — to follow')+'</span>'
    +'<span class="k">Created</span><span>'+esc(fmtWhen(r.created))+'</span>'
    +'<span class="k">Handle</span><span>'+esc(r.handle||'—')+'</span></div>'
    +'<div><div class="sub" style="margin-bottom:4px">Fields</div>'+rows+'</div>'
    +'<a class="alink" href="'+admin+'" target="_blank" rel="noopener noreferrer">Open in Shopify admin ↗</a>'
    +'</div>';
  $('drawer').classList.add('open');$('drawer').setAttribute('aria-hidden','false');$('scrim').classList.add('open');}
function closeDrawer(){SEL=null;$('drawer').classList.remove('open');$('drawer').setAttribute('aria-hidden','true');$('scrim').classList.remove('open');}
$('scrim').addEventListener('click',closeDrawer);
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeDrawer();});
// view toggle
function setView(v){$('vGrid').classList.toggle('on',v==='grid');$('vCols').classList.toggle('on',v==='columns');set('view',v);render();}
$('vGrid').addEventListener('click',()=>setView('grid'));$('vCols').addEventListener('click',()=>setView('columns'));
// controls persistence
['sort','group'].forEach(k=>$(k).addEventListener('change',()=>{set(k,$(k).value);render();}));
function setCols(v){document.documentElement.style.setProperty('--cols',v);set('cols',v);}
$('dens').addEventListener('input',e=>setCols(e.target.value));
// theme (day/night) toggle
function applyTheme(t){document.documentElement.setAttribute('data-theme',t);$('theme').textContent=t==='dark'?'☀️ Day':'🌙 Night';set('theme',t);}
$('theme').addEventListener('click',()=>applyTheme(document.documentElement.getAttribute('data-theme')==='dark'?'light':'dark'));
// movable / dockable nav (header): drag up/down/anywhere via grip, or dock to a left panel
const hdr=document.querySelector('header');
function applyDock(mode){hdr.classList.remove('floating','dock-left');document.body.classList.remove('nav-left');
  if(mode==='left'){hdr.classList.add('dock-left');document.body.classList.add('nav-left');hdr.style.left='';hdr.style.top='';}
  else if(mode==='float'){hdr.classList.add('floating');let p={x:20,y:20};try{p=JSON.parse(get('navpos','{"x":20,"y":20}'))}catch(e){}hdr.style.left=p.x+'px';hdr.style.top=p.y+'px';}
  else{hdr.style.left='';hdr.style.top='';}
  set('navmode',mode);$('dock').textContent=(mode==='left')?'⇥ Dock top':'⇤ Dock left';}
$('dock').addEventListener('click',()=>applyDock(get('navmode','top')==='left'?'top':'left'));
(function(){let drag=false,ox=0,oy=0;const grip=$('grip');
  grip.addEventListener('mousedown',e=>{if(get('navmode','top')!=='float')applyDock('float');drag=true;const r=hdr.getBoundingClientRect();ox=e.clientX-r.left;oy=e.clientY-r.top;e.preventDefault();});
  window.addEventListener('mousemove',e=>{if(!drag)return;const x=Math.max(0,e.clientX-ox),y=Math.max(0,e.clientY-oy);hdr.style.left=x+'px';hdr.style.top=y+'px';set('navpos',JSON.stringify({x,y}));});
  window.addEventListener('mouseup',()=>drag=false);})();
// fixers
function renderFixers(){$('fixers').innerHTML=(META.fixers||[]).map(f=>'<button class="fx '+(f.launch?'':'gated')+'" data-id="'+esc(f.id)+'" '+(f.launch?'':'disabled title="Gated — run manually"')+'>'+esc(f.label||f.id)+(f.launch?'':' · gated')+'</button>').join('');
  document.querySelectorAll('.fx:not(.gated)').forEach(b=>b.addEventListener('click',async e=>{e.stopPropagation();b.disabled=true;b.textContent='running…';try{const r=await fetch('api/fix?id='+encodeURIComponent(b.dataset.id),{method:'POST'});const j=await r.json();b.textContent=(j.ok?'✓ ':'✗ ')+b.dataset.id;}catch(x){b.textContent='error';}setTimeout(poll,1200);setTimeout(renderFixers,4000);}));}
// init persisted state
(function(){
  applyTheme(get('theme','light')); // default = day (black text on white); toggle to Night persists
  const c=get('cols','3');$('dens').value=c;setCols(c);
  $('sort').value=get('sort','broken');$('group').value=get('group','none');
  setView(get('view','grid'));
  applyDock(get('navmode','top'));
})();
async function loadMeta(){try{META=await(await fetch('api/meta')).json();$('h1').textContent='🛠️ '+META.name;document.title=META.name;renderFixers();}catch(e){}}
async function poll(){try{const j=await(await fetch('api/status')).json();const err=$('err');
  if(j.err){err.style.display='block';err.textContent='probe error: '+j.err;}else{err.style.display='none';}
  DATA=j.rows||[];render();if(SEL)openDrawer(SEL);$('upd').textContent='updated '+new Date().toLocaleTimeString();
}catch(e){$('upd').textContent='refresh error';}}
loadMeta();poll();setInterval(poll,8000);
</script></body></html>