app
Adom Project Manager
Public Made by Adomby adom
Adom Project Manager — the central hub for navigating wiki boards, molecules and libraries into Hydrogen: browse your wiki boards/projects (Personal / Organization / Public), view symbol + footprint + 3D and the per-EDA footprint layer stackup, explore the board and schematic in INTERACTIVE cross-probing viewers (click a net, hover a part for stock and price), add components to your KiCad/Altium libraries, and deliver self-contained KiCad projects. Built on adom-lbr. (Formerly Adom Project Manag
← Commit history
v0.3.2: tags below slug + recolor (molecule gold, board blue, KiCad dark blue, Altium gold, Fusion orange); parallel-warmed+cached EDA (no per-row trickle); activity chips; board/molecule+EDA tags; transparent PCB fills; back-button race fix
2 files changed
+161−7
src/main.rs+107@@ -213,6 +213,14 @@ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> { let slug = &p["/api/board/".len()..]; json_resp(&board_deps(slug).to_string()) }+ (Method::Get, p) if p.starts_with("/api/board-activity/") => {+ let slug = &p["/api/board-activity/".len()..];+ json_resp(&board_activity(slug).to_string())+ }+ (Method::Get, p) if p.starts_with("/api/board-eda/") => {+ let slug = &p["/api/board-eda/".len()..];+ json_resp(&json!({"eda": board_eda_cached(slug)}).to_string())+ } (Method::Get, p) if p.starts_with("/api/board-media/") => { let slug = &p["/api/board-media/".len()..]; json_resp(&board_media(slug).to_string())@@ -343,6 +351,27 @@ fn list_components() -> Value { items.push(json!({"slug": s, "title": up(s), "brief": ""})); } }+ // Fold the EDA type into every BOARD item up front, computed concurrently.+ // The server is single-threaded, so lazy per-row `/api/board-eda` fetches would+ // otherwise serialize N wiki file-listings (KiCad tags trickling in one by one).+ let board_slugs: Vec<String> = items.iter().filter_map(|it| {+ let is_board = it.get("tags").and_then(|t| t.as_array()).is_some_and(|a| {+ a.iter().any(|x| matches!(x.as_str().unwrap_or("").to_lowercase().as_str(), "board" | "molecule"))+ });+ if is_board { it.get("slug").and_then(|s| s.as_str()).map(String::from) } else { None }+ }).collect();+ let eda_map: std::collections::HashMap<String, &'static str> = std::thread::scope(|sc| {+ let handles: Vec<_> = board_slugs.iter().map(|s| {+ let s = s.clone();+ sc.spawn(move || { let e = board_eda_cached(&s); (s, e) })+ }).collect();+ handles.into_iter().filter_map(|h| h.join().ok()).collect()+ });+ for it in items.iter_mut() {+ if let Some(e) = it.get("slug").and_then(|s| s.as_str()).and_then(|s| eda_map.get(s)) {+ it["eda"] = json!(e);+ }+ } let (me, orgs) = whoami(); json!({ "status": "ok", "components": items, "me": me, "orgs": orgs, "hints": ["click a component to open its viewer"] })@@ -435,6 +464,84 @@ fn board_deps(slug: &str) -> Value { "hints": ["board → component library → components (the library's dependencies)"] }) } +/// The board's EDA toolchain, inferred from the files on its page:+/// "kicad" | "altium" | "fusion" | "".+fn board_eda(slug: &str) -> &'static str {+ let files = list_page_files(slug);+ let has = |exts: &[&str]| files.iter().any(|n| { let l = n.to_lowercase(); exts.iter().any(|e| l.ends_with(e)) });+ if has(&[".kicad_pcb", ".kicad_sch", ".kicad_pro"]) {+ "kicad"+ } else if has(&[".pcbdoc", ".schdoc", ".prjpcb", ".intlib", ".pcblib", ".schlib"]) {+ "altium"+ } else if has(&[".fbrd", ".fsch", ".brd", ".sch", ".f3d", ".fusion"]) {+ "fusion"+ } else {+ ""+ }+}++/// `board_eda` cached across requests. The EDA type never changes for a given+/// board within a session, and the raw call costs a wiki file-listing round-trip;+/// caching turns the left-list's per-row EDA into a one-time cost.+static EDA_CACHE: Mutex<Option<std::collections::HashMap<String, &'static str>>> = Mutex::new(None);+fn board_eda_cached(slug: &str) -> &'static str {+ {+ let mut g = EDA_CACHE.lock().unwrap();+ if let Some(&e) = g.get_or_insert_with(Default::default).get(slug) {+ return e;+ }+ }+ let e = board_eda(slug);+ EDA_CACHE.lock().unwrap().get_or_insert_with(Default::default).insert(slug.to_string(), e);+ e+}++/// The board's commit history enriched with what each commit changed: components+/// added/removed (footprint reference designators on the PCB) and whether the PCB+/// or schematic changed. Fetches each commit's .kicad_pcb + .kicad_sch via ?ref=.+fn board_activity(slug: &str) -> Value {+ let log: Vec<Value> = curl(&format!("{WIKI}/api/v1/pages/{slug}/log"))+ .and_then(|b| serde_json::from_slice::<Value>(&b).ok())+ .and_then(|v| v.get("log").and_then(|l| l.as_array().cloned()))+ .unwrap_or_default();+ if log.is_empty() {+ return json!({ "activity": [] });+ }+ let files = list_page_files(slug);+ let pcb = files.iter().find(|n| n.ends_with(".kicad_pcb")).cloned();+ let sch = files.iter().find(|n| n.ends_with(".kicad_sch")).cloned();+ let at = |file: &Option<String>, hash: &str| -> Option<String> {+ file.as_ref().and_then(|f| curl(&format!("{WIKI}/api/v1/pages/{slug}/files/{f}?ref={hash}")).and_then(|b| String::from_utf8(b).ok()))+ };+ // component reference designators placed on the PCB+ let refs = |pcb: &str| -> std::collections::HashSet<String> {+ pcb.split("(property \"Reference\" \"").skip(1)+ .filter_map(|c| c.split('"').next())+ .filter(|r| !r.is_empty() && *r != "REF**")+ .map(String::from)+ .collect()+ };+ let hashes: Vec<String> = log.iter().filter_map(|c| c.get("hash").and_then(|h| h.as_str()).map(String::from)).collect();+ let pcbs: Vec<Option<String>> = hashes.iter().map(|h| at(&pcb, h)).collect();+ let schs: Vec<Option<String>> = hashes.iter().map(|h| at(&sch, h)).collect();+ let mut out = vec![];+ for i in 0..log.len() {+ let mut e = log[i].clone();+ // compare against the previous (older) commit — the next entry in the log+ if i + 1 < log.len() {+ e["pcb_changed"] = json!(pcbs[i] != pcbs[i + 1]);+ e["sch_changed"] = json!(schs[i] != schs[i + 1]);+ if let (Some(n), Some(o)) = (&pcbs[i], &pcbs[i + 1]) {+ let (rn, ro) = (refs(n), refs(o));+ e["comp_added"] = json!(rn.difference(&ro).count());+ e["comp_removed"] = json!(ro.difference(&rn).count());+ }+ }+ out.push(e);+ }+ json!({ "activity": out })+}+ /// List the file names on a page (robust to any MPN→filename convention — the /// slug can't be uppercased to the filename when the MPN has `/`, `.`, etc.). fn list_page_files(slug: &str) -> Vec<String> {
src/manage.html+54−7@@ -19,6 +19,15 @@ #crumb a:hover{text-decoration:underline} #crumb .cactive{color:var(--ink);font-weight:600} .row .tag{display:inline-block;font-size:10px;background:#243049;color:var(--acc);border-radius:4px;padding:1px 5px;margin-left:6px;vertical-align:1px}+ .tag{display:inline-block;font-size:10px;background:#243049;color:var(--acc);border-radius:4px;padding:1px 6px;margin-left:6px;vertical-align:2px}+ .row .row-tags{margin-top:4px}+ .row .row-tags .tag{margin-left:0;margin-right:6px;vertical-align:0}+ .tag.kind.board{background:#16324f;color:#79b8ff}+ .tag.kind.molecule{background:#3d3416;color:#f5c518}+ .tag.eda:empty{display:none}+ .tag.eda.kicad{background:#0b2036;color:#5c9ce6}+ .tag.eda.altium{background:#3d3416;color:#f5c518}+ .tag.eda.fusion{background:#452510;color:#ff8c33} .cathead{padding:7px 16px 5px;font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:var(--acc);background:#12151c;border-bottom:1px solid var(--edge);font-weight:700;position:sticky;top:0;z-index:1} .tabs{display:flex;border-bottom:1px solid var(--edge)} .tabs .tab{flex:1;background:none;border:none;border-bottom:2px solid transparent;color:var(--mut);padding:10px 4px;font-size:12px;cursor:pointer;white-space:nowrap}@@ -130,6 +139,10 @@ .bo-log .commit:last-child{border-bottom:none} .commit-msg{font-size:13px;color:var(--ink)} .commit-meta{font-size:11.5px;color:var(--mut);margin-top:2px}+ .commit-chips{display:flex;gap:5px;flex-wrap:wrap;margin:3px 0}+ .chg{font-size:10px;padding:1px 7px;border-radius:4px;background:#21262e;color:var(--mut);white-space:nowrap}+ .chg.add{color:#4ade80}+ .chg.rm{color:#f87171} .commit-hash{font-family:ui-monospace,monospace;background:#0d0f14;border:1px solid var(--edge);border-radius:4px;padding:0 5px} .cols{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px} .card{background:var(--panel);border:1px solid var(--edge);border-radius:12px;overflow:hidden;display:flex;flex-direction:column}@@ -278,11 +291,25 @@ function renderBoards(){ } // A board row. Boards you've starred on the wiki show a ★ and pin to the top of // their list (starred status comes from the wiki API; there's no toggle here).+const EDA_LABEL={kicad:'KiCad',altium:'Altium',fusion:'Fusion'};+function boardKind(c){return (c.tags||[]).map(x=>(''+x).toLowerCase()).includes('molecule')?'molecule':'board';}+let edaCache={};+function loadEda(slug){+ const fill=eda=>document.querySelectorAll('.eda[data-eda="'+CSS.escape(slug)+'"]').forEach(el=>{el.textContent=EDA_LABEL[eda]||'';el.className='tag eda '+(eda||'');});+ if(edaCache[slug]!==undefined){fill(edaCache[slug]);return;}+ fetch('api/board-eda/'+encodeURIComponent(slug)).then(r=>r.json()).then(d=>{edaCache[slug]=d.eda||'';fill(edaCache[slug]);}).catch(()=>{});+} function boardRow(c){ const d=document.createElement('div');d.className='row';d.dataset.slug=c.slug; const star=c.starred?`<span class="star" title="starred on the wiki">★</span>`:'';- d.innerHTML=`<div class="mpn">${star}${c.title||c.slug}<span class="tag">board</span></div><div class="sub">${c.slug}</div>`;+ const kind=boardKind(c);+ // eda arrives with the list (computed server-side in parallel); only fall back+ // to the per-row fetch when it's absent.+ const edaKnown=c.eda!==undefined;+ const edaSpan=edaKnown?`<span class="tag eda ${c.eda}">${EDA_LABEL[c.eda]||''}</span>`:`<span class="tag eda" data-eda="${c.slug}"></span>`;+ d.innerHTML=`<div class="mpn">${star}${c.title||c.slug}</div><div class="sub">${c.slug}</div><div class="row-tags"><span class="tag kind ${kind}">${kind}</span>${edaSpan}</div>`; d.onclick=()=>openBoard(c.slug);+ if(!edaKnown)loadEda(c.slug); return d; } function sortBoards(list){@@ -311,8 +338,11 @@ async function openBoard(slug){ document.getElementById('crumb').innerHTML=`<a href="#" onclick="backToBoards();return false">← Boards</a> / <span class="cactive">${title}</span>`; renderBoardOverview(slug); rowsEl().innerHTML='<div class="row" style="color:#8b93a3;cursor:default">loading dependencies…</div>';- try{ const r=await fetch('api/board/'+slug).then(r=>r.json()); curDeps=(r.deps||[]).map(d=>d.slug); fillBoardInfo(slug,r.log); }- catch(e){ curDeps=[]; }+ try{ const r=await fetch('api/board/'+slug).then(r=>r.json());+ if(boardSlug!==slug)return; // navigated back/away while loading — don't paint stale parts+ curDeps=(r.deps||[]).map(d=>d.slug); fillBoardInfo(slug,r.log); }+ catch(e){ if(boardSlug!==slug)return; curDeps=[]; }+ if(boardSlug!==slug)return; renderDeps(); } // The board overview (top of the right pane): name + author, a 3D view of the@@ -323,7 +353,7 @@ function renderBoardOverview(slug){ view.classList.remove('split'); document.getElementById('component-view').innerHTML=''; document.getElementById('board-overview').innerHTML=`- <div class="bo-head"><h2 class="title">${c.title||slug}</h2><div class="bo-author">by ${c.owner||'unknown'}${c.org?` · ${c.org}`:''}</div></div>+ <div class="bo-head"><h2 class="title">${c.title||slug}<span class="tag kind ${boardKind(c)}">${boardKind(c)}</span><span class="tag eda" data-eda="${slug}"></span></h2><div class="bo-author">by ${c.owner||'unknown'}${c.org?` · ${c.org}`:''}</div></div> <div class="bo-view bo-3d-wrap"><div class="bo-3d" id="bo3d"><div class="bo-na">loading 3D…</div></div><button class="bo-fs bo-fs-abs" title="fullscreen" onclick="toggleFs(this)">⛶</button></div> <div class="bo-views"> <div class="bo-view"><button class="bo-fs bo-fs-abs" title="fullscreen" onclick="toggleFs(this)">⛶</button><div class="bo-view-hd"><h3>Schematic</h3><select id="bo-sheet" class="bo-sheet" style="display:none"></select></div><div class="bo-viewbody" id="bo-sch"><div class="bo-na">rendering…</div></div></div>@@ -331,6 +361,7 @@ function renderBoardOverview(slug){ </div> <div class="bo-section"><h3>Activity</h3><div class="bo-log" id="bo-log"><div class="bo-na" style="text-align:left;padding:8px 0">loading…</div></div></div>`; loadBoardMedia(slug);+ loadEda(slug); } function esc(s){return (s||'').replace(/[&<>]/g,m=>({'&':'&','<':'<','>':'>'}[m]));} function timeago(ts){@@ -339,10 +370,26 @@ function timeago(ts){ const h=Math.floor(m/60);if(h<24)return h+'h ago';const d=Math.floor(h/24);if(d<30)return d+'d ago'; const mo=Math.floor(d/30);if(mo<12)return mo+'mo ago';return Math.floor(mo/12)+'y ago'; }+function commitHtml(c){+ const chips=[];+ if(c.comp_added)chips.push(`<span class="chg add">+${c.comp_added} component${c.comp_added>1?'s':''}</span>`);+ if(c.comp_removed)chips.push(`<span class="chg rm">−${c.comp_removed} component${c.comp_removed>1?'s':''}</span>`);+ if(c.pcb_changed)chips.push(`<span class="chg">PCB changed</span>`);+ if(c.sch_changed)chips.push(`<span class="chg">schematic changed</span>`);+ const chipsHtml=chips.length?`<div class="commit-chips">${chips.join('')}</div>`:'';+ return `<div class="commit"><div class="commit-msg">${esc(c.message||'')}</div>${chipsHtml}<div class="commit-meta">${esc(c.author||'')} · ${timeago(c.timestamp)} · <span class="commit-hash">${esc((c.hash||'').slice(0,7))}</span></div></div>`;+} function fillBoardInfo(slug,log){ if(boardSlug!==slug)return; const lg=document.getElementById('bo-log');- if(lg)lg.innerHTML=(log&&log.length)?log.map(c=>`<div class="commit"><div class="commit-msg">${esc(c.message||'')}</div><div class="commit-meta">${esc(c.author||'')} · ${timeago(c.timestamp)} · <span class="commit-hash">${esc((c.hash||'').slice(0,7))}</span></div></div>`).join(''):'<div class="bo-na" style="text-align:left">no commits</div>';+ if(!lg)return;+ lg.innerHTML=(log&&log.length)?log.map(commitHtml).join(''):'<div class="bo-na" style="text-align:left">no commits</div>';+ // enrich each entry with what changed (components +/-, PCB/schematic) — lazily,+ // since it re-fetches each commit's PCB/schematic to diff them+ fetch('api/board-activity/'+slug).then(r=>r.json()).then(d=>{+ if(boardSlug!==slug)return;+ const a=d.activity||[]; if(a.length&&lg)lg.innerHTML=a.map(commitHtml).join('');+ }).catch(()=>{}); } async function loadBoardMedia(slug){ // service-kicad renders in the background (a board GLB can take ~a minute) —@@ -391,7 +438,7 @@ function mountSvg(box,svgText,opt){ // dim the copper fills so lower layers show through (see all 4 copper layers); // keep silkscreen/edge fully opaque so they stay readable if(opt.dimFills){- const keep=new Set(['#f2eda1','#d0d2cd','#c83434']); // silk, edge, F.Cu → stay opaque+ const keep=new Set(['#f2eda1','#d0d2cd']); // silk + edge stay opaque (markings); ALL copper dims svg.querySelectorAll('[style*="fill"]').forEach(el=>{ const m=(el.getAttribute('style')||'').match(/fill:\s*(#[0-9a-fA-F]{6})/); if(!m)return;@@ -402,7 +449,7 @@ function mountSvg(box,svgText,opt){ const circles=el.querySelectorAll('[r]'); if(circles.length) circles.forEach(ci=>{ ci.style.fill = (parseFloat(ci.getAttribute('r')||'0')*2 < 0.5) ? '#e3b72e' : '#0d0f14'; }); else el.style.fill='#0d0f14';- } else if(!keep.has(c)){ el.style.fillOpacity=opt.dimFills; } // dim inner/bottom copper+ } else if(!keep.has(c)){ el.style.fillOpacity=opt.dimFills; } // dim all copper (top/bottom/inner) }); } // remove the full-page paper rect (crop to the content on the box background)