app
Mouser Electronics Search
Public Made by Adomby adom
Mouser component search in your workspace — stock, quantity price breaks, datasheets and lifecycle. CLI verbs, a Hydrogen app, and a shared backend service.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Mouser Search</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<style>
@font-face {
font-family: 'Familjen Grotesk';
src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-600-normal.woff2') format('woff2'),
url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-500-normal.woff2') format('woff2');
font-weight: 600; 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: 'Satoshi';
src: url('https://adom.inc/fonts/satoshi/satoshi-700-normal.woff2') format('woff2');
font-weight: 600 900; font-display: swap;
}
:root {
--bg: #0d1117;
--surface: #161b22;
--surface2: #1c2129;
--border: #30363d;
--text: #e6edf3;
--text-dim: #8b949e;
--accent: #00b8b0;
--green: #3fb950;
--red: #f85149;
--yellow: #d29922;
--purple: #8C6BF7;
--blue: #58a6ff;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
background: var(--bg);
color: var(--text);
font-family: 'Satoshi', sans-serif;
font-size: 14px;
line-height: 1.55;
min-height: 100vh;
/* Tooltips default-anchor `left: 0` and grow rightward; a tooltip on a
right-edge trigger extends up to 340px past the viewport even at
opacity:0, which creates a horizontal scrollbar. overflow-x: hidden
clips that silently. Specific right-edge triggers also carry
data-tooltip-align="right" for correct semantics. */
overflow-x: hidden;
}
body { padding: 24px 28px 80px; }
h1, h2, h3 { font-family: 'Familjen Grotesk', sans-serif; font-weight: 600; }
code, pre, .mono { font-family: 'JetBrains Mono', monospace; font-size: 13px; }
/* --- Header --- */
.hdr { display: flex; align-items: center; gap: 14px; margin-bottom: 20px; }
.hdr .logo {
/* In-app icons are monochrome white (var(--text)) per brand/SKILL.md §"Icons
inside the UI must be monochrome white (#e6edf3) or inherit currentColor
from a white-text context". The favicon at docs/icon.svg can be teal —
this rule only governs icons rendered INSIDE the running app. */
width: 32px; height: 32px; color: var(--text);
display: inline-flex; align-items: center; justify-content: center;
}
.hdr h1 { font-size: 22px; letter-spacing: -0.01em; }
.hdr .subtitle { color: var(--text-dim); font-size: 13px; margin-top: 2px; }
.hdr .backend-status {
margin-left: auto; display: flex; align-items: center; gap: 6px;
font-size: 12px; color: var(--text-dim);
padding: 4px 10px; border-radius: 8px;
background: var(--surface); border: 1px solid var(--border);
}
.hdr .backend-status .dot {
width: 8px; height: 8px; border-radius: 50%; background: var(--text-dim);
transition: background 0.2s;
}
.hdr .backend-status.ok .dot { background: var(--green); box-shadow: 0 0 0 2px rgba(63,185,80,0.2); }
.hdr .backend-status.err .dot { background: var(--red); box-shadow: 0 0 0 2px rgba(248,81,73,0.2); }
/* --- Search bar --- */
.searchbar {
display: flex; gap: 10px; align-items: stretch; margin-bottom: 16px;
background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 6px;
transition: border-color 0.15s, box-shadow 0.15s;
}
.searchbar:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(0,184,176,0.15);
}
.searchbar input {
flex: 1; background: transparent; border: none; outline: none; color: var(--text);
font-family: 'Satoshi', sans-serif; font-size: 15px; padding: 10px 14px;
}
.searchbar input::placeholder { color: var(--text-dim); }
.searchbar .limit-select {
background: var(--surface2); border: 1px solid var(--border); border-radius: 8px;
color: var(--text); padding: 0 10px; font-size: 13px; cursor: pointer;
font-family: 'JetBrains Mono', monospace;
}
.btn {
padding: 10px 18px; border: none; border-radius: 8px; cursor: pointer;
font-family: 'Satoshi', sans-serif; font-weight: 600; font-size: 14px;
transition: background 0.15s, transform 0.05s;
}
.btn:active { transform: translateY(1px); }
.btn.primary { background: var(--accent); color: #0d1117; }
.btn.primary:hover { background: #00d4cb; }
.btn.primary:disabled { background: var(--border); color: var(--text-dim); cursor: not-allowed; }
.btn.ghost {
background: transparent; color: var(--text-dim); border: 1px solid var(--border);
}
.btn.ghost:hover { color: var(--text); border-color: var(--text-dim); }
.filters {
display: flex; gap: 16px; align-items: center; margin-bottom: 20px;
color: var(--text-dim); font-size: 13px;
}
.filters label { display: flex; gap: 6px; align-items: center; cursor: pointer; }
.filters input[type="checkbox"] { accent-color: var(--accent); width: 14px; height: 14px; }
.filters .hint { font-size: 12px; }
/* --- Status + feedback --- */
.statusline {
color: var(--text-dim); font-size: 13px; margin: 8px 0 16px; min-height: 20px;
}
.statusline.error { color: var(--red); }
.statusline.ok { color: var(--green); }
/* --- Results --- */
.results { display: flex; flex-direction: column; gap: 12px; }
.card {
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 18px 20px; transition: border-color 0.15s;
}
.card:hover { border-color: var(--text-dim); }
.card-top { display: flex; align-items: flex-start; gap: 16px; }
.card-img {
flex: 0 0 64px; width: 64px; height: 64px; border-radius: 8px;
background: var(--surface2); display: flex; align-items: center; justify-content: center;
overflow: hidden; position: relative; cursor: zoom-in;
transition: transform 0.12s ease;
}
.card-img:hover { transform: scale(1.04); }
.card-img img { width: 100%; height: 100%; object-fit: contain; display: block; }
/* Lightbox for clicked product photo */
.lightbox {
position: fixed; inset: 0; z-index: 100000;
background: rgba(13,17,23,0.92); backdrop-filter: blur(10px);
display: flex; align-items: center; justify-content: center;
opacity: 0; pointer-events: none;
transition: opacity 0.18s ease;
cursor: zoom-out;
}
.lightbox.open { opacity: 1; pointer-events: auto; }
.lightbox img {
max-width: min(90vw, 1200px); max-height: 86vh;
border-radius: 12px;
box-shadow: 0 24px 80px rgba(0,0,0,0.7), 0 0 0 1px rgba(0,184,176,0.25);
background: #fff;
}
.lightbox .lb-caption {
position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%);
color: var(--text); background: rgba(22,27,34,0.88);
padding: 10px 18px; border-radius: 10px; font-size: 13px;
border: 1px solid var(--border); max-width: min(90vw, 900px);
font-family: 'JetBrains Mono', monospace;
}
.lightbox .lb-close {
position: absolute; top: 20px; right: 24px; color: var(--text);
background: rgba(22,27,34,0.88); border: 1px solid var(--border);
width: 40px; height: 40px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
cursor: pointer; font-family: 'Satoshi', sans-serif; font-size: 18px;
}
.card-img .placeholder { color: var(--text-dim); font-size: 10px; font-family: 'JetBrains Mono', monospace; }
.card-body { flex: 1; min-width: 0; }
.card-row1 {
display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 4px;
}
.mouser-pn { font-family: 'JetBrains Mono', monospace; color: var(--accent); font-weight: 600; font-size: 14px; }
.mpn { font-family: 'JetBrains Mono', monospace; color: var(--text); font-size: 13px; }
.mfg { color: var(--text-dim); font-size: 13px; }
.card-desc { color: var(--text); font-size: 13px; margin-bottom: 8px; }
.card-category { color: var(--text-dim); font-size: 12px; margin-bottom: 10px; }
.card-row2 {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; font-size: 13px;
}
.card-field { display: flex; flex-direction: column; gap: 2px; }
.card-field .label { color: var(--text-dim); font-size: 11px; text-transform: lowercase; }
.card-field .value { color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 13px; }
.card-field .value.low { color: var(--red); }
.card-field .value.medium { color: var(--yellow); }
.card-field .value.high { color: var(--green); }
.card-flags {
display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; font-size: 11px;
}
.flag {
padding: 2px 8px; border-radius: 10px;
background: var(--surface2); border: 1px solid var(--border); color: var(--text-dim);
font-family: 'JetBrains Mono', monospace;
}
.flag.rohs { color: var(--green); border-color: rgba(63,185,80,0.3); }
.flag.lifecycle-active { color: var(--green); border-color: rgba(63,185,80,0.3); }
.flag.lifecycle-nrnd { color: var(--yellow); border-color: rgba(210,153,34,0.3); }
.flag.lifecycle-obsolete { color: var(--red); border-color: rgba(248,81,73,0.3); }
.flag.lifecycle-new { color: var(--blue); border-color: rgba(88,166,255,0.3); }
.card-actions { display: flex; gap: 10px; margin-top: 14px; flex-wrap: wrap; }
.card-actions a {
color: var(--accent); text-decoration: none; font-size: 12px;
padding: 6px 12px; border-radius: 6px; border: 1px solid var(--border);
background: var(--surface2);
display: inline-flex; align-items: center; gap: 6px;
}
.card-actions a:hover { border-color: var(--accent); }
.card-actions a svg { width: 14px; height: 14px; color: var(--text); fill: currentColor; }
.price-tiers {
font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-dim);
margin-top: 6px;
}
.price-tiers b { color: var(--text); font-weight: 600; }
.empty {
padding: 60px 20px; text-align: center; color: var(--text-dim);
border: 1px dashed var(--border); border-radius: 12px;
background: var(--surface);
}
.empty h3 { color: var(--text); margin-bottom: 8px; font-size: 16px; }
.empty p { font-size: 13px; line-height: 1.6; }
.loading {
display: inline-block; width: 14px; height: 14px;
border: 2px solid var(--border); border-top-color: var(--accent);
border-radius: 50%; animation: spin 0.8s linear infinite;
vertical-align: middle; margin-right: 6px;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* --- Tooltips (per app-creator skill: 500ms hover, anchored to trigger edges) --- */
[data-tooltip] { position: relative; }
[data-tooltip]::after {
content: attr(data-tooltip);
position: absolute;
bottom: calc(100% + 8px);
left: 0; right: auto;
width: max-content;
max-width: min(340px, calc(100vw - 48px));
background: rgba(22,27,34,0.96);
backdrop-filter: blur(16px);
border: 1px solid rgba(0,184,176,0.25);
border-radius: 10px;
padding: 10px 14px;
font-family: 'Satoshi', sans-serif;
font-size: 12px;
line-height: 1.55;
color: var(--text);
text-align: left;
white-space: pre-wrap;
pointer-events: none;
opacity: 0;
transform: translateY(4px);
box-shadow: 0 12px 40px rgba(0,0,0,0.55);
transition: opacity 0.2s ease 0s, transform 0.2s ease 0s;
z-index: 99999;
}
[data-tooltip][data-tooltip-align="right"]::after { left: auto; right: 0; }
[data-tooltip]:hover::after {
opacity: 1;
transform: translateY(0);
transition: opacity 0.2s ease 0.5s, transform 0.2s ease 0.5s;
}
</style>
</head>
<body>
<div class="hdr">
<span class="logo" aria-hidden="true">
<svg width="28" height="28" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<g fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round">
<rect x="10" y="14" width="28" height="28" rx="3"/>
<path d="M6 20 L10 20 M6 28 L10 28 M6 36 L10 36 M38 20 L42 20 M38 28 L42 28 M38 36 L42 36 M16 10 L16 14 M24 10 L24 14 M32 10 L32 14 M16 42 L16 46 M24 42 L24 46 M32 42 L32 46"/>
</g>
<circle cx="42" cy="40" r="10" fill="#0d1117" stroke="currentColor" stroke-width="4"/>
<line x1="50" y1="48" x2="58" y2="56" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>
<!-- no hard-coded teal fill here — brand rule: in-app icons monochrome white -->
</svg>
</span>
<div>
<h1>Mouser Search</h1>
<div class="subtitle">Live component search, pricing, and stock — via the shared backend service.</div>
</div>
<div id="backend-status" class="backend-status" data-tooltip-align="right" data-tooltip="Health of the adom-mouser backend HTTP proxy the UI talks to. Green means the backend is reachable and its MOUSER_API_KEY is working. Click to re-check.">
<span class="dot"></span>
<span class="txt">checking…</span>
</div>
</div>
<form id="search-form" class="searchbar" autocomplete="off">
<input id="q" name="keyword" type="text" placeholder="Search: STM32F103RBT6, ESP32, LDO 3.3V 500mA SOT-23 …" autofocus>
<select id="limit" class="limit-select" data-tooltip="How many results to request from the Mouser API. 10 is a good default for browsing; set higher when you need to compare many alternatives.">
<option value="5">5</option>
<option value="10" selected>10</option>
<option value="20">20</option>
<option value="50">50</option>
</select>
<button type="submit" class="btn primary" id="go" data-tooltip-align="right" data-tooltip="Run the search against Mouser. Results will replace whatever is shown below. Keyboard: Enter from the search box does the same thing.">Search</button>
</form>
<div class="filters">
<label data-tooltip="When enabled, only parts that currently have stock at Mouser are returned. Useful when you need something fast; otherwise leave off so you can see pricing for factory-lead-time parts too.">
<input type="checkbox" id="instock"> In stock only
</label>
<span class="hint">Tip: paste a full MPN (e.g. <code>STM32F103RBT6</code>) for exact matches, or a parametric query (e.g. <code>LDO 3.3V 500mA SOT-23</code>) for discovery.</span>
</div>
<div id="status" class="statusline" aria-live="polite"></div>
<div id="results" class="results"></div>
<!-- Lightbox for product-photo zoom -->
<div id="lightbox" class="lightbox" role="dialog" aria-hidden="true">
<div class="lb-close" id="lb-close" data-tooltip="Close (or press Escape)">✕</div>
<img id="lb-img" alt="">
<div class="lb-caption" id="lb-caption"></div>
</div>
<script>
// --- Console forwarding so the AI can read UI-side logs via GET /console
(function() {
const orig = { log: console.log, warn: console.warn, error: console.error };
function forward(level, args) {
orig[level].apply(console, args);
try {
fetch('console', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({
level,
ts: Date.now(),
message: args.map(a => {
try { return typeof a === 'string' ? a : JSON.stringify(a); }
catch { return String(a); }
}).join(' ')
})
}).catch(() => {});
} catch {}
}
console.log = function(...a) { forward('log', a); };
console.warn = function(...a) { forward('warn', a); };
console.error = function(...a) { forward('error', a); };
window.addEventListener('error', e => forward('error', [e.message, (e.filename||'') + ':' + (e.lineno||'')]));
window.addEventListener('unhandledrejection', e => forward('error', ['unhandledrejection', String(e.reason)]));
})();
// --- Eval channel (dev only). The server's /state tells us whether
// --dev is on; if not, don't poll (silences a 404/sec spam in prod).
(async function() {
let devEnabled = false;
try {
const r = await fetch('state');
const s = await r.json();
devEnabled = !!s.dev;
} catch {}
if (!devEnabled) return;
async function pollOnce() {
try {
const r = await fetch('eval/pending');
if (!r.ok) return;
const pending = await r.json();
if (!pending || !pending.code) return;
let result;
try {
const fn = new (async function(){}).constructor(pending.code);
result = await fn();
} catch (e) {
result = { error: String(e && e.message || e), stack: String(e && e.stack || '') };
}
console.log('eval-result', pending.id, result);
await fetch('eval/' + pending.id + '/result', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ result: result === undefined ? null : result })
});
} catch {}
}
setInterval(pollOnce, 500);
})();
// --- Backend health poll
async function updateBackendStatus() {
const el = document.getElementById('backend-status');
const txt = el.querySelector('.txt');
try {
const r = await fetch('backend-health');
const v = await r.json();
if (r.ok && v.ok) {
el.classList.remove('err');
el.classList.add('ok');
txt.textContent = 'backend ok';
if (v.api_reachable === false) {
el.classList.remove('ok');
el.classList.add('err');
txt.textContent = 'API unreachable';
}
} else {
el.classList.remove('ok');
el.classList.add('err');
txt.textContent = v.error ? 'no API key' : 'backend down';
}
} catch {
el.classList.remove('ok');
el.classList.add('err');
txt.textContent = 'backend down';
}
}
document.getElementById('backend-status').addEventListener('click', updateBackendStatus);
updateBackendStatus();
setInterval(updateBackendStatus, 30000);
// --- Format helpers
function fmtStock(stock, text) {
if (text && text !== 'Unknown') return text;
if (stock >= 1_000_000) return (stock/1_000_000).toFixed(1) + 'M';
if (stock >= 1000) return (stock/1000).toFixed(1) + 'k';
return String(stock);
}
function lifecycleClass(lc) {
const l = (lc||'').toLowerCase();
if (l.includes('obsolete') || l.includes('eol')) return 'lifecycle-obsolete';
if (l.includes('nrnd') || l.includes('not recommended')) return 'lifecycle-nrnd';
if (l.includes('new')) return 'lifecycle-new';
if (l.includes('active')) return 'lifecycle-active';
return '';
}
function escape(s) {
return String(s||'').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}
function renderCard(c) {
const priceTiers = (c.price_tiers||[]).slice(0, 6).map(t => `<b>${t.qty}+</b>: ${escape(t.price)}`).join(' · ');
const attrs = (c.attributes||[]).slice(0, 6).map(a =>
`<span class="flag" data-tooltip="${escape(a.name)}: ${escape(a.value)}">${escape(a.name)}: ${escape(a.value)}</span>`
).join('');
const lc = escape(c.lifecycle || '');
const rohs = escape(c.rohs || '');
const stockTip =
`${c.stock.toLocaleString()} units reported in stock at Mouser. ` +
`Stock counts refresh every 10 minutes on the backend cache.`;
const priceTip =
`Unit price at quantity 1. The "Breaks" line below shows quantity pricing tiers — ordering more gets you a lower per-unit price.`;
const leadTip =
`How long Mouser says it will take to get more of this part from the manufacturer if stock runs out.`;
return `
<div class="card">
<div class="card-top">
<div class="card-img">
${c.image_url ? `<img src="${escape(c.image_url)}" alt="" onerror="this.style.display='none'; this.nextElementSibling.style.display='block'">` : ''}
<div class="placeholder" ${c.image_url ? 'style="display:none"' : ''}>no image</div>
</div>
<div class="card-body">
<div class="card-row1">
<span class="mouser-pn">${escape(c.mouser_pn)}</span>
<span class="mpn">${escape(c.mpn)}</span>
<span class="mfg">${escape(c.manufacturer)}</span>
</div>
${c.description ? `<div class="card-desc">${escape(c.description)}</div>` : ''}
${c.category ? `<div class="card-category">${escape(c.category)}</div>` : ''}
<div class="card-row2">
<div class="card-field" data-tooltip="${stockTip}">
<span class="label">stock</span>
<span class="value ${c.popularity||''}">${fmtStock(c.stock, c.stock_text)}</span>
</div>
<div class="card-field" data-tooltip="${priceTip}">
<span class="label">unit price</span>
<span class="value">${escape(c.unit_price || '—')}</span>
</div>
<div class="card-field" data-tooltip="${leadTip}">
<span class="label">lead time</span>
<span class="value">${escape(c.lead_time || '—')}</span>
</div>
</div>
${priceTiers ? `<div class="price-tiers">Breaks: ${priceTiers}</div>` : ''}
<div class="card-flags">
${rohs ? `<span class="flag rohs" data-tooltip="RoHS = Restriction of Hazardous Substances. 'Compliant' means the part doesn't contain banned amounts of lead, cadmium, mercury, etc.">${rohs}</span>` : ''}
${lc ? `<span class="flag ${lifecycleClass(lc)}" data-tooltip="Lifecycle status from the manufacturer. Active = currently produced. NRND = 'not recommended for new design'. Obsolete = don't design in. New = recently released.">${lc}</span>` : ''}
${`<span class="flag" data-tooltip="Smallest order quantity and the step size. Min 1 / Mult 1 means you can order any number; Mult 100 means you must order in multiples of 100 (typical for reel packaging).">MinQty ${c.min_order||1} · Mult ${c.mult||1}</span>`}
${attrs}
</div>
<div class="card-actions">
${c.product_url ? `<a href="${escape(c.product_url)}" target="_blank" rel="noopener" data-tooltip="Open the Mouser product page in your default browser. Use this to view the full spec sheet, order, or check real-time stock at all Mouser warehouses.">Product page ↗</a>` : ''}
${c.datasheet_url ? `<a href="${escape(c.datasheet_url)}" target="_blank" rel="noopener" data-tooltip="Open the manufacturer's PDF datasheet.">Datasheet ↗</a>` : ''}
<a href="#" data-copy="${escape(c.mouser_pn)}" data-tooltip="Copy the Mouser part number to your clipboard. Use this for BOM ordering.">Copy Mouser PN</a>
<a href="#" data-copy="${escape(c.mpn)}" data-tooltip="Copy the manufacturer part number (MPN) to your clipboard. Use this in schematic symbols and footprint names.">Copy MPN</a>
</div>
</div>
</div>
</div>`;
}
function setStatus(msg, level='') {
const el = document.getElementById('status');
el.textContent = msg;
el.className = 'statusline' + (level ? ' '+level : '');
}
async function doSearch(keyword) {
if (!keyword || !keyword.trim()) return;
const limit = parseInt(document.getElementById('limit').value, 10);
const inStock = document.getElementById('instock').checked;
const go = document.getElementById('go');
go.disabled = true; go.textContent = 'Searching…';
setStatus('<span class="loading"></span> searching Mouser for "' + keyword + '"…');
document.getElementById('status').innerHTML = '<span class="loading"></span> searching Mouser for "' + escape(keyword) + '"…';
try {
const r = await fetch('search', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ keyword, limit, inStockOnly: inStock })
});
const v = await r.json();
if (!r.ok) {
setStatus('error: ' + (v.error || r.statusText), 'error');
document.getElementById('results').innerHTML = '';
return;
}
const components = v.components || [];
if (components.length === 0) {
document.getElementById('results').innerHTML = '<div class="empty"><h3>No parts found</h3><p>Try a different keyword, a full MPN, or turn off the in-stock-only filter.</p></div>';
setStatus('0 results', 'ok');
return;
}
document.getElementById('results').innerHTML = components.map(renderCard).join('');
setStatus(`found ${v.count || components.length} results; showing ${components.length}`, 'ok');
} catch (e) {
console.error('search error', e);
setStatus('error: ' + (e && e.message || e), 'error');
} finally {
go.disabled = false; go.textContent = 'Search';
}
}
document.getElementById('search-form').addEventListener('submit', e => {
e.preventDefault();
doSearch(document.getElementById('q').value);
});
// Copy-to-clipboard for Mouser PN / MPN buttons
document.addEventListener('click', async e => {
const a = e.target.closest('a[data-copy]');
if (!a) return;
e.preventDefault();
const val = a.getAttribute('data-copy');
try {
await navigator.clipboard.writeText(val);
const orig = a.textContent;
a.textContent = '✓ Copied';
setTimeout(() => { a.textContent = orig; }, 1200);
} catch (err) {
console.warn('clipboard write failed', err);
}
});
// Lightbox: click a card thumbnail to see the full product photo
(function() {
const lb = document.getElementById('lightbox');
const lbImg = document.getElementById('lb-img');
const lbCap = document.getElementById('lb-caption');
const lbClose = document.getElementById('lb-close');
function open(src, caption) {
lbImg.src = src;
lbCap.textContent = caption || '';
lb.classList.add('open');
lb.setAttribute('aria-hidden', 'false');
}
function close() {
lb.classList.remove('open');
lb.setAttribute('aria-hidden', 'true');
// Release the img src after the fade so we don't hold the cached resource.
setTimeout(() => { if (!lb.classList.contains('open')) lbImg.src = ''; }, 200);
}
// Click on any .card-img opens the lightbox with that card's MPN as caption.
document.addEventListener('click', e => {
const box = e.target.closest('.card-img');
if (box) {
const img = box.querySelector('img');
if (!img || !img.src || img.style.display === 'none') return;
const card = box.closest('.card');
const mpn = card?.querySelector('.mpn')?.textContent || '';
const mfg = card?.querySelector('.mfg')?.textContent || '';
open(img.src, [mpn, mfg].filter(Boolean).join(' — '));
return;
}
// Clicking the backdrop or close button closes
if (e.target === lb || e.target === lbClose) close();
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && lb.classList.contains('open')) close();
});
})();
// Restore last query if there is one (so a tab reopen shows prior results)
(async function() {
try {
const r = await fetch('state');
const v = await r.json();
if (v.last_query) {
document.getElementById('q').value = v.last_query;
}
if (v.last_results && Array.isArray(v.last_results.components) && v.last_results.components.length) {
document.getElementById('results').innerHTML = v.last_results.components.map(renderCard).join('');
setStatus(`cached: ${v.last_results.count || v.last_results.components.length} results for "${escape(v.last_query || '')}"`, 'ok');
}
} catch {}
})();
</script>
</body>
</html>