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.0: loading splash (animated mark + Fetching Projects %), board version selector (render/<version> archive + dropdown), red/green visibility tags, org Your/Other split + author names + tag filter chips, gold molecule tag, evenodd fill transparency, model_3d GLB 3D, silk-skip
4 files changed
+283−261
Cargo.lock−212@@ -2,12 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]]-name = "adler2"-version = "2.0.1"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"- [[package]] name = "adom-project-manager" version = "0.1.0"@@ -23,33 +17,11 @@ dependencies = [ name = "altium-codec" version = "0.1.0" dependencies = [- "altium-pcblib",- "altium-schlib", "anyhow",- "cfb",- "flate2", "serde", "serde_json", ] -[[package]]-name = "altium-pcblib"-version = "0.1.0"-dependencies = [- "anyhow",- "cfb",- "serde_json",-]--[[package]]-name = "altium-schlib"-version = "0.1.0"-dependencies = [- "anyhow",- "cfb",- "serde_json",-]- [[package]] name = "anyhow" version = "1.0.103"@@ -62,90 +34,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" -[[package]]-name = "bumpalo"-version = "3.20.3"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"--[[package]]-name = "byteorder"-version = "1.5.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"--[[package]]-name = "cfb"-version = "0.10.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "d8a4f8e55be323b378facfcf1f06aa97f6ec17cf4ac84fb17325093aaf62da41"-dependencies = [- "byteorder",- "fnv",- "uuid",-]--[[package]]-name = "cfg-if"-version = "1.0.4"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"- [[package]] name = "chunked_transfer" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" -[[package]]-name = "crc32fast"-version = "1.5.0"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"-dependencies = [- "cfg-if",-]--[[package]]-name = "flate2"-version = "1.1.9"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"-dependencies = [- "crc32fast",- "miniz_oxide",-]--[[package]]-name = "fnv"-version = "1.0.7"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"--[[package]]-name = "futures-core"-version = "0.3.32"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"--[[package]]-name = "futures-task"-version = "0.3.32"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"--[[package]]-name = "futures-util"-version = "0.3.32"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"-dependencies = [- "futures-core",- "futures-task",- "pin-project-lite",- "slab",-]- [[package]] name = "httpdate" version = "1.0.3"@@ -158,17 +52,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]]-name = "js-sys"-version = "0.3.103"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"-dependencies = [- "cfg-if",- "futures-util",- "wasm-bindgen",-]- [[package]] name = "log" version = "0.4.33"@@ -181,28 +64,6 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" -[[package]]-name = "miniz_oxide"-version = "0.8.9"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"-dependencies = [- "adler2",- "simd-adler32",-]--[[package]]-name = "once_cell"-version = "1.21.4"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"--[[package]]-name = "pin-project-lite"-version = "0.2.17"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"- [[package]] name = "proc-macro2" version = "1.0.106"@@ -221,12 +82,6 @@ dependencies = [ "proc-macro2", ] -[[package]]-name = "rustversion"-version = "1.0.23"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"- [[package]] name = "serde" version = "1.0.228"@@ -270,18 +125,6 @@ dependencies = [ "zmij", ] -[[package]]-name = "simd-adler32"-version = "0.3.9"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"--[[package]]-name = "slab"-version = "0.4.12"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"- [[package]] name = "syn" version = "2.0.118"@@ -311,61 +154,6 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]]-name = "uuid"-version = "1.23.4"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"-dependencies = [- "js-sys",- "wasm-bindgen",-]--[[package]]-name = "wasm-bindgen"-version = "0.2.126"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"-dependencies = [- "cfg-if",- "once_cell",- "rustversion",- "wasm-bindgen-macro",- "wasm-bindgen-shared",-]--[[package]]-name = "wasm-bindgen-macro"-version = "0.2.126"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"-dependencies = [- "quote",- "wasm-bindgen-macro-support",-]--[[package]]-name = "wasm-bindgen-macro-support"-version = "0.2.126"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"-dependencies = [- "bumpalo",- "proc-macro2",- "quote",- "syn",- "wasm-bindgen-shared",-]--[[package]]-name = "wasm-bindgen-shared"-version = "0.2.126"-source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"-dependencies = [- "unicode-ident",-]- [[package]] name = "zmij" version = "1.0.21"
src/loader.gifadded⋯ 1 unchanged line ⋯
src/main.rs+131−29@@ -67,23 +67,75 @@ fn render_and_upload(slug: &str) -> bool { for (i, (_, svg)) in sheets.iter().enumerate() { let _ = std::fs::write(format!("{rdir}/sheet-{i:03}.svg"), svg); }+ let sheet_names: Vec<String> = sheets.iter().map(|(n, _)| n.clone()).collect();++ // ---- version archive -------------------------------------------------+ // Content-hash the just-rendered assets. A new version is archived ONLY when+ // the render actually differs from the last archived one (a deps/metadata-only+ // republish reuses the prior version → no duplicate visual version). Older+ // render/<version>/ folders on the page are preserved (we push targeted paths).+ let new_hash = {+ use std::hash::{Hash, Hasher};+ let mut h = std::collections::hash_map::DefaultHasher::new();+ if let Some(g) = &glb { g.hash(&mut h); }+ if let Some(s) = &pcb_svg { s.as_bytes().hash(&mut h); }+ for (_, svg) in &sheets { svg.as_bytes().hash(&mut h); }+ sheet_names.hash(&mut h);+ format!("{:016x}", h.finish())+ };+ let version = wiki_text(slug, "package.json")+ .and_then(|t| serde_json::from_str::<Value>(&t).ok())+ .and_then(|v| v.get("version").and_then(|x| x.as_str()).map(String::from))+ .unwrap_or_else(|| "0.0.0".to_string());+ // prior versions[] from the existing manifest (empty on first archive / legacy pages)+ let mut versions: Vec<Value> = wiki_bytes(slug, "render/manifest.json")+ .and_then(|b| serde_json::from_slice::<Value>(&b).ok())+ .and_then(|m| m.get("versions").and_then(|a| a.as_array()).cloned())+ .unwrap_or_default();+ let latest_hash = versions.first().and_then(|v| v.get("hash").and_then(|h| h.as_str())).unwrap_or("");+ let changed = latest_hash != new_hash;+ if changed {+ // stage this version's assets under render/<version>/ and index it+ let vdir = format!("{rdir}/{version}");+ let _ = std::fs::create_dir_all(&vdir);+ if let Some(g) = &glb { let _ = std::fs::write(format!("{vdir}/board.glb"), g); }+ if let Some(s) = &pcb_svg { let _ = std::fs::write(format!("{vdir}/pcb.svg"), s); }+ for (i, (_, svg)) in sheets.iter().enumerate() { let _ = std::fs::write(format!("{vdir}/sheet-{i:03}.svg"), svg); }+ // drop any existing entry for this version string, then prepend the fresh one+ versions.retain(|v| v.get("version").and_then(|x| x.as_str()) != Some(version.as_str()));+ versions.insert(0, json!({"version": version, "hash": new_hash,+ "glb": glb.is_some(), "pcb": pcb_svg.is_some(), "sheets": sheet_names}));+ }+ let current = versions.first().and_then(|v| v.get("version").and_then(|x| x.as_str()).map(String::from))+ .unwrap_or_else(|| version.clone()); let manifest = json!({- "v": 1,+ "v": 2,+ "current": current, "glb": glb.is_some(), "pcb": pcb_svg.is_some(),- "sheets": sheets.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>(),+ "sheets": sheet_names,+ "versions": versions, }); let _ = std::fs::write(format!("{rdir}/manifest.json"), serde_json::to_string_pretty(&manifest).unwrap());- // repo push the render/ folder (bare slug works for org-owned pages too)++ // Push TARGETED paths (not the whole render/ dir) so previously-archived+ // render/<version>/ folders on the page are not clobbered. let bare = slug.rsplit('/').next().unwrap_or(slug);- let ok = Command::new("adom-wiki")- .args(["repo", "push", bare, "--files", "render", "-m",- "regenerate board render assets (3D GLB + PCB/schematic SVGs)"])- .current_dir(&work)- .status()- .map(|s| s.success())- .unwrap_or(false);- eprintln!("{}", if ok { "uploaded render/ assets" } else { "upload FAILED" });+ let mut files: Vec<String> = vec!["render/manifest.json".into()];+ if glb.is_some() { files.push("render/board.glb".into()); }+ if pcb_svg.is_some() { files.push("render/pcb.svg".into()); }+ for i in 0..sheets.len() { files.push(format!("render/sheet-{i:03}.svg")); }+ if changed { files.push(format!("render/{version}")); }+ let mut args: Vec<String> = vec!["repo".into(), "push".into(), bare.into(), "--files".into()];+ args.extend(files);+ args.push("-m".into());+ args.push(format!("render assets{}", if changed { format!(" + archive v{version}") } else { String::new() }));+ let ok = Command::new("adom-wiki").args(&args).current_dir(&work)+ .status().map(|s| s.success()).unwrap_or(false);+ let msg = if !ok { "upload FAILED".to_string() }+ else if changed { format!("uploaded render/ + archived v{version}") }+ else { "uploaded render/ (no content change → no new version)".to_string() };+ eprintln!("{msg}"); ok } @@ -213,6 +265,9 @@ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> { json_resp(&list_dirs(&pp).to_string()) } (Method::Get, "/") => html(INDEX_HTML),+ (Method::Get, "/loader.gif") => Response::from_data(LOADER_GIF.to_vec())+ .with_header(hdr("image/gif"))+ .with_header(Header::from_bytes(&b"Cache-Control"[..], &b"public, max-age=86400"[..]).unwrap()), (Method::Get, "/api/components") => { // ?refresh=1 busts the server-side cache so newly-pushed components appear if url.contains("refresh=1") {@@ -241,32 +296,43 @@ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> { json_resp(&board_media(slug).to_string()) } (Method::Get, p) if p.starts_with("/board-svg/") => {- // forms: /board-svg/<slug>/pcb or /board-svg/<slug>/sch/<idx>- let segs: Vec<&str> = p["/board-svg/".len()..].splitn(3, '/').collect();+ // forms: /board-svg/<slug>/pcb or /board-svg/<slug>/sch/<idx> [?v=<version>]+ let (path, ver) = split_ver(p);+ let segs: Vec<&str> = path["/board-svg/".len()..].splitn(3, '/').collect(); let slug = segs.first().copied().unwrap_or("");- // live in-memory render first; else a stored render/ asset from the page- let svg = cached_board(slug)- .and_then(|(sheets, pcb, _)| match segs.get(1).copied() {+ 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")),+ _ => None,+ };+ // a selected PAST version → serve from its render/<version>/ archive;+ // else the live in-memory render; else the current stored top-level render/.+ 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())), _ => None,- })- .or_else(|| {- let file = match segs.get(1).copied() {- Some("pcb") => Some("render/pcb.svg".to_string()),- Some("sch") => segs.get(2).and_then(|i| i.parse::<usize>().ok()).map(|i| format!("render/sheet-{i:03}.svg")),- _ => None,- };- file.and_then(|f| wiki_bytes(slug, &f)).and_then(|b| String::from_utf8(b).ok())- });+ }))+ .or_else(|| sub("render/").and_then(|f| wiki_bytes(slug, &f)).and_then(|b| String::from_utf8(b).ok())); match svg { Some(s) => Response::from_string(s).with_header(hdr("image/svg+xml")), None => Response::from_string("not rendered").with_status_code(404), } } (Method::Get, p) if p.starts_with("/board-glb/") => {- let slug = &p["/board-glb/".len()..];- let glb = cached_board(slug).and_then(|(_, _, g)| g).or_else(|| wiki_bytes(slug, "render/board.glb"));+ let (path, ver) = split_ver(p);+ let slug = &path["/board-glb/".len()..];+ // A selected PAST version → its archived render/<version>/board.glb. Otherwise+ // serve the SAME 3D the wiki page shows: the page's model_3d GLB (Drew's+ // molecule-publish emits `<slug>.glb` and points model_3d_path at it), so the+ // Manager's 3D matches the wiki page. Fall back to live cache, then render/board.glb.+ let glb = ver.as_ref()+ .and_then(|v| wiki_bytes(slug, &format!("render/{v}/board.glb")))+ .or_else(|| page_model_glb(slug))+ .or_else(|| cached_board(slug).and_then(|(_, _, g)| g))+ .or_else(|| wiki_bytes(slug, "render/board.glb")); match glb { Some(g) => Response::from_data(g).with_header(hdr("model/gltf-binary")), None => Response::from_string("not rendered").with_status_code(404),@@ -334,6 +400,36 @@ fn wiki_bytes(slug: &str, file: &str) -> Option<Vec<u8>> { curl(&format!("{WIKI}/api/v1/pages/{slug}/files/{file}")) } +/// The page's model_3d GLB — the exact 3D file the wiki page displays. Drew's+/// molecule-publish emits `<slug>.glb` and sets `model_3d_path` to it, so serving+/// this keeps the Manager's 3D view consistent with the wiki page. Prefers the+/// explicit `model_3d_path` from package.json, else the `<slug>.glb` convention.+fn page_model_glb(slug: &str) -> Option<Vec<u8>> {+ if let Some(txt) = wiki_text(slug, "package.json") {+ if let Ok(v) = serde_json::from_str::<Value>(&txt) {+ if let Some(p) = v.get("model_3d_path").and_then(|x| x.as_str()).filter(|s| !s.is_empty()) {+ let p = p.trim_start_matches("./").trim_start_matches('/');+ if let Some(b) = wiki_bytes(slug, p) {+ return Some(b);+ }+ }+ }+ }+ wiki_bytes(slug, &format!("{slug}.glb"))+}++/// Split a request path into (path_without_query, the `?v=<version>` value if present).+/// Used by the board asset routes to serve a past version from `render/<version>/`.+fn split_ver(p: &str) -> (&str, Option<String>) {+ match p.split_once('?') {+ Some((path, q)) => {+ let v = q.split('&').find_map(|kv| kv.strip_prefix("v=")).map(|s| s.to_string());+ (path, v.filter(|s| !s.is_empty() && !s.contains("..") && !s.contains('/')))+ }+ None => (p, None),+ }+}+ /// Uppercase-MPN filename stem for a slug (files are named `<MPN>.ext`). fn up(slug: &str) -> String { slug.to_uppercase()@@ -375,7 +471,9 @@ fn list_components() -> Value { let owner = pg.get("author_name").and_then(|s| s.as_str()).unwrap_or("").to_string(); let org = pg.get("org_name").and_then(|s| s.as_str()).unwrap_or("").to_string(); let is_starred = pg.get("starred").and_then(|s| s.as_bool()).unwrap_or(false);- items.push(json!({"slug": slug, "title": title, "brief": brief, "tags": tags, "owner": owner, "org": org, "starred": is_starred}));+ 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})); } offset += n; let total = v.get("total").and_then(|t| t.as_u64()).unwrap_or(0) as usize;@@ -1609,7 +1707,10 @@ fn board_media(slug: &str) -> Value { .unwrap_or_default(); return json!({"rendering": false, "is_kicad": true, "stored": true, "has_render": true, "has_assets": true, "sheets": sheets, "has_3d": m.get("glb").and_then(|b| b.as_bool()).unwrap_or(false),- "has_pcb": m.get("pcb").and_then(|b| b.as_bool()).unwrap_or(false)});+ "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!([])),+ "current": m.get("current").cloned().unwrap_or(Value::Null)}); } // no stored render/ folder from here on — the board live-renders instead. if let Some((sheets, pcb, glb)) = cached_board(slug) {@@ -2236,3 +2337,4 @@ fn json_resp(s: &str) -> Response<Cursor<Vec<u8>>> { } const INDEX_HTML: &str = include_str!("manage.html");+const LOADER_GIF: &[u8] = include_bytes!("loader.gif");
src/manage.html+152−20@@ -23,12 +23,27 @@ .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.kind.molecule{background:#463307;color:#f0a81c} .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}+ .tag.vis{text-transform:capitalize}+ .tag.vis.private{background:#3d1416;color:#f87171}+ .tag.vis.public{background:#123322;color:#4ade80}+ .tagfilter{display:flex;flex-wrap:wrap;gap:5px;padding:2px 16px 8px}+ .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.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}+ .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:'· '} .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} .tabs .tab:hover{color:var(--ink)}@@ -49,6 +64,14 @@ #toolbar button.primary{background:var(--acc);color:#08111f;border-color:var(--acc);font-weight:600} #toolbar button:disabled{opacity:.45;cursor:not-allowed} .modal-overlay{position:fixed;inset:0;background:#000a;display:flex;align-items:center;justify-content:center;z-index:1000}+ /* first-load splash: spinning adom mark + fetch progress */+ #app-loader{position:fixed;inset:0;background:var(--bg);z-index:2000;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;transition:opacity .45s ease}+ #app-loader.hide{opacity:0;pointer-events:none}+ #app-loader img{width:150px;height:auto;image-rendering:auto}+ #app-loader .lt{font-size:21px;font-weight:700;color:var(--ink);letter-spacing:.01em;margin-top:4px}+ #app-loader .lt span{font-weight:400;color:var(--mut)}+ #app-loader .lp{color:var(--mut);font-size:13px;letter-spacing:.04em;font-variant-numeric:tabular-nums;margin-top:2px}+ #app-loader .lp b{color:var(--ink);font-weight:600} .modal{background:var(--panel);border:1px solid var(--edge);border-radius:14px;width:min(760px,92vw);max-height:88vh;overflow:auto;padding:22px 24px;box-shadow:0 20px 60px #000a} .modal h2{margin:0 0 4px;font-size:18px} .modal .msub{color:var(--mut);font-size:13px;margin-bottom:16px}@@ -129,6 +152,18 @@ .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-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)}+ .bo-ver.static{cursor:default}+ .bo-ver.static:hover{border-color:var(--edge)}+ .bo-verdrop{display:none;position:absolute;top:calc(100% + 5px);left:0;min-width:170px;background:var(--panel);border:1px solid var(--edge);border-radius:8px;box-shadow:0 12px 30px #000a;z-index:50;padding:4px}+ .bo-ver:hover .bo-verdrop,.bo-ver:focus-within .bo-verdrop{display:block}+ .bo-veropt{display:block;padding:5px 9px;border-radius:5px;font-size:12px;font-weight:500;color:var(--ink);cursor:pointer;white-space:nowrap}+ .bo-veropt:hover{background:#243049}+ .bo-veropt.on{color:var(--acc)}+ .bo-verbanner{margin-top:8px;font-size:12px;color:#f0a81c;background:#2a2109;border:1px solid #4a380e;border-radius:6px;padding:5px 10px}+ .bo-verbanner a{color:var(--acc)} .bo-3d-wrap{position:relative;margin:16px 20px;padding:0} .bo-3d-wrap .bo-3d{margin:0 auto} .bo-fs-abs{position:absolute;top:0;right:0;z-index:3}@@ -200,11 +235,17 @@ </style> </head> <body>+ <div id="app-loader">+ <img src="loader.gif" alt="Loading">+ <div class="lt">Adom <span>Project Manager</span></div>+ <div class="lp">Fetching Projects: <b id="app-loader-pct">0%</b></div>+ </div> <aside id="list"> <h1>Adom <small>Project Manager</small><button id="refresh" title="refresh list (fetch newly-pushed boards)">↻</button></h1> <div id="crumb"></div> <div id="tabbar" class="tabs"></div> <input id="q" placeholder="filter boards…">+ <div id="tagfilter" class="tagfilter"></div> <div id="toolbar" style="display:none"> <label class="selall"><input type="checkbox" id="selall"> <span id="selcount">select all</span></label> <div class="tbtns">@@ -265,16 +306,32 @@ function updateToolbar(){ const b=document.getElementById('btn-addlib'); if(b){b.disabled=n===0;b.textContent=n?`Add ${n} to Library`:'Add to Library';} const sa=document.getElementById('selall'); if(sa)sa.checked=curDeps.length>0&&curDeps.every(s=>selected.has(s)); }+// First-load splash: the animated adom mark + a "Fetching Projects: XX%" that eases+// toward 90% while the (single, blocking) list request is in flight, then snaps to 100%.+let _ldTimer=null;+function _setPct(p){ const b=document.getElementById('app-loader-pct'); if(b)b.textContent=Math.round(p)+'%'; }+function startLoader(){+ const el=document.getElementById('app-loader'); if(!el)return;+ el.classList.remove('hide'); let pct=0; _setPct(0);+ clearInterval(_ldTimer);+ _ldTimer=setInterval(()=>{ pct+=Math.max(0.7,(90-pct)*0.08); if(pct>90)pct=90; _setPct(pct); },80);+}+function finishLoader(){+ clearInterval(_ldTimer); _setPct(100);+ const el=document.getElementById('app-loader'); if(el)setTimeout(()=>el.classList.add('hide'),280);+} async function loadList(fresh){ const btn=document.getElementById('refresh'); if(btn&&fresh)btn.classList.add('spin');- if(!fresh) rowsEl().innerHTML='<div class="row" style="color:#8b93a3;cursor:default">loading boards from wiki…</div>';+ if(!fresh){ startLoader(); rowsEl().innerHTML='<div class="row" style="color:#8b93a3;cursor:default">loading boards from wiki…</div>'; } try{ const r=await fetch('api/components'+(fresh?'?refresh=1':'')).then(r=>r.json()); all=r.components||[]; me=r.me||''; orgs=r.orgs||[]; bySlug={}; all.forEach(c=>bySlug[c.slug]=c); fetch('api/env').then(r=>r.json()).then(e=>{ if(e&&e.os) env=e; }).catch(()=>{}); fetch('api/libraries').then(r=>r.json()).then(l=>{ if(l&&l.symLibs) userLibs=l; }).catch(()=>{}); render();+ if(!fresh) finishLoader(); }catch(e){+ if(!fresh) finishLoader(); rowsEl().innerHTML='<div class="row" style="color:#f87171;cursor:default">failed to load — retry</div>'; }finally{ if(btn)btn.classList.remove('spin'); } }@@ -291,27 +348,55 @@ function renderTabs(){ 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(); }); }+// 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='';+ 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 boardEdaOf(c){ return Array.isArray(c.eda)?c.eda : (Array.isArray(edaCache[c.slug])?edaCache[c.slug]:null); }+function matchesFilter(c){+ if(!filterTags.size) return true;+ const kindSel=['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; }+ return true;+} function renderBoards(){ document.getElementById('toolbar').style.display='none';selected.clear(); document.getElementById('crumb').innerHTML='<span class="cactive">Projects</span>'; const q=qval(),el=rowsEl();el.innerHTML='';- renderTabs();+ renderTabs(); renderFilterBar(); const boards=all.filter(isBoard); const mineAll=boards.filter(c=>c.owner&&c.owner===me&&!c.org); 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,el);return;}- const list=(boardTab==='mine'?mineAll:orgAll).filter(c=>!q||c.slug.includes(q)||(c.title||'').toLowerCase().includes(q));+ if(boardTab==='public'){renderPublic(boards.filter(matchesFilter),el);return;}+ const list=(boardTab==='mine'?mineAll:orgAll).filter(c=>!q||c.slug.includes(q)||(c.title||'').toLowerCase().includes(q)).filter(matchesFilter); if(!list.length){const d=document.createElement('div');d.className='row';d.style.cssText='color:#8b93a3;cursor:default;line-height:1.5';d.innerHTML=boardTab==='mine'?'no personal projects yet — push a board tagged <b>board</b>/<b>molecule</b> (without --org), then press ↻':'no projects in your organization(s) yet';el.appendChild(d);return;} if(boardTab==='mine'){ sortBoards(list).forEach(c=>el.appendChild(boardRow(c))); } else {- // group by organization (so multiple orgs list under their own headers)+ // group by organization; within each org, split into projects you contribute to+ // (you're the author) vs everyone else's. orgs.forEach(o=>{- const inOrg=sortBoards(list.filter(c=>c.org===o));+ const inOrg=list.filter(c=>c.org===o); if(!inOrg.length)return; const h=document.createElement('div');h.className='cathead';h.textContent=`${o} · ${inOrg.length}`;el.appendChild(h);- inOrg.forEach(c=>el.appendChild(boardRow(c)));+ const yours=sortBoards(inOrg.filter(c=>c.owner&&c.owner===me));+ const others=sortBoards(inOrg.filter(c=>!c.owner||c.owner!==me));+ const section=(label,arr)=>{+ if(!arr.length)return;+ const sh=document.createElement('div');sh.className='subhead';sh.textContent=`${label} · ${arr.length}`;el.appendChild(sh);+ arr.forEach(c=>el.appendChild(boardRow(c)));+ };+ section('Your Projects',yours);+ section('Other Projects',others); }); } }@@ -376,7 +461,10 @@ function boardRow(c){ // to the per-row fetch when it's absent. const edaKnown=Array.isArray(c.eda); const edaSpan=edaKnown?edaTags(c.eda):`<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>`;+ const vis=(c.visibility||'').toLowerCase();+ 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); if(!edaKnown)loadEda(c.slug); return d;@@ -402,7 +490,7 @@ function renderPublic(boards,el){ renderPub(); } async function openBoard(slug){- boardSlug=slug; curDeps=[]; selected.clear();+ 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>`; renderBoardOverview(slug);@@ -418,11 +506,13 @@ async function openBoard(slug){ // board (from a wiki GLB if present), and small KiCad schematic + PCB views. function renderBoardOverview(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>`:''; const view=document.getElementById('view'); 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 ${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-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-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>@@ -493,20 +583,56 @@ async function loadBoardMedia(slug){ 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; }- if(d3)d3.innerHTML=m.has_3d?`<model-viewer src="board-glb/${slug}" camera-controls auto-rotate shadow-intensity="1"></model-viewer>`:`<div class="bo-na">board 3D file not available</div>`;+ curBoardMedia=m; boardVersion=null;+ renderVersionPill(); updateVerBanner();+ await mountBoardAssets(slug);+}+// Version selector state: boardVersion = the selected PAST version (null = current).+let boardVersion=null, curBoardMedia=null;+function verQ(){ const m=curBoardMedia||{}; return (boardVersion && m.current && boardVersion!==m.current) ? '?v='+encodeURIComponent(boardVersion) : ''; }+// Mount the 3D / PCB / schematic views for the selected version. Re-called when the+// version pill changes — each asset fetch carries ?v=<version> for a past version.+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>`; 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}`).then(r=>r.text()), {light:true}); };+ 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}); }; }- mountSvg(sch, await fetch(`board-svg/${slug}/sch/0`).then(r=>r.text()), {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}); } 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){ mountSvg(pcb, await fetch('board-svg/'+slug+'/pcb').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){ 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>'; }+}+function selectVersion(v){+ const m=curBoardMedia||{};+ boardVersion=(v==null||v===m.current)?null:v;+ renderVersionPill(); updateVerBanner(); mountBoardAssets(boardSlug);+}+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 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+ el.innerHTML=`<span class="bo-ver" tabindex="0">v${cur} ▾<span class="bo-verdrop">${vers.map(v=>`<span class="bo-veropt${v.version===cur?' on':''}" onclick="event.stopPropagation();selectVersion('${v.version}')">v${v.version}${v.version===m.current?' · current':''}</span>`).join('')}</span></span>`;+ } else { // single/no archive → static version label+ el.innerHTML=`<span class="bo-ver static">v${cur}</span>`;+ }+}+function updateVerBanner(){+ const b=document.getElementById('bo-verbanner'), m=curBoardMedia||{}; if(!b)return;+ const off=boardVersion&&m.current&&boardVersion!==m.current;+ 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>`; } // 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@@ -534,12 +660,17 @@ function mountSvg(box,svgText,opt){ if(circles.length) circles.forEach(ci=>{ ci.style.fill = (parseFloat(ci.getAttribute('r')||'0')*2 < 0.5) ? '#e3b72e' : '#0d0f14'; }); else el.style.fill='#0d0f14'; });- // A pour is a big FILLED path (huge 'd'); pads are tiny paths/circles and traces- // are stroke-only (computed fill:none). Silk/edge fills are skipped by color so a- // filled silk logo stays readable.- const skip=new Set(['rgb(242, 237, 161)','rgb(208, 210, 205)','rgb(255, 255, 255)']); // silk, edge, white+ // Identify copper POURS/zones by SHAPE, not size: a poured zone is filled with+ // fill-rule:evenodd because it carries clearance cutouts around every pad/trace,+ // while pads and simple fills are nonzero. This is area-independent, so it catches+ // small zones the old `d.length ≥ 2000` heuristic missed. Traces are stroke-only+ // (fill:none). Silk/edge fills are evenodd too (letter holes), so they're skipped+ // BY COLOR — keeping a filled silk logo fully opaque no matter its size.+ const skip=new Set([+ 'rgb(242, 237, 161)','rgb(255, 230, 0)','rgb(255, 229, 0)', // silk (kicad + altium yellow)+ 'rgb(208, 210, 205)','rgb(255, 255, 255)']); // edge, white svg.querySelectorAll('path').forEach(el=>{- if((el.getAttribute('d')||'').length < 2000) return; // pads / short segments → opaque+ if((getComputedStyle(el).fillRule||'').toLowerCase()!=='evenodd') return; // only poured zones → dim; pads/simple fills opaque const f=getComputedStyle(el).fill; // resolves fill inherited from a parent <g> if(!f||f==='none'||f==='rgba(0, 0, 0, 0)'||skip.has(f)) return; // traces / edge / silk → opaque el.style.fillOpacity=opt.dimFills; // copper pour → translucent@@ -587,6 +718,7 @@ function typeOf(c){ } function renderDeps(){ renderTabs();+ document.getElementById('tagfilter').style.display='none'; document.getElementById('q').placeholder='filter components…'; document.getElementById('toolbar').style.display='flex'; const q=qval(),el=rowsEl();el.innerHTML='';