← Commit history

v0.4.1: Fusion GLB orientation fix, viewer loading spinners, public-page search/filter tweaks, real org avatars, one-line org rows

barrett-land ·d2425df3c3 ·3mo ago ·parent cc5c62a
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');