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.4.1: Fusion GLB orientation fix, viewer loading spinners, public-page search/filter tweaks, real org avatars, one-line org rows
2 files changed
+285−40
src/main.rs+57−2@@ -283,6 +283,10 @@ 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/project/") => {+ let slug = &p["/api/project/".len()..];+ json_resp(&project_boards(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())@@ -295,6 +299,38 @@ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> { let slug = &p["/api/board-media/".len()..]; json_resp(&board_media(slug).to_string()) }+ (Method::Get, p) if p.starts_with("/board-hero/") => {+ // Proxy a page's hero image (used as the project 3D-view fallback). Resolve+ // the hero descriptor → its image URL → serve the bytes. Works for billboard+ // SVG heroes and plain-image heroes alike, and avoids a cross-origin fetch.+ let slug = &p["/board-hero/".len()..];+ let hero = curl(&format!("{WIKI}/api/v1/pages/{slug}/hero"))+ .and_then(|b| serde_json::from_slice::<Value>(&b).ok());+ let ct = hero.as_ref().and_then(|h| h.get("content_type").and_then(|c| c.as_str()))+ .unwrap_or("image/png").to_string();+ let img = hero.as_ref()+ .and_then(|h| h.get("url").and_then(|u| u.as_str()))+ .and_then(|u| { let full = if u.starts_with("http") { u.to_string() } else { format!("{WIKI}{u}") }; curl(&full) });+ match img {+ Some(bytes) => Response::from_data(bytes).with_header(hdr(&ct)),+ None => Response::from_string("no hero").with_status_code(404),+ }+ }+ (Method::Get, p) if p.starts_with("/org-avatar/") => {+ // Proxy an organization's real avatar. Org avatars live in Carbon/Hydrogen; the+ // public Carbon API (carbon.adom.inc/orgs/<org>) returns an avatar_url on the+ // public cdn.adom.inc, which we fetch and serve (avoids a cross-origin fetch).+ let org = &p["/org-avatar/".len()..];+ let org = org.split(['/', '?']).next().unwrap_or(org);+ let img = curl(&format!("https://carbon.adom.inc/orgs/{org}"))+ .and_then(|b| serde_json::from_slice::<Value>(&b).ok())+ .and_then(|m| m.get("avatar_url").and_then(|u| u.as_str()).map(String::from))+ .and_then(|u| curl(&u));+ match img {+ Some(bytes) => Response::from_data(bytes).with_header(hdr("image/png")),+ None => Response::from_string("no avatar").with_status_code(404),+ }+ } (Method::Get, p) if p.starts_with("/board-svg/") => { // forms: /board-svg/<slug>/pcb or /board-svg/<slug>/sch/<idx> [?v=<version>] let (path, ver) = split_ver(p);@@ -473,7 +509,8 @@ fn list_components() -> Value { let is_starred = pg.get("starred").and_then(|s| s.as_bool()).unwrap_or(false); let visibility = pg.get("visibility").and_then(|s| s.as_str()).unwrap_or("").to_string(); let version = pg.get("version").and_then(|s| s.as_str()).unwrap_or("").to_string();- items.push(json!({"slug": slug, "title": title, "brief": brief, "tags": tags, "owner": owner, "org": org, "starred": is_starred, "visibility": visibility, "version": version}));+ let score = pg.get("composite_score").and_then(|s| s.as_f64()).unwrap_or(0.0);+ items.push(json!({"slug": slug, "title": title, "brief": brief, "tags": tags, "owner": owner, "org": org, "starred": is_starred, "visibility": visibility, "version": version, "score": score})); } offset += n; let total = v.get("total").and_then(|t| t.as_u64()).unwrap_or(0) as usize;@@ -600,6 +637,18 @@ fn board_deps(slug: &str) -> Value { "hints": ["board → component library → components (the library's dependencies)"] }) } +/// A PROJECT's direct board dependencies — a page tagged `project` depends on+/// boards (not components), so unlike `board_deps` these are NOT expanded. The+/// Manager lists them, and clicking one opens it as a normal board.+fn project_boards(slug: &str) -> Value {+ let boards: Vec<Value> = deps_of(slug).into_iter().map(|s| json!({"slug": s})).collect();+ let log = curl(&format!("{WIKI}/api/v1/pages/{slug}/log"))+ .and_then(|b| serde_json::from_slice::<Value>(&b).ok())+ .and_then(|v| v.get("log").cloned())+ .unwrap_or_else(|| json!([]));+ json!({ "status": "ok", "project": slug, "boards": boards, "log": log })+}+ /// The board's EDA toolchain, inferred from the files on its page: /// "kicad" | "altium" | "fusion" | "". /// Every EDA toolchain whose files are present on the board — a board can carry@@ -1727,7 +1776,13 @@ fn board_media(slug: &str) -> Value { // Only when these are absent is the board truly un-renderable → show the red bar. let has_assets = has(".kicad_pcb") && has(".kicad_sch"); if !is_kicad {- return json!({"rendering": false, "is_kicad": false, "has_render": false, "has_assets": false, "sheets": [], "has_3d": false, "has_pcb": false});+ // Not a KiCad board (EAGLE/Fusion .brd/.sch, or STEP/GLB-only). We can't+ // render the 2D PCB/schematic (service-kicad is KiCad-only), but if the page+ // ships a model GLB we can still show the 3D — served by the board-glb route's+ // <slug>.glb fallback. So report has_3d from the GLB rather than giving up.+ let has_3d = has(".glb");+ return json!({"rendering": false, "is_kicad": false, "has_render": false,+ "has_assets": has_3d, "sheets": [], "has_3d": has_3d, "has_pcb": false}); } let start = { let mut g = BOARD_RENDERING.lock().unwrap();
src/manage.html+228−38@@ -22,8 +22,10 @@ .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{text-transform:capitalize} .tag.kind.board{background:#16324f;color:#79b8ff} .tag.kind.molecule{background:#463307;color:#f0a81c}+ .tag.kind.project{background:#0e3b36;color:#2dd4bf} .tag.eda:empty{display:none} .tag.eda.kicad{background:#0b2036;color:#5c9ce6} .tag.eda.altium{background:#3d3416;color:#f5c518}@@ -35,12 +37,22 @@ .fchip{font-size:10px;font-weight:600;border-radius:5px;padding:2px 8px;cursor:pointer;border:1px solid var(--edge);background:#12151c;color:var(--mut);user-select:none;text-transform:capitalize;transition:background .12s,color .12s,border-color .12s} .fchip:hover{border-color:var(--acc)} .fchip.on{border-color:transparent}+ .fchip.on.project{background:#0e3b36;color:#2dd4bf} .fchip.on.molecule{background:#463307;color:#f0a81c} .fchip.on.board{background:#16324f;color:#79b8ff} .fchip.on.kicad{background:#0b2036;color:#5c9ce6} .fchip.on.altium{background:#3d3416;color:#f5c518} .fchip.on.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}+ .pubback{cursor:pointer}+ .pubback:hover{color:var(--ink)}+ .orgrow{display:flex;align-items:center;gap:11px}+ .orgrow .orgicon{width:36px;height:36px;flex:none;color:#2dd4bf;opacity:.9;padding:6px;box-sizing:border-box;background:#1a1f2b;border-radius:8px}+ .orgrow .orgicon-img{width:36px;height:36px;flex:none;border-radius:8px;object-fit:cover;background:#1a1f2b}+ .orgrow .orgname{font-weight:600;color:var(--ink)}+ .orgrow .orgmeta{color:var(--mut);font-size:12.5px}+ .orgrow .scorebadge{margin-left:0}+ .scorebadge{font-size:10px;font-weight:700;color:#f0a81c;background:#463307;border-radius:4px;padding:1px 6px;margin-left:8px;vertical-align:1px} .subhead{padding:6px 16px 4px 20px;font-size:9.5px;text-transform:uppercase;letter-spacing:.09em;color:var(--mut);background:#0f1218;font-weight:700} .row .sub .auth{color:#7f8a9e} .row .sub .auth::before{content:'· '}@@ -152,6 +164,8 @@ .bo-head{padding:16px 20px 12px;border-bottom:1px solid var(--edge)} .bo-head .title{margin:0;font-size:20px;font-weight:700} .bo-author{color:var(--mut);font-size:13px;margin-top:3px}+ .bo-hero{max-width:100%;max-height:100%;object-fit:contain;display:block;margin:auto;border-radius:8px}+ .bo-desc{color:var(--ink);font-size:13px;line-height:1.55} .bo-verwrap{position:relative;display:inline-block;margin-left:8px;vertical-align:3px} .bo-ver{position:relative;display:inline-block;font-size:11px;font-weight:600;color:var(--acc);background:#12151c;border:1px solid var(--edge);border-radius:6px;padding:2px 8px;cursor:pointer;user-select:none} .bo-ver:hover{border-color:var(--acc)}@@ -192,6 +206,11 @@ .bo-viewbody{height:260px;background:#161b22;border:1px solid var(--edge);border-radius:8px;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative;touch-action:none} .bo-viewbody svg{width:100%;height:100%;display:block} .bo-na{color:#8b93a3;font-size:13px;text-align:center;padding:20px;line-height:1.5}+ /* In-viewer loading spinner (3D / schematic / PCB): Adom mark + percent below */+ .vload{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;width:100%;gap:10px;background:#0d0f14}+ .vload.ov{position:absolute;inset:0;z-index:5}+ .vload img{width:72px;height:72px;object-fit:contain;image-rendering:auto}+ .vload .vload-pct{color:#9fb0c0;font-size:12px;letter-spacing:.06em;font-variant-numeric:tabular-nums} .bo-section{padding:0 20px 20px} .bo-section>h3{margin:0 0 8px;font-size:12px;color:var(--mut);text-transform:uppercase;letter-spacing:.04em;border-bottom:1px solid var(--edge);padding-bottom:6px} .bo-log .commit{padding:8px 0;border-bottom:1px solid var(--edge)}@@ -275,6 +294,7 @@ const COLORS={Silkscreen:'#d8b4fe',Courtyard:'#4ade80','Fabrication / Assembly': // Board-centric navigation: Boards (tagged board/molecule) ▸ a board's component // dependencies ▸ the symbol/footprint/3D viewer. let all=[], bySlug={}, boardSlug=null, curDeps=[], me='', pubQuery='', orgs=[], boardTab='mine';+let projectSlug=null, projectBoards=[]; // when a PROJECT is open: its board deps (project → boards → components) // the user's machine env (OS + home + default delivery folders), from /api/env let env={os:'linux',home:'',sep:'/',dest_folders:[]}; // the user's REAL KiCad libraries (name+uri), from /api/libraries; curPlan = the@@ -282,6 +302,8 @@ let env={os:'linux',home:'',sep:'/',dest_folders:[]}; let userLibs={symLibs:[],fpLibs:[]}, curPlan=[]; function destFolders(){ return (env.dest_folders&&env.dest_folders.length)?env.dest_folders:[(env.home||'~')+env.sep+'Documents'+env.sep+'KiCad'+env.sep+'Projects']; } const isBoard=c=>{const t=(c.tags||[]).map(x=>(''+x).toLowerCase());return t.includes('board')||t.includes('molecule');};+const isProject=c=>((c.tags||[]).map(x=>(''+x).toLowerCase())).includes('project');+const isListable=c=>isBoard(c)||isProject(c); const qval=()=>document.getElementById('q').value.toLowerCase(); const rowsEl=()=>document.getElementById('rows'); function makeRow(slug,title,sub,onclick,tag,sel){@@ -335,33 +357,46 @@ async function loadList(fresh){ rowsEl().innerHTML='<div class="row" style="color:#f87171;cursor:default">failed to load — retry</div>'; }finally{ if(btn)btn.classList.remove('spin'); } }-function render(){ boardSlug?renderDeps():renderBoards(); }+function render(){ if(boardSlug)renderDeps(); else if(projectSlug)renderProjectBoards(); else renderBoards(); } // The Personal/Organization/Public nav lives in a persistent bar (not inside the // scrolling list), so it's always available — even while a board is open. Clicking // a tab leaves the current project and returns you to that board list. function renderTabs(){- const boards=all.filter(isBoard);- const mineAll=boards.filter(c=>c.owner&&c.owner===me&&!c.org).length;+ const boards=all.filter(isListable);+ const mineAll=boards.filter(c=>c.owner&&c.owner===me).length; const orgAll=boards.filter(c=>c.org&&orgs.includes(c.org)).length; const defs=[['mine',`Personal · ${mineAll}`],['org',`Organization · ${orgAll}`],['public','Public']]; const bar=document.getElementById('tabbar'); bar.innerHTML=defs.map(([k,label])=>`<button class="tab${boardTab===k?' active':''}" data-tab="${k}">${label}</button>`).join('');- bar.querySelectorAll('.tab').forEach(t=>t.onclick=()=>{ boardTab=t.dataset.tab; boardSlug?backToBoards():renderBoards(); });+ bar.querySelectorAll('.tab').forEach(t=>t.onclick=()=>{ boardTab=t.dataset.tab; backToTop(); }); } // Tag filter chips (kind: molecule/board · EDA: kicad/altium/fusion). Faceted: // AND across the two facets, OR within a facet. Empty = show everything. let filterTags=new Set();-const FILTER_CHIPS=[['molecule','Molecule'],['board','Board'],['kicad','KiCad'],['altium','Altium'],['fusion','Fusion']];-function renderFilterBar(){- const el=document.getElementById('tagfilter'); if(!el)return;- el.style.display='';+const FILTER_CHIPS=[['project','Project'],['molecule','Molecule'],['board','Board'],['kicad','KiCad'],['altium','Altium'],['fusion','Fusion']];+// Render the tag-filter chips into any container (shared by the top bar and the public search).+function fillChips(el){+ if(!el)return; el.innerHTML=FILTER_CHIPS.map(([k,label])=>`<span class="fchip ${k}${filterTags.has(k)?' on':''}" data-t="${k}">${label}</span>`).join(''); el.querySelectorAll('.fchip').forEach(ch=>ch.onclick=()=>{ const t=ch.dataset.t; filterTags.has(t)?filterTags.delete(t):filterTags.add(t); renderBoards(); }); }+function renderFilterBar(){+ const el=document.getElementById('tagfilter'); if(!el)return;+ // Public page renders its chips BELOW the public search box (inside renderPublic), so hide the top bar there.+ if(boardTab==='public'){ el.style.display='none'; el.innerHTML=''; return; }+ el.style.display=''; fillChips(el);+} function boardEdaOf(c){ return Array.isArray(c.eda)?c.eda : (Array.isArray(edaCache[c.slug])?edaCache[c.slug]:null); }+// Fusion/EAGLE (.brd/.sch) STEP exports are Z-up; glTF/model-viewer is Y-up, so those GLBs+// render on their edge ("sideways"). KiCad exports are already Y-up (correct). We fix it+// client-side (leaving the GLBs + the wiki untouched) by tipping only Fusion boards -90deg+// about X via model-viewer's `orientation` (roll pitch yaw). Tunable if the sign is off.+const FUSION_ORIENT='0deg -90deg 0deg';+function isFusionBoard(slug){ const e=boardEdaOf(bySlug[slug]||{slug}); return !!(e&&e.includes('fusion')&&!e.includes('kicad')); }+function mvOrient(slug){ return isFusionBoard(slug)?` orientation="${FUSION_ORIENT}"`:''; } function matchesFilter(c){ if(!filterTags.size) return true;- const kindSel=['molecule','board'].filter(t=>filterTags.has(t));+ const kindSel=['project','molecule','board'].filter(t=>filterTags.has(t)); const edaSel=['kicad','altium','fusion'].filter(t=>filterTags.has(t)); if(kindSel.length && !kindSel.includes(boardKind(c))) return false; if(edaSel.length){ const eda=boardEdaOf(c); if(!eda||!edaSel.some(t=>eda.includes(t))) return false; }@@ -372,8 +407,8 @@ function renderBoards(){ document.getElementById('crumb').innerHTML='<span class="cactive">Projects</span>'; const q=qval(),el=rowsEl();el.innerHTML=''; renderTabs(); renderFilterBar();- const boards=all.filter(isBoard);- const mineAll=boards.filter(c=>c.owner&&c.owner===me&&!c.org);+ const boards=all.filter(isListable);+ const mineAll=boards.filter(c=>c.owner&&c.owner===me); const orgAll=boards.filter(c=>c.org&&orgs.includes(c.org)); const qbox=document.getElementById('q');qbox.style.display=boardTab==='public'?'none':'';qbox.placeholder='search projects…'; if(boardTab==='public'){renderPublic(boards.filter(matchesFilter),el);return;}@@ -403,7 +438,7 @@ 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';}+function boardKind(c){const t=(c.tags||[]).map(x=>(''+x).toLowerCase());return t.includes('project')?'project':t.includes('molecule')?'molecule':'board';} let edaCache={}; // a board can carry more than one toolchain (e.g. KiCad + Fusion) — render one tag each. function edaTags(list){return (list||[]).map(e=>`<span class="tag eda ${e}">${EDA_LABEL[e]||''}</span>`).join('');}@@ -465,34 +500,73 @@ function boardRow(c){ const visClass=vis==='private'?'private':(vis==='public'||vis==='open')?'public':''; const visTag=vis?`<span class="tag vis ${visClass}">${vis==='open'?'public':vis}</span>`:''; d.innerHTML=`<div class="mpn">${star}${c.title||c.slug}</div><div class="sub">${c.slug}${c.owner?`<span class="auth">${c.owner}</span>`:''}</div><div class="row-tags"><span class="tag kind ${kind}">${kind}</span>${edaSpan}${visTag}</div>`;- d.onclick=()=>openBoard(c.slug);+ d.onclick=()=>isProject(c)?openProject(c.slug):openBoard(c.slug); if(!edaKnown)loadEda(c.slug); return d; } function sortBoards(list){ return list.slice().sort((a,b)=>((a.starred?0:1)-(b.starred?0:1))||(a.title||a.slug).toLowerCase().localeCompare((b.title||b.slug).toLowerCase())); }+// The Public tab. DEFAULT (no search) = a popularity leaderboard: top 10 most+// popular public projects, then top 10 most popular orgs. Typing a query clears+// the leaderboard and shows normal filtered results. Clicking an org drills into+// that org's public projects.+// If an org has no avatar (or the proxy fails), fall back to the generic building glyph.+function orgIconErr(img){ img.outerHTML=ORG_ICON; }+const ORG_ICON='<svg class="orgicon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M3 21V6a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v3h7a1 1 0 0 1 1 1v11H3zm2-2h2v-2H5v2zm0-4h2v-2H5v2zm0-4h2V9H5v2zm0-4h2V7H5v2zm4 12h2v-2H9v2zm0-4h2v-2H9v2zm0-4h2V9H9v2zm0-4h2V7H9v2zm4 12h6v-2h-6v2zm2-4h2v-2h-2v2zm0-4h2v-2h-2v2z"/></svg>';+let pubOrg=null, pubBoards=[]; function renderPublic(boards,el){+ pubBoards=boards.filter(c=>{const v=(c.visibility||'').toLowerCase();return v==='public'||v==='open';}); const wrap=document.createElement('div');wrap.className='pubsearch';- wrap.innerHTML=`<div class="pubrow"><input id="pubq" placeholder="search public projects"><button id="pubgo" title="search">⏎</button></div><div id="pubresults"></div>`;+ wrap.innerHTML=`<div class="pubrow"><input id="pubq" placeholder="search public projects"></div><div id="tagfilter-pub" class="tagfilter"></div><div id="pubresults"></div>`; el.appendChild(wrap);+ fillChips(document.getElementById('tagfilter-pub')); // tag filters sit BELOW the search on the public page const pubq=document.getElementById('pubq');pubq.value=pubQuery;- const renderPub=()=>{- const pr=document.getElementById('pubresults');pr.innerHTML='';const pq=pubQuery.trim().toLowerCase();- if(!pq)return;- const m=boards.filter(c=>c.slug.includes(pq)||(c.title||'').toLowerCase().includes(pq)||(c.brief||'').toLowerCase().includes(pq));+ const doPub=()=>{pubQuery=pubq.value;pubOrg=null;renderPubResults();};+ pubq.oninput=()=>{ pubQuery=pubq.value; pubOrg=null; if(!pubq.value.trim()){pubQuery='';} renderPubResults(); }; // live search; cleared → leaderboard+ pubq.onkeydown=e=>{if(e.key==='Enter')doPub();};+ renderPubResults();+}+function renderPubResults(){+ const pr=document.getElementById('pubresults'); if(!pr)return; pr.innerHTML='';+ const pub=pubBoards;+ if(pubOrg){ // drilled into one org+ const back=document.createElement('div');back.className='cathead pubback';back.innerHTML=`← ${pubOrg} · public projects`;back.onclick=()=>{pubOrg=null;renderPubResults();};pr.appendChild(back);+ sortBoards(pub.filter(c=>c.org===pubOrg)).forEach(c=>pr.appendChild(boardRow(c)));+ return;+ }+ const pq=pubQuery.trim().toLowerCase();+ if(pq){ // normal search+ const m=pub.filter(c=>c.slug.includes(pq)||(c.title||'').toLowerCase().includes(pq)||(c.brief||'').toLowerCase().includes(pq)); if(!m.length){pr.innerHTML=`<div class="row" style="color:#8b93a3;cursor:default">no public projects match “${pubQuery.trim()}”</div>`;return;} sortBoards(m).forEach(c=>pr.appendChild(boardRow(c)));- };- const doPub=()=>{pubQuery=pubq.value;renderPub();};- pubq.onkeydown=e=>{if(e.key==='Enter')doPub();};- document.getElementById('pubgo').onclick=doPub;- renderPub();+ return;+ }+ // DEFAULT leaderboard: top 10 popular projects+ const topBoards=[...pub].sort((a,b)=>(b.score||0)-(a.score||0)).slice(0,10);+ const h1=document.createElement('div');h1.className='cathead';h1.textContent='Trending';pr.appendChild(h1);+ if(!topBoards.length){const d=document.createElement('div');d.className='row';d.style.cssText='color:#8b93a3;cursor:default';d.textContent='no public projects yet';pr.appendChild(d);}+ topBoards.forEach(c=>{const row=boardRow(c);const m=row.querySelector('.mpn');if(m)m.insertAdjacentHTML('beforeend',` <span class="scorebadge" title="popularity score">▲ ${Math.round(c.score||0)}</span>`);pr.appendChild(row);});+ // top 10 popular organizations (by aggregate score of their public projects)+ const agg={};+ pub.forEach(c=>{const o=c.org;if(o){const a=agg[o]=agg[o]||{score:0,count:0};a.score+=(c.score||0);a.count++;}});+ const topOrgs=Object.entries(agg).sort((a,b)=>b[1].score-a[1].score).slice(0,10);+ if(topOrgs.length){+ const h2=document.createElement('div');h2.className='cathead';h2.textContent='Popular Organizations';pr.appendChild(h2);+ topOrgs.forEach(([org,a],i)=>{+ const d=document.createElement('div');d.className='row orgrow';+ d.innerHTML=`<img class="orgicon-img" src="org-avatar/${encodeURIComponent(org)}" alt="" onerror="orgIconErr(this)"><span class="orgname">${org}</span><span class="orgmeta">· ${a.count} project${a.count>1?'s':''} ·</span><span class="scorebadge" title="popularity score">▲ ${Math.round(a.score)}</span>`;+ d.onclick=()=>{pubOrg=org;renderPubResults();};+ pr.appendChild(d);+ });+ } } async function openBoard(slug){ boardSlug=slug; curDeps=[]; selected.clear(); boardVersion=null; curBoardMedia=null; const title=(bySlug[slug]||{}).title||slug;- document.getElementById('crumb').innerHTML=`<a href="#" onclick="backToBoards();return false">← Boards</a> / <span class="cactive">${title}</span>`;+ document.getElementById('crumb').innerHTML=projectSlug+ ? `<a href="#" onclick="backToTop();return false">Projects</a> / <a href="#" onclick="openProject('${projectSlug}');return false">${(bySlug[projectSlug]||{}).title||projectSlug}</a> / <span class="cactive">${title}</span>`+ : `<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());@@ -513,10 +587,10 @@ function renderBoardOverview(slug){ document.getElementById('component-view').innerHTML=''; document.getElementById('board-overview').innerHTML=` <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>${_visTag}<span id="bo-version" class="bo-verwrap"></span></h2><div class="bo-author">by ${c.owner||'unknown'}${c.org?` · ${c.org}`:''}</div><div id="bo-verbanner" class="bo-verbanner" style="display:none"></div></div>- <div class="bo-view bo-3d-wrap"><div class="bo-3d"><button class="bo-fs boxfs" title="fullscreen" onclick="toggleFs(this)">⛶</button><div class="bo-boxinner" id="bo3d"><div class="bo-na">loading 3D…</div></div></div></div>+ <div class="bo-view bo-3d-wrap"><div class="bo-3d"><button class="bo-fs boxfs" title="fullscreen" onclick="toggleFs(this)">⛶</button><div class="bo-boxinner" id="bo3d">${viewerLoaderHTML('loading…')}</div></div></div> <div class="bo-views">- <div class="bo-view"><div class="bo-view-hd"><h3>Schematic</h3></div><div class="bo-viewbody"><button class="bo-fs boxfs" title="fullscreen" onclick="toggleFs(this)">⛶</button><select id="bo-sheet" class="bo-sheet boxsheet" style="display:none"></select><div class="bo-boxinner" id="bo-sch"><div class="bo-na">rendering…</div></div></div></div>- <div class="bo-view"><div class="bo-view-hd"><h3>PCB</h3></div><div class="bo-viewbody"><button class="bo-fs boxfs" title="fullscreen" onclick="toggleFs(this)">⛶</button><div class="bo-boxinner" id="bo-pcb"><div class="bo-na">rendering…</div></div></div></div>+ <div class="bo-view"><div class="bo-view-hd"><h3>Schematic</h3></div><div class="bo-viewbody"><button class="bo-fs boxfs" title="fullscreen" onclick="toggleFs(this)">⛶</button><select id="bo-sheet" class="bo-sheet boxsheet" style="display:none"></select><div class="bo-boxinner" id="bo-sch">${viewerLoaderHTML('rendering…')}</div></div></div>+ <div class="bo-view"><div class="bo-view-hd"><h3>PCB</h3></div><div class="bo-viewbody"><button class="bo-fs boxfs" title="fullscreen" onclick="toggleFs(this)">⛶</button><div class="bo-boxinner" id="bo-pcb">${viewerLoaderHTML('rendering…')}</div></div></div> </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);@@ -578,11 +652,8 @@ async function loadBoardMedia(slug){ } if(boardSlug!==slug)return; showRenderWarning(slug,m);- const d3=document.getElementById('bo3d'),sch=document.getElementById('bo-sch'),pcb=document.getElementById('bo-pcb');- if(m.is_kicad===false){- const na='<div class="bo-na">board type not supported</div>';- if(d3)d3.innerHTML=na;if(sch)sch.innerHTML=na;if(pcb)pcb.innerHTML=na;return;- }+ // non-KiCad boards (EAGLE/Fusion, or 3D-only) still show their 3D via the model+ // GLB; the 2D PCB/schematic panes just render "not available" (no KiCad renderer). curBoardMedia=m; boardVersion=null; renderVersionPill(); updateVerBanner(); await mountBoardAssets(slug);@@ -595,21 +666,32 @@ function verQ(){ const m=curBoardMedia||{}; return (boardVersion && m.current && async function mountBoardAssets(slug){ const m=curBoardMedia||{}, q=verQ(); const d3=document.getElementById('bo3d'),sch=document.getElementById('bo-sch'),pcb=document.getElementById('bo-pcb');- if(d3)d3.innerHTML=m.has_3d?`<model-viewer src="board-glb/${slug}${q}" camera-controls auto-rotate shadow-intensity="1"></model-viewer>`:`<div class="bo-na">board 3D file not available</div>`;+ if(d3){+ if(m.has_3d){+ d3.style.position='relative'; d3.style.background='#0d0f14';+ d3.innerHTML=`<model-viewer src="board-glb/${slug}${q}"${mvOrient(slug)} camera-controls auto-rotate shadow-intensity="1"></model-viewer>`++ `<div class="vload ov"><img src="loader.gif" alt=""></div>`;+ const mv=d3.querySelector('model-viewer'), ov=d3.querySelector('.vload.ov'), pct=ov.querySelector('.vload-pct');+ const done=()=>{ if(ov)ov.remove(); };+ // model-viewer streams the GLB and reports detail.totalProgress 0→1.+ mv.addEventListener('progress',e=>{ const p=e.detail&&e.detail.totalProgress; if(p!=null){ setViewerPct(pct,p); if(p>=1)done(); } });+ mv.addEventListener('load',done);+ } else d3.innerHTML=`<div class="bo-na">board 3D file not available</div>`;+ } const sheets=m.sheets||[],sel=document.getElementById('bo-sheet'); if(sch){ if(sheets.length){ if(sel){ sel.innerHTML=sheets.map((n,i)=>`<option value="${i}">${n}</option>`).join(''); sel.style.display=sheets.length>1?'':'none';- sel.onchange=async()=>{ if(boardSlug!==slug)return; sch.innerHTML='<div class="bo-na">loading…</div>'; mountSvg(sch, await fetch(`board-svg/${slug}/sch/${sel.value}${verQ()}`).then(r=>r.text()), {light:true}); };+ sel.onchange=async()=>{ if(boardSlug!==slug)return; const p=showViewerLoader(sch); mountSvg(sch, await fetchTextProgress(`board-svg/${slug}/sch/${sel.value}${verQ()}`,p), {light:true}); }; }- sch.innerHTML='<div class="bo-na">loading…</div>';- mountSvg(sch, await fetch(`board-svg/${slug}/sch/0${q}`).then(r=>r.text()), {light:true});+ const p=showViewerLoader(sch);+ mountSvg(sch, await fetchTextProgress(`board-svg/${slug}/sch/0${q}`,p), {light:true}); } else { if(sel)sel.style.display='none'; sch.innerHTML='<div class="bo-na">schematic view not available</div>'; } } if(boardSlug!==slug)return;- if(pcb){ if(m.has_pcb){ pcb.innerHTML='<div class="bo-na">loading…</div>'; mountSvg(pcb, await fetch(`board-svg/${slug}/pcb${q}`).then(r=>r.text()), {dimFills:0.4}); } else pcb.innerHTML='<div class="bo-na">PCB view not available</div>'; }+ if(pcb){ if(m.has_pcb){ const p=showViewerLoader(pcb); mountSvg(pcb, await fetchTextProgress(`board-svg/${slug}/pcb${q}`,p), {dimFills:0.4}); } else pcb.innerHTML='<div class="bo-na">PCB view not available</div>'; } } function selectVersion(v){ const m=curBoardMedia||{};@@ -619,7 +701,7 @@ function selectVersion(v){ function renderVersionPill(){ const el=document.getElementById('bo-version'); if(!el)return; const m=curBoardMedia||{}, vers=Array.isArray(m.versions)?m.versions:[];- const pkgVer=(bySlug[boardSlug]||{}).version; // wiki package version (always present)+ const pkgVer=(bySlug[boardSlug||projectSlug]||{}).version; // wiki package version (always present) const cur=boardVersion||m.current||(vers[0]&&vers[0].version)||pkgVer; if(!cur){ el.innerHTML=''; return; } // no version info at all if(vers.length>1){ // multiple archived → clickable dropdown@@ -634,6 +716,42 @@ function updateVerBanner(){ b.style.display=off?'':'none'; if(off)b.innerHTML=`Viewing <b>v${boardVersion}</b> — not the current version. <a href="#" onclick="selectVersion(null);return false">Back to v${m.current}</a>`; }+// Loading UI shared by the 3D / schematic / PCB viewers: the Adom mark + a percent below.+function setViewerPct(el,frac){ if(el) el.textContent=Math.round(Math.max(0,Math.min(1,frac))*100)+'%'; }+// Adom-mark spinner markup with a status/percent label below it (default "0%").+function viewerLoaderHTML(label){ return `<div class="vload"><img src="loader.gif" alt=""></div>`; }+// Fill a viewer box with the loading spinner; returns the percent/status <span> to update.+function showViewerLoader(box,label){ box.style.background='#0d0f14'; box.innerHTML=viewerLoaderHTML(label); return box.querySelector('.vload-pct'); }+// Fetch text while streaming download progress into a percent element (Content-Length based;+// falls back to KB counter if the length is unknown, then a plain text() on any stream error).+async function fetchTextProgress(url,pctEl){+ let r; try{ r=await fetch(url); }catch(e){ throw e; }+ if(!r.ok) return await r.text();+ if(!r.body||!r.body.getReader) return await r.text();+ const total=+(r.headers.get('content-length')||0);+ try{+ const reader=r.body.getReader(); let got=0; const chunks=[];+ for(;;){ const {done,value}=await reader.read(); if(done)break; chunks.push(value); got+=value.length;+ if(pctEl){ if(total>0) setViewerPct(pctEl,got/total); else pctEl.textContent=Math.round(got/1024)+' KB'; } }+ if(pctEl&&total>0) setViewerPct(pctEl,1);+ const buf=new Uint8Array(got); let off=0; for(const c of chunks){ buf.set(c,off); off+=c.length; }+ return new TextDecoder().decode(buf);+ }catch(e){ return await r.text(); }+}+// Like fetchTextProgress but returns a Blob (for the project 3D GLB, which we load via a blob URL).+async function fetchBlobProgress(url,pctEl){+ const r=await fetch(url);+ if(!r.ok) return null;+ if(!r.body||!r.body.getReader) return await r.blob();+ const total=+(r.headers.get('content-length')||0);+ try{+ const reader=r.body.getReader(); let got=0; const chunks=[];+ for(;;){ const {done,value}=await reader.read(); if(done)break; chunks.push(value); got+=value.length;+ if(pctEl){ if(total>0) setViewerPct(pctEl,got/total); else pctEl.textContent=Math.round(got/1024)+' KB'; } }+ if(pctEl&&total>0) setViewerPct(pctEl,1);+ return new Blob(chunks,{type:r.headers.get('content-type')||'model/gltf-binary'});+ }catch(e){ return await r.blob(); }+} // Inject an SVG on a dark background: drop the full-page paper rect (so the dark // box shows through AND the content crops tight via getBBox), optionally invert // for the PCB, then enable pan/zoom.@@ -737,10 +855,82 @@ function renderDeps(){ function backToBoards(){ boardSlug=null;curDeps=[];document.getElementById('q').value=''; const view=document.getElementById('view');view.classList.remove('split');+ document.getElementById('component-view').innerHTML='';+ if(projectSlug){ openProject(projectSlug); return; } // inside a project → back to its board list+ document.getElementById('board-overview').innerHTML='<div id="hint">← pick a board to see it here</div>';+ render();+}+function backToTop(){+ projectSlug=null; boardSlug=null; curDeps=[]; projectBoards=[];+ document.getElementById('q').value='';+ document.getElementById('view').classList.remove('split'); document.getElementById('board-overview').innerHTML='<div id="hint">← pick a board to see it here</div>'; document.getElementById('component-view').innerHTML=''; render(); }+// ---- Projects: a page tagged `project` whose deps are BOARDS. Opening one lists+// its boards in the left pane and shows a simple overview (3D · description ·+// activity) on the right; clicking a board opens it normally (board → components).+async function openProject(slug){+ projectSlug=slug; boardSlug=null; curDeps=[]; selected.clear(); boardVersion=null; curBoardMedia=null;+ renderProjectOverview(slug);+ rowsEl().innerHTML='<div class="row" style="color:#8b93a3;cursor:default">loading boards…</div>';+ try{ const r=await fetch('api/project/'+slug).then(r=>r.json());+ if(projectSlug!==slug)return;+ projectBoards=(r.boards||[]).map(d=>d.slug);+ fillProjectInfo(slug,r.log);+ }catch(e){ if(projectSlug!==slug)return; projectBoards=[]; }+ if(projectSlug!==slug)return;+ renderProjectBoards();+}+function renderProjectOverview(slug){+ const c=bySlug[slug]||{slug};+ const _vis=(c.visibility||'').toLowerCase();+ const _visTag=_vis?`<span class="tag vis ${_vis==='private'?'private':(_vis==='public'||_vis==='open')?'public':''}">${_vis==='open'?'public':_vis}</span>`:'';+ document.getElementById('view').classList.remove('split');+ document.getElementById('component-view').innerHTML='';+ document.getElementById('board-overview').innerHTML=`+ <div class="bo-head"><h2 class="title">${c.title||slug}<span class="tag kind project">project</span>${_visTag}<span id="bo-version" class="bo-verwrap"></span></h2><div class="bo-author">by ${c.owner||'unknown'}${c.org?` · ${c.org}`:''}</div><div id="bo-verbanner" class="bo-verbanner" style="display:none"></div></div>+ <div class="bo-view bo-3d-wrap"><div class="bo-3d"><button class="bo-fs boxfs" title="fullscreen" onclick="toggleFs(this)">⛶</button><div class="bo-boxinner" id="bo3d">${viewerLoaderHTML('loading…')}</div></div></div>+ <div class="bo-section"><h3>Description</h3><div class="bo-desc" id="bo-desc">${esc(c.brief||'')||'<span class="bo-na" style="text-align:left">no description</span>'}</div></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>`;+ curBoardMedia={}; renderVersionPill();+ loadProject3d(slug);+}+async function loadProject3d(slug){+ const d3=document.getElementById('bo3d'); if(!d3)return;+ const pct=showViewerLoader(d3); // Adom mark + percent while the GLB streams down+ try{+ const blob=await fetchBlobProgress('board-glb/'+slug,pct);+ if(projectSlug!==slug)return;+ if(blob){ const url=URL.createObjectURL(blob); d3.style.background='#0d0f14'; d3.innerHTML=`<model-viewer src="${url}"${mvOrient(slug)} camera-controls auto-rotate shadow-intensity="1"></model-viewer>`; return; }+ }catch(e){}+ if(projectSlug!==slug)return;+ // no GLB yet → fall back to the page's hero image (proxied server-side, any hero type)+ d3.innerHTML='';+ const img=document.createElement('img'); img.className='bo-hero';+ img.src='board-hero/'+slug;+ img.onerror=()=>{ if(projectSlug===slug)d3.innerHTML='<div class="bo-na">3D not available</div>'; };+ d3.appendChild(img);+}+function fillProjectInfo(slug,log){+ if(projectSlug!==slug)return;+ const lg=document.getElementById('bo-log'); if(!lg)return;+ lg.innerHTML=(log&&log.length)?log.map(commitHtml).join(''):'<div class="bo-na" style="text-align:left">no commits</div>';+}+function renderProjectBoards(){+ renderTabs();+ document.getElementById('tagfilter').style.display='none';+ document.getElementById('toolbar').style.display='none';+ const qbox=document.getElementById('q'); qbox.style.display=''; qbox.placeholder='filter boards…';+ const q=qval(),el=rowsEl();el.innerHTML='';+ const title=(bySlug[projectSlug]||{}).title||projectSlug;+ document.getElementById('crumb').innerHTML=`<a href="#" onclick="backToTop();return false">← Projects</a> / <span class="cactive">${title}</span>`;+ const boards=projectBoards.map(s=>bySlug[s]||{slug:s}).filter(c=>!q||c.slug.includes(q)||((c.title||'').toLowerCase().includes(q)));+ if(!boards.length){el.innerHTML='<div class="row" style="color:#8b93a3;cursor:default">this project lists no boards</div>';return;}+ const h=document.createElement('div');h.className='cathead';h.textContent=`Boards · ${boards.length}`;el.appendChild(h);+ sortBoards(boards).forEach(c=>el.appendChild(boardRow(c)));+} // Close the bottom-half component view (the ✕), back to just the board overview. function closeComponent(){ document.getElementById('view').classList.remove('split');