app
Chip Fetcher Lite
Public Made by Adomby adom
Lightweight wiki-first component staging area. Add a component, check the Adom wiki for KiCad/Altium/Fusion files, upload what's missing, then file the symbol + footprint + 3D model straight into your
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>chip-fetcher-lite</title>
<style>
/* Adom brand fonts — Familjen Grotesk (headlines), Satoshi (body), JetBrains Mono (code).
Per the Adom brand style guide (wiki: brand). Never fall back to system fonts as the primary face. */
@font-face{font-family:'Familjen Grotesk';src:url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-700-normal.woff2') format('woff2');font-weight:700;font-display:swap}
@font-face{font-family:'Familjen Grotesk';src:url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-500-normal.woff2') format('woff2');font-weight:500;font-display:swap}
@font-face{font-family:'Satoshi';src:url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2');font-weight:400;font-display:swap}
@font-face{font-family:'Satoshi';src:url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2');font-weight:500;font-display:swap}
@font-face{font-family:'JetBrains Mono';src:url('https://fonts.gstatic.com/s/jetbrainsmono/v24/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx3cwhsk.woff2') format('woff2');font-weight:400;font-display:swap}
@font-face{font-family:'JetBrains Mono';src:url('https://fonts.gstatic.com/s/jetbrainsmono/v24/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxTcwhsk.woff2') format('woff2');font-weight:500;font-display:swap}
:root{
--bg:#0d1117; --surface:#161b22; --elevated:#1c2128; --overlay:#21262d;
--border:#30363d; --border-muted:#21262d;
--text:#e6edf3; --text-muted:#8b949e; --text-dim:#484f58;
--accent:#00b8b0; --accent-bright:#00e6dc; --accent-dim:rgba(0,184,176,.12); --accent-border:rgba(0,184,176,.25);
--green:#3fb950; --green-dim:rgba(63,185,80,.14);
--yellow:#d29922; --yellow-dim:rgba(210,153,34,.14);
--red:#f85149; --red-dim:rgba(248,81,73,.14);
--purple:#8C6BF7; --row-hover:#1c2128;
}
*{box-sizing:border-box}
html{height:100%}
body{margin:0;min-height:100%}
body{background:var(--bg);color:var(--text);font-family:'Satoshi',-apple-system,Segoe UI,Roboto,sans-serif;font-size:13px}
.mono{font-family:'JetBrains Mono',ui-monospace,Menlo,Consolas,monospace}
h1,h2,h3{font-family:'Familjen Grotesk',sans-serif;margin:0}
button{font-family:inherit;cursor:pointer}
/* header */
.app-header{display:flex;align-items:center;gap:12px;height:44px;padding:0 14px;background:var(--surface);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:30}
.brand{display:flex;align-items:center;gap:9px}
.logo-chip{display:block;color:var(--accent-bright)}
.app-name{font-family:'Familjen Grotesk',sans-serif;font-weight:600;font-size:15px;letter-spacing:.2px;color:var(--accent-bright)}
.sep{color:var(--text-dim)}
.app-subtitle{color:var(--text-muted);font-size:12px}
.subject{margin-left:6px;color:var(--text-muted);font-size:12px}
.subject b{color:var(--text);font-weight:600}
.spacer{flex:1}
.lib-status{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--text-muted);margin-right:6px}
.lib-status .dot{width:8px;height:8px;border-radius:50%;background:var(--text-dim);flex:none}
.lib-status.ok .dot{background:var(--green);box-shadow:0 0 6px var(--green)}
.lib-status.warn .dot{background:var(--yellow)}
.lib-status.none .dot{background:var(--red)}
.send-status{display:inline-flex;align-items:center;gap:7px;font-size:12px;padding:4px 11px;border-radius:7px;margin-left:12px;font-weight:600;max-width:520px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.send-status.running{background:var(--accent-dim);border:1px solid var(--accent-border);color:var(--text)}
.send-status.done{background:var(--green-dim);border:1px solid var(--green);color:var(--text)}
.send-status.err{background:var(--red-dim);border:1px solid var(--red);color:var(--text)}
/* controls bar */
.controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:10px 14px;background:var(--bg);border-bottom:1px solid var(--border-muted);position:sticky;top:var(--header-h,44px);z-index:20}
.add-box{display:flex;align-items:center;gap:6px;flex:1;min-width:260px;max-width:520px}
input[type=text]{background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:7px;padding:7px 10px;font-size:13px;width:100%}
input[type=text]:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-dim)}
.btn{background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:7px;padding:7px 12px;font-size:12.5px;font-weight:600;transition:.15s;white-space:nowrap}
.btn:hover{border-color:var(--accent-border);background:var(--elevated)}
.btn.primary{background:var(--accent);border-color:var(--accent);color:#04231f}
.btn.primary:hover{background:var(--accent-bright)}
.btn:disabled{opacity:.45;cursor:not-allowed}
.pills{display:flex;gap:6px}
.pill{background:transparent;border:1px solid var(--border);color:var(--text-muted);border-radius:999px;padding:5px 11px;font-size:12px}
.pill.active{color:var(--text);border-color:var(--accent-border);background:var(--accent-dim)}
.send-group{display:flex;align-items:center;gap:8px;padding:5px 9px;border:1px solid var(--border);border-radius:8px;background:var(--surface)}
.send-group .lbl{color:var(--text-muted);font-size:11px;text-transform:uppercase;letter-spacing:.4px}
.eda-checks{display:flex;gap:8px}
.eda-checks label{display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-muted);cursor:pointer;user-select:none}
.eda-checks input{accent-color:var(--accent)}
/* table */
.wrap{padding:0 0 60px}
table{width:100%;border-collapse:collapse}
thead th{position:sticky;top:var(--stick-top,96px);z-index:10;background:var(--surface);color:var(--text-muted);font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.5px;text-align:left;padding:8px 12px;border-bottom:1px solid var(--border)}
.cat-row td{background:var(--elevated);border-top:1px solid var(--border);border-bottom:1px solid var(--border-muted);padding:6px 12px}
.cat-name{font-family:'Satoshi',sans-serif;font-weight:600;font-size:12px;letter-spacing:.6px;text-transform:uppercase;color:var(--accent-bright)}
.cat-count{color:var(--text-dim);font-size:11px;margin-left:8px}
tbody tr.item{border-bottom:1px solid var(--border-muted);cursor:pointer}
tbody tr.item:hover{background:var(--row-hover)}
tbody tr.item.sel{background:var(--accent-dim);box-shadow:inset 3px 0 0 var(--accent)}
td{padding:8px 12px;vertical-align:middle}
td.sel-col{width:34px;text-align:center}
input[type=checkbox]{accent-color:var(--accent);width:15px;height:15px;cursor:pointer}
.name{font-weight:600}
.name .mpn{color:var(--text-muted);font-size:11px;font-weight:400}
.cdesc{color:var(--text-muted);font-size:11px;font-weight:400;margin-top:2px;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.dest{color:var(--text);font-size:12px}
.dest.empty{color:var(--text-dim)}
.dest .basename{cursor:default;border-bottom:1px dotted var(--text-dim)}
.namecell{min-width:180px}
.nameinput{background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:5px;padding:4px 7px;font-size:12px;width:100%;font-family:'JetBrains Mono',ui-monospace,monospace}
.nameinput:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-dim)}
.destsub{font-size:10.5px;color:var(--text-dim);margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.destsub .basename{border-bottom:1px dotted var(--text-dim);cursor:default;color:var(--text-muted)}
.destsub .nodest{font-style:italic}
/* EDA file-availability boxes — one per EDA, each colours on its own */
.eda-boxes{display:flex;gap:6px}
.checking-box{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);border-radius:8px;padding:5px 10px;font-size:11px;color:var(--text-dim)}
.eda-box{position:relative;display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border:1px solid var(--border);border-radius:8px;background:var(--surface);font-size:11.5px;font-weight:600;cursor:default}
.eda-box .tally{font-family:'JetBrains Mono',monospace;font-size:9.5px;opacity:.85}
.eda-box.jlc{cursor:default}
.newtag{font-size:9px;font-weight:700;letter-spacing:.3px;text-transform:uppercase;color:var(--accent-bright);border:1px solid var(--accent-border);background:var(--accent-dim);border-radius:4px;padding:0 4px;vertical-align:1px}
.deftag{font-size:9px;font-weight:700;letter-spacing:.3px;text-transform:uppercase;color:var(--purple-light);border:1px solid rgba(140,107,247,.4);background:rgba(140,107,247,.14);border-radius:4px;padding:0 4px;vertical-align:1px}
.nameinput.locked{opacity:.7;cursor:not-allowed;border-style:dashed}
.eda-box.green{border-color:var(--green);background:var(--green-dim);color:var(--text)}
.eda-box.yellow{border-color:var(--yellow);background:var(--yellow-dim);color:var(--text)}
.eda-box.red{border-color:var(--red);background:var(--red-dim);color:var(--text-muted)}
.eda-pop{position:absolute;top:calc(100% + 6px);left:0;z-index:40;display:none;flex-direction:column;gap:5px;background:var(--overlay);border:1px solid var(--border);border-radius:8px;padding:9px 11px;min-width:140px;box-shadow:0 12px 34px rgba(0,0,0,.55)}
.eda-box:hover .eda-pop{display:flex}
.eda-pop .pophdr{font-size:10px;letter-spacing:.5px;text-transform:uppercase;color:var(--text-dim);margin-bottom:1px}
.eda-pop .pk{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-dim)}
.eda-pop .pk.on{color:var(--text)}
.eda-pop .cb{width:15px;height:15px;border:1px solid var(--text-dim);border-radius:4px;display:inline-flex;align-items:center;justify-content:center;font-size:11px;color:#04231f;flex:none}
.eda-pop .pk.on .cb{background:var(--green);border-color:var(--green)}
.eda-pop .pk .tag{margin-left:auto;font-size:9px;color:var(--text-dim);font-style:italic}
.spin{width:12px;height:12px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;display:inline-block;animation:sp .7s linear infinite;vertical-align:-2px}
@keyframes sp{to{transform:rotate(360deg)}}
.row-actions{display:flex;gap:6px;justify-content:flex-end}
.icon-btn{background:var(--surface);border:1px solid var(--border);color:var(--text-muted);border-radius:6px;width:30px;height:28px;display:inline-flex;align-items:center;justify-content:center;font-size:13px}
.icon-btn:hover{border-color:var(--accent-border);color:var(--text)}
.icon-btn.del:hover{border-color:var(--red);color:var(--red)}
.icon-btn:disabled{opacity:.3;cursor:not-allowed}
.icon-btn:disabled:hover{border-color:var(--border);color:var(--text-muted)}
.badge3d{font-size:10px;color:var(--text-dim);border:1px solid var(--border);border-radius:5px;padding:2px 5px}
.badge3d.on{color:var(--green);border-color:var(--green)}
.empty-state{text-align:center;color:var(--text-muted);padding:80px 20px}
.empty-state h2{font-size:18px;margin-bottom:8px;color:var(--text)}
/* modal */
.modal-bg{position:fixed;inset:0;background:rgba(1,4,9,.7);backdrop-filter:blur(6px);display:none;align-items:center;justify-content:center;z-index:50}
.modal-bg.show{display:flex}
.modal{background:var(--overlay);border:1px solid var(--border);border-radius:12px;padding:22px;width:440px;max-width:92vw;box-shadow:0 20px 60px rgba(0,0,0,.5)}
.modal h2{font-size:17px;margin-bottom:6px}
.modal p{color:var(--text-muted);margin:0 0 16px;line-height:1.5}
.modal .eda-pick{display:flex;flex-direction:column;gap:10px;margin-bottom:18px}
.modal .eda-pick label{display:flex;align-items:center;gap:9px;padding:10px 12px;border:1px solid var(--border);border-radius:8px;cursor:pointer;font-size:13px}
.modal .eda-pick label:hover{border-color:var(--accent-border);background:var(--accent-dim)}
.modal-actions{display:flex;gap:8px;justify-content:flex-end}
/* toast */
#toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:var(--elevated);border:1px solid var(--accent-border);color:var(--text);padding:10px 16px;border-radius:9px;font-size:12.5px;z-index:60;opacity:0;transition:.2s;pointer-events:none}
#toast.show{opacity:1}
</style>
</head>
<body>
<header class="app-header">
<div class="brand">
<svg class="logo-chip" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<rect x="7" y="7" width="10" height="10" rx="1.5"/>
<rect x="10.5" y="10.5" width="3" height="3" rx=".5"/>
<line x1="10" y1="7" x2="10" y2="4"/><line x1="14" y1="7" x2="14" y2="4"/>
<line x1="10" y1="17" x2="10" y2="20"/><line x1="14" y1="17" x2="14" y2="20"/>
<line x1="7" y1="10" x2="4" y2="10"/><line x1="7" y1="14" x2="4" y2="14"/>
<line x1="17" y1="10" x2="20" y2="10"/><line x1="17" y1="14" x2="20" y2="14"/>
</svg>
<span class="app-name">chip-fetcher-lite</span>
</div>
<span class="subject">· <b id="count">0</b> components</span>
<span class="send-status" id="sendStatus" style="display:none"></span>
<span class="spacer"></span>
<span class="lib-status" id="libStatus"><span class="dot"></span><span class="txt">libraries not probed</span></span>
<button class="btn" id="probeBtn" title="Probe your EDA libraries">◎ Probe libraries</button>
</header>
<div class="controls">
<div class="add-box">
<input type="text" id="addInput" placeholder="Add a component — MPN or name, then Enter" autocomplete="off" />
<button class="btn primary" id="addBtn">+ Add</button>
</div>
<div class="pills" id="pills">
<button class="pill active" data-f="all">All</button>
<button class="pill" data-f="green">Complete</button>
<button class="pill" data-f="yellow">Partial</button>
<button class="pill" data-f="red">Missing</button>
</div>
<span class="spacer"></span>
<button class="btn" id="selectAll">✓ Select all</button>
<div class="send-group">
<span class="lbl">send to</span>
<div class="eda-checks" id="sendEdas">
<label><input type="checkbox" value="kicad" checked> KiCad</label>
<label><input type="checkbox" value="altium"> Altium</label>
<label><input type="checkbox" value="fusion"> Fusion</label>
</div>
<button class="btn primary" id="sendBtn">↑ Send to library</button>
</div>
</div>
<div class="wrap">
<table>
<thead><tr>
<th class="sel-col"></th>
<th>Component</th>
<th>Symbol name → dest</th>
<th>Footprint name → dest</th>
<th>3D dest</th>
<th id="edaHead">EDA Files Available</th>
<th style="text-align:right">Actions</th>
</tr></thead>
<tbody id="tbody"></tbody>
</table>
<div class="empty-state" id="empty" style="display:none">
<h2>No components yet</h2>
<div>Add a component above. It'll be checked against the Adom wiki for KiCad, Altium & Fusion files.</div>
</div>
</div>
<!-- probe modal -->
<div class="modal-bg" id="probeModal">
<div class="modal">
<h2>Probe your EDA libraries?</h2>
<p>To show where each component will be filed, I can scan your local libraries. Pick which EDA software you use:</p>
<div class="eda-pick">
<label><input type="checkbox" value="kicad" checked> <span>KiCad</span></label>
<label><input type="checkbox" value="altium"> <span>Altium</span></label>
<label><input type="checkbox" value="fusion"> <span>Fusion 360</span></label>
</div>
<div class="modal-actions">
<button class="btn" id="probeSkip">Skip for now</button>
<button class="btn primary" id="probeGo">⌖ Probe my libraries</button>
</div>
</div>
</div>
<input type="file" id="fileInput" style="display:none" />
<div id="toast"></div>
<script>
const $ = s => document.querySelector(s);
let STATE = { components:[], probe:{edas:[]}, jobs:[] };
let FILTER = 'all';
let uploadTarget = null;
function toast(msg){ const t=$('#toast'); t.textContent=msg; t.classList.add('show'); clearTimeout(t._t); t._t=setTimeout(()=>t.classList.remove('show'),2600); }
// Resolve API paths relative to the page (works behind the Hydrogen proxy at /proxy/<port>/).
function apiUrl(path){ return new URL('api'+path, document.baseURI).href; }
async function api(path, opts){ const r = await fetch(apiUrl(path), opts); if(!r.ok && r.status!==409){ const e=await r.json().catch(()=>({})); throw Object.assign(new Error(e.error||r.statusText),{status:r.status,body:e}); } return r.status===409 ? Object.assign({_conflict:true}, await r.json()) : r.json(); }
// EDAs the user actually probed for; before any probe, show all three.
function activeEdas(){
const e=(STATE.probe&&STATE.probe.edas)||[];
const v=e.filter(x=>['kicad','altium','fusion'].includes(x));
return v.length?v:['kicad','altium','fusion'];
}
const KINDS=[['symbol','Symbol'],['footprint','Footprint'],['model3d','3D']];
// each EDA box colours independently by how many of symbol/footprint/3d it has
function edaColor(c,eda){
const e=c.eda[eda]||{}; const n=['symbol','footprint','model3d'].filter(k=>e[k]&&e[k].present).length;
return n===3?'green':n>=1?'yellow':'red';
}
// component-level status (for the filter pills): green=all active boxes green, red=all red, else yellow
function compStatus(c){
if(c.wiki.status==='checking') return 'checking';
const cols=['kicad','altium','fusion'].map(e=>edaColor(c,e));
if(cols.every(x=>x==='green')) return 'green';
if(cols.every(x=>x==='red')) return 'red';
return 'yellow';
}
// editable library name (defaults to MPN) + the destination it'll be filed into
function destBasename(d){
if(!d||!d.name) return '<span class="nodest">no destination yet</span>';
let tag='';
if(d.source==='kicad-default') tag=' <span class="deftag">KiCad default</span>';
else if(d.exists===false) tag=' <span class="newtag">new</span>';
return `→ <span class="basename" title="${esc(d.path||d.name)}">${esc(d.name)}</span>${tag}`;
}
function nameDestCell(c, kind){
const d=c.dest&&c.dest[kind];
const nm=(c.names&&c.names[kind])!=null?c.names[kind]:c.mpn;
const locked = kind==='footprint' && c.footprintLocked;
const attrs = locked ? 'readonly class="nameinput locked" title="Standard KiCad footprint — name is fixed"' : `class="nameinput" title="Name this ${kind} will get in the library"`;
return `<td class="namecell"><input ${attrs} data-name="${kind}" data-id="${c.id}" value="${esc(nm)}" spellcheck="false"><div class="destsub">${destBasename(d)}</div></td>`;
}
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,m=>({'&':'&','<':'<','>':'>','"':'"'}[m])); }
const EDA_NAME={kicad:'KiCad',altium:'Altium',fusion:'Fusion'};
function edaBox(c,eda){
const e=c.eda[eda]||{};
const n=['symbol','footprint','model3d'].filter(k=>e[k]&&e[k].present).length;
const rows=KINDS.map(([k,label])=>{
const v=e[k]||{}; const on=v.present;
const tag=on?`<span class="tag">${v.source==='upload'?'uploaded':'wiki'}</span>`:'';
return `<div class="pk ${on?'on':''}"><span class="cb">${on?'✓':''}</span>${label}${tag}</div>`;
}).join('');
return `<div class="eda-box ${edaColor(c,eda)}" title="Hover for symbol / footprint / 3D status">${EDA_NAME[eda]} <span class="tally">${n}/3</span>`+
`<div class="eda-pop"><div class="pophdr">${EDA_NAME[eda]} files on wiki</div>${rows}</div></div>`;
}
function jlcBox(c){
const on=!!c.jlc;
const row = on
? `<div class="pk on"><span class="cb">✓</span>${esc(c.jlcPart||'available')}</div>`
: `<div class="pk"><span class="cb"></span>Part number not on wiki</div>`;
return `<div class="eda-box jlc ${on?'green':'red'}" data-jlcedit="${c.id}" style="cursor:pointer"><span style="pointer-events:none">JLC</span><div class="eda-pop"><div class="pophdr">JLCPCB part number — click to edit</div>${row}</div></div>`;
}
function statusCell(c){
if(c.wiki.status==='checking') return `<div class="checking-box"><span class="spin"></span> checking wiki…</div>`;
// always show all three EDA boxes + a JLC presence box; probe selection only affects the status text
return `<div class="eda-boxes">${['kicad','altium','fusion'].map(e=>edaBox(c,e)).join('')}${jlcBox(c)}</div>`;
}
const EDA_LABEL={kicad:'KiCad',altium:'Altium',fusion:'Fusion'};
function updateLibStatus(){
const el=$('#libStatus'); if(!el) return;
const p=STATE.probe||{};
const found=p.libraries?Object.keys(p.libraries):[];
let cls,txt;
if(p.probedAt){
if(found.length){ cls='ok'; txt='libraries found: '+found.map(e=>EDA_LABEL[e]||e).join(', '); }
else { cls='none'; txt='no libraries found'; }
} else if(p.status==='requested'){ cls='warn'; txt='probing libraries…'; }
else { cls='none'; txt='libraries not probed'; }
el.className='lib-status '+cls;
el.querySelector('.txt').textContent=txt;
}
function updateSendStatus(){
const el=$('#sendStatus'); if(!el) return;
const s=STATE.send||{};
if(s.status==='running'){ el.style.display='inline-flex'; el.className='send-status running'; el.innerHTML=`<span class="spin"></span> Sending ${s.done||0}/${s.total||0} — ${esc(s.current||'')}`; }
else if(s.status==='done'){ el.style.display='inline-flex'; el.className='send-status done'; el.textContent='✓ '+(s.current||'Sent to library'); }
else if(s.status==='error'){ el.style.display='inline-flex'; el.className='send-status err'; el.textContent='Send error: '+(s.current||''); }
else { el.style.display='none'; }
}
function updateStickyOffsets(){
const h=$('.app-header'), c=$('.controls'); if(!h||!c) return;
const hh=h.offsetHeight, ch=c.offsetHeight;
document.documentElement.style.setProperty('--header-h', hh+'px');
document.documentElement.style.setProperty('--stick-top', (hh+ch)+'px');
}
window.addEventListener('resize', updateStickyOffsets);
function render(){
$('#count').textContent = STATE.components.length;
updateLibStatus();
updateSendStatus();
updateStickyOffsets();
const list = STATE.components.filter(c=>{
if(FILTER==='all') return true;
if(c.wiki.status==='checking') return false;
return compStatus(c)===FILTER;
});
$('#empty').style.display = STATE.components.length? 'none':'block';
// group by type
const groups={};
for(const c of list){ const t=c.type||'IC / Other'; (groups[t]=groups[t]||[]).push(c); }
const tbody=$('#tbody'); tbody.innerHTML='';
const types=Object.keys(groups).sort();
for(const t of types){
const cat=document.createElement('tr'); cat.className='cat-row';
cat.innerHTML=`<td colspan="7"><span class="cat-name">${esc(t)}</span><span class="cat-count">${groups[t].length}</span></td>`;
tbody.appendChild(cat);
for(const c of groups[t]){
const tr=document.createElement('tr'); tr.className='item'+(c.selected?' sel':''); tr.dataset.id=c.id;
const d3 = c.dest&&c.dest.model3d;
const has3d = c.wikiFiles && c.wikiFiles['3d'];
tr.innerHTML =
`<td class="sel-col"><input type="checkbox" ${c.selected?'checked':''} data-sel="${c.id}"></td>`+
`<td class="name">${esc(c.name)}${c.description?`<div class="cdesc" title="Symbol description">${esc(c.description)}</div>`:''}</td>`+
nameDestCell(c,'symbol')+
nameDestCell(c,'footprint')+
`<td class="dest ${d3&&d3.name?'':'empty'}">${d3&&d3.name?`<span class="basename" title="${esc(d3.path||d3.name)}">${esc(d3.name)}</span>${d3.exists===false?' <span class="newtag">new</span>':''}`:(has3d?'<span class="badge3d on">3D on wiki</span>':'—')}</td>`+
`<td>${statusCell(c)}</td>`+
`<td><div class="row-actions">`+
(c.wiki&&c.wiki.url
? `<button class="icon-btn" data-wiki="${esc(c.wiki.url)}" title="Open wiki page: ${esc(c.wiki.url)}">↗</button>`
: `<button class="icon-btn" disabled title="No wiki page found">↗</button>`)+
`<button class="icon-btn" data-up="${c.id}" title="Upload a file">⬆</button>`+
`<button class="icon-btn" data-check="${c.id}" title="Re-check wiki">↻</button>`+
`<button class="icon-btn del" data-del="${c.id}" title="Remove">✕</button>`+
`</div></td>`;
tbody.appendChild(tr);
}
}
}
// ---- events
$('#addBtn').onclick = addComponent;
$('#addInput').addEventListener('keydown', e=>{ if(e.key==='Enter') addComponent(); });
async function addComponent(){
const inp=$('#addInput'); const name=inp.value.trim(); if(!name) return;
inp.value='';
try{ const c=await api('/components',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})});
if(c._duplicate || STATE.components.some(x=>x.id===c.id)){ toast(`${c.name} is already in the list`); render(); return; }
STATE.components.unshift(c); render(); toast(`Added ${name} — checking wiki…`);
}catch(e){ toast('Add failed: '+e.message); }
}
$('#tbody').addEventListener('click', async e=>{
const t=e.target;
if(t.dataset.del){ if(!confirm('Remove this component and its staged files?'))return; await api(`/components/${t.dataset.del}`,{method:'DELETE'}); STATE.components=STATE.components.filter(x=>x.id!==t.dataset.del); render(); return; }
if(t.dataset.check){ const id=t.dataset.check; const c=STATE.components.find(x=>x.id===id); if(c){c.wiki.status='checking';render();} const nc=await api(`/components/${id}/check`,{method:'POST'}); replaceComp(nc); render(); return; }
if(t.dataset.up){ uploadTarget=t.dataset.up; $('#fileInput').value=''; $('#fileInput').click(); return; }
if(t.dataset.wiki){ window.open(t.dataset.wiki,'_blank','noopener'); return; }
const jb=t.closest('[data-jlcedit]');
if(jb){ const id=jb.dataset.jlcedit; const c=STATE.components.find(x=>x.id===id); if(!c)return;
const pn=prompt('JLCPCB / LCSC part number for '+c.name+':', c.jlcPart||'');
if(pn===null) return;
const nc=await api(`/components/${id}/jlc`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({jlcPart:pn.trim()})});
replaceComp(nc); render(); toast(pn.trim()?('JLC PN set: '+pn.trim()):'JLC PN cleared'); return; }
if(t.dataset.sel!==undefined){ return setSelected(t.dataset.sel, t.checked); } // checkbox
// click anywhere else on the row toggles selection (but not on inputs/buttons/eda boxes)
if(t.closest('input,button,.eda-box,.nameinput,.icon-btn,a')) return;
const tr=t.closest('tr.item'); if(!tr) return;
const c=STATE.components.find(x=>x.id===tr.dataset.id); if(!c) return;
setSelected(tr.dataset.id, !c.selected);
});
async function setSelected(id, on){
const c=STATE.components.find(x=>x.id===id); if(c) c.selected=!!on; render();
try{ await api(`/components/${id}/select`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({on:!!on})}); }catch(e){}
}
// editable symbol/footprint library names
$('#tbody').addEventListener('change', async e=>{
const t=e.target; if(t.dataset.name===undefined) return;
const id=t.dataset.id, kind=t.dataset.name, val=t.value.trim();
const c=STATE.components.find(x=>x.id===id); if(c){ c.names=c.names||{}; c.names[kind]=val; }
try{ await api(`/components/${id}/names`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({[kind]:val})}); toast(`${kind[0].toUpperCase()+kind.slice(1)} name → ${val}`); }
catch(err){ toast('Rename failed: '+err.message); }
});
$('#tbody').addEventListener('keydown', e=>{ if(e.target.classList.contains('nameinput') && e.key==='Enter') e.target.blur(); });
$('#fileInput').addEventListener('change', async e=>{
const f=e.target.files[0]; if(!f||!uploadTarget) return;
const id=uploadTarget; const buf=await f.arrayBuffer();
const q=`?filename=${encodeURIComponent(f.name)}`;
try{
let res=await api(`/components/${id}/upload${q}`,{method:'POST',body:buf});
if(res._conflict){
if(!confirm(`A ${res.existing.eda} ${res.existing.kind} file (${res.existing.name}) is already staged. Replace it with ${f.name}?`)) return;
res=await api(`/components/${id}/upload${q}&replace=1`,{method:'POST',body:buf});
}
replaceComp(res); render(); toast(`Uploaded ${f.name}`);
}catch(err){ toast('Upload failed: '+err.message); }
});
$('#pills').addEventListener('click', e=>{ if(!e.target.dataset.f)return; FILTER=e.target.dataset.f; [...$('#pills').children].forEach(p=>p.classList.toggle('active',p.dataset.f===FILTER)); render(); });
let allOn=false;
$('#selectAll').onclick=async()=>{ allOn=!allOn; await api('/select-all',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({on:allOn})}); STATE.components.forEach(c=>c.selected=allOn); $('#selectAll').textContent=(allOn?'✕ Deselect all':'✓ Select all'); render(); };
$('#sendBtn').onclick=async()=>{
const ids=STATE.components.filter(c=>c.selected).map(c=>c.id);
if(!ids.length){ toast('Select components first'); return; }
const edas=[...$('#sendEdas').querySelectorAll('input:checked')].map(i=>i.value);
if(!edas.length){ toast('Pick at least one EDA target'); return; }
try{
await api('/send-to-library',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids,edas})});
toast(`Sending ${ids.length} component(s) to ${edas.join(', ')}…`); poll();
}catch(e){ toast('Send failed: '+e.message); }
};
// ---- probe
function showProbe(){ $('#probeModal').classList.add('show'); }
$('#probeBtn').onclick=showProbe;
$('#probeSkip').onclick=()=>$('#probeModal').classList.remove('show');
$('#probeGo').onclick=async()=>{
const edas=[...$('#probeModal').querySelectorAll('.eda-pick input:checked')].map(i=>i.value);
if(!edas.length){ toast('Pick at least one EDA'); return; }
await api('/probe-request',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({edas})});
// sync the send-to targets to what they use
$('#sendEdas').querySelectorAll('input').forEach(i=>i.checked=edas.includes(i.value));
$('#probeModal').classList.remove('show');
toast('Probe requested — the agent will scan your '+edas.join(', ')+' libraries.');
};
function replaceComp(nc){ const i=STATE.components.findIndex(x=>x.id===nc.id); if(i>=0)STATE.components[i]=nc; }
// ---- poll
async function poll(){
try{ const s=await api('/state'); STATE=s;
// don't rebuild the table out from under an in-progress edit
if(!document.activeElement || !document.activeElement.classList.contains('nameinput')) render();
}catch(e){}
}
async function init(){
await poll();
// offer the probe on first launch if not done yet
if(!STATE.probe || (!STATE.probe.probedAt && STATE.probe.status!=='requested')) showProbe();
setInterval(poll, 2500);
}
init();
</script>
</body>
</html>