← Commit history

board view: serve the interactive adom-layout-viewer/adom-schematic-viewer embeds (per-version, viewers/<version>/), and fix /board-svg/ slug parsing for owner-qualified slugs

barrett-land ·70e9e96fc4 ·3mo ago ·parent b90fdea
2 files changed +89−12
src/main.rs+51−8
@@ -357,12 +357,25 @@ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> {         }         (Method::Get, p) if p.starts_with("/board-svg/") => {             // forms: /board-svg/<slug>/pcb  or  /board-svg/<slug>/sch/<idx>  [?v=<version>]+            //+            // The slug itself CONTAINS a '/' when it is owner-qualified (`adom/usb3-…`,+            // which is every org board), so parse the view selector off the END. Splitting+            // from the front took `adom` as the whole slug and 404'd both panes.             let (path, ver) = split_ver(url);-            let segs: Vec<&str> = path["/board-svg/".len()..].splitn(3, '/').collect();-            let slug = segs.first().copied().unwrap_or("");-            let sub = |dir: &str| match segs.get(1).copied() {-                Some("pcb") => Some(format!("{dir}pcb.svg")),-                Some("sch") => segs.get(2).and_then(|i| i.parse::<usize>().ok()).map(|i| format!("{dir}sheet-{i:03}.svg")),+            let rest = &path["/board-svg/".len()..];+            let (slug, kind, sch_idx): (&str, &str, usize) = if let Some(s) = rest.strip_suffix("/pcb") {+                (s, "pcb", 0)+            } else if let Some((head, i)) = rest.rsplit_once('/') {+                match (head.strip_suffix("/sch"), i.parse::<usize>().ok()) {+                    (Some(s), Some(n)) => (s, "sch", n),+                    _ => ("", "", 0),+                }+            } else {+                ("", "", 0)+            };+            let sub = |dir: &str| match kind {+                "pcb" => Some(format!("{dir}pcb.svg")),+                "sch" => Some(format!("{dir}sheet-{sch_idx:03}.svg")),                 _ => None,             };             // a selected PAST version → serve from its render/<version>/ archive;@@ -370,9 +383,9 @@ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> {             let svg = ver.as_ref()                 .and_then(|v| sub(&format!("render/{v}/")))                 .and_then(|f| wiki_bytes(slug, &f)).and_then(|b| String::from_utf8(b).ok())-                .or_else(|| cached_board(slug).and_then(|(sheets, pcb, _)| match segs.get(1).copied() {-                    Some("pcb") => pcb,-                    Some("sch") => segs.get(2).and_then(|i| i.parse::<usize>().ok()).and_then(|i| sheets.get(i).map(|(_, s)| s.clone())),+                .or_else(|| cached_board(slug).and_then(|(sheets, pcb, _)| match kind {+                    "pcb" => pcb,+                    "sch" => sheets.get(sch_idx).map(|(_, s)| s.clone()),                     _ => None,                 }))                 .or_else(|| sub("render/").and_then(|f| wiki_bytes(slug, &f)).and_then(|b| String::from_utf8(b).ok()));@@ -381,6 +394,33 @@ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> {                 None => Response::from_string("not rendered").with_status_code(404),             }         }+        (Method::Get, p) if p.starts_with("/board-viewer/") => {+            // forms: /board-viewer/<slug>/<name>  [?v=<version>]+            // Serves the self-contained INTERACTIVE viewer HTML (adom-layout-viewer /+            // adom-schematic-viewer `embed` output): real net highlighting, layer+            // toggles incl. inner copper, component hover. These are archived per board+            // version under viewers/<version>/, mirroring render/<version>/.+            //+            // With ?v= we deliberately do NOT fall back to the current viewer: a board+            // version that predates the viewer archive must show its own static SVG+            // rather than silently render a DIFFERENT revision of the board.+            let (path, ver) = split_ver(url);+            let rest = &path["/board-viewer/".len()..];+            let (slug, name) = match rest.rsplit_once('/') { Some(x) => x, None => ("", "") };+            let safe = !name.is_empty()+                && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');+            let html = if !safe || slug.is_empty() {+                None+            } else if let Some(v) = ver.as_ref() {+                wiki_bytes(slug, &format!("viewers/{v}/{name}.html"))+            } else {+                wiki_bytes(slug, &format!("viewers/{name}.html"))+            };+            match html {+                Some(b) => Response::from_data(b).with_header(hdr("text/html; charset=utf-8")),+                None => Response::from_string("no viewer").with_status_code(404),+            }+        }         (Method::Get, p) if p.starts_with("/board-glb/") => {             let (path, ver) = split_ver(url);             let slug = &path["/board-glb/".len()..];@@ -1925,6 +1965,9 @@ fn board_media(slug: &str) -> Value {             "has_pcb": m.get("pcb").and_then(|b| b.as_bool()).unwrap_or(false),             // version archive (empty on legacy flat manifests → the UI shows single-current)             "versions": m.get("versions").cloned().unwrap_or_else(|| json!([])),+            // interactive viewer exports available for the CURRENT version (e.g.+            // ["layout","sch-usb",…]). Per-version availability rides on versions[].viewers.+            "viewers": m.get("viewers").cloned().unwrap_or_else(|| json!([])),             "current": m.get("current").cloned().unwrap_or(Value::Null)});     }     // no stored render/ folder from here on — the board live-renders instead.
src/manage.html+38−4
@@ -206,6 +206,8 @@   .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}+  /* self-contained interactive board/schematic viewer (adom-layout-viewer embed) */+  .bo-viewer{width:100%;height:100%;border:0;display:block;background:#0d1117}   /* 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}@@ -722,19 +724,51 @@ async function mountBoardAssets(slug){     } else d3.innerHTML=`<div class="bo-na">board 3D file not available</div>`;   }   const sheets=m.sheets||[],sel=document.getElementById('bo-sheet');+  // Interactive viewers available for the version being shown. On a PAST version this+  // is that version's own archived list, so a board revision without archived viewers+  // falls back to its own static SVG instead of showing a newer revision.+  const vlist=viewersFor(m);+  const schViewer=(i)=>{ const n='sch-'+String(sheets[i]||'').toLowerCase(); return vlist.includes(n)?n:null; };   if(sch){     if(sheets.length){+      const showSheet=async(i)=>{+        if(boardSlug!==slug)return;+        const vn=schViewer(i);+        if(vn) return mountViewer(sch, `board-viewer/${slug}/${vn}${verQ()}`);+        const p=showViewerLoader(sch);+        mountSvg(sch, await fetchTextProgress(`board-svg/${slug}/sch/${i}${verQ()}`,p), schOpt);+      };       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; const p=showViewerLoader(sch); mountSvg(sch, await fetchTextProgress(`board-svg/${slug}/sch/${sel.value}${verQ()}`,p), schOpt); };+        sel.onchange=()=>showSheet(sel.value);       }-      const p=showViewerLoader(sch);-      mountSvg(sch, await fetchTextProgress(`board-svg/${slug}/sch/0${q}`,p), schOpt);+      await showSheet(0);     } 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){ const p=showViewerLoader(pcb); mountSvg(pcb, await fetchTextProgress(`board-svg/${slug}/pcb${q}`,p), pcbOpt); } else pcb.innerHTML='<div class="bo-na">PCB view not available</div>'; }+  if(pcb){+    if(vlist.includes('layout')) mountViewer(pcb, `board-viewer/${slug}/layout${q}`);+    else if(m.has_pcb){ const p=showViewerLoader(pcb); mountSvg(pcb, await fetchTextProgress(`board-svg/${slug}/pcb${q}`,p), pcbOpt); }+    else pcb.innerHTML='<div class="bo-na">PCB view not available</div>';+  }+}+// Which interactive viewers apply to the version currently selected.+function viewersFor(m){+  if(!boardVersion) return m.viewers||[];+  const ent=(m.versions||[]).find(v=>v.version===boardVersion);+  return (ent&&ent.viewers)||[];+}+// Mount a self-contained interactive viewer (iframe). It ships its own chrome+// (layers/nets panel, hover cards), so no mountSvg post-processing applies.+function mountViewer(el, src){+  el.style.position='relative'; el.style.background='#0d1117';+  el.innerHTML=`<iframe class="bo-viewer" src="${src}" title="interactive board view"></iframe>`++    `<div class="vload ov"><img src="loader.gif" alt=""></div>`;+  const fr=el.querySelector('iframe'), ov=el.querySelector('.vload.ov');+  const done=()=>{ if(ov&&ov.parentNode) ov.remove(); };+  fr.addEventListener('load',done);+  setTimeout(done,15000); // never strand the spinner if load never fires } function selectVersion(v){   const m=curBoardMedia||{};