← back to Projects Agentabrams
left rail: collapsible category sections (7 groups, collapsed by default, auto-expand on search + current selection); build-projects.js assigns category via keyword rules
e86c0c6031a25c566911735334fe535ebcbbb768 · 2026-07-28 07:45:39 -0700 · steve@designerwallcoverings.com
Files touched
M build-projects.jsM projects.jsonM public/index.html
Diff
commit e86c0c6031a25c566911735334fe535ebcbbb768
Author: steve@designerwallcoverings.com <steve@designerwallcoverings.com>
Date: Tue Jul 28 07:45:39 2026 -0700
left rail: collapsible category sections (7 groups, collapsed by default, auto-expand on search + current selection); build-projects.js assigns category via keyword rules
---
build-projects.js | 28 ++++++++
projects.json | 196 ++++++++++++++++++++++++++++++++++++------------------
public/index.html | 105 ++++++++++++++++++++++-------
3 files changed, 242 insertions(+), 87 deletions(-)
diff --git a/build-projects.js b/build-projects.js
index 44a7fbd..49c4d8b 100644
--- a/build-projects.js
+++ b/build-projects.js
@@ -25,6 +25,33 @@ const get = (slug) => {
return map.get(slug);
};
+// Bucket a project into a collapsible section. Ordered rules, first match wins;
+// keyword-based so newly-added projects auto-sort without editing a static list.
+const CATEGORIES = [
+ 'DW Vendor Lines',
+ 'DW Catalog & Marketing',
+ "Wallpaper's Back & Patterns",
+ 'Consulting & Client Sites',
+ 'Directories & Verticals',
+ 'Webdev Accelerator',
+ 'Agents, Ops & Tools',
+ 'Other',
+];
+function categorize(slug) {
+ const s = slug.toLowerCase();
+ const rules = [
+ ["Wallpaper's Back & Patterns", /^wpb-|pattern-vault|patterndesignlab|wild-wallcoverings|wallco-ai/],
+ ['DW Vendor Lines', /-internal$|-line-viewer$|-onboard$|-naturals$|^schumacher|^muralsource|^reidwitlin|viewer-local|all-designerwallcoverings/],
+ ['Consulting & Client Sites', /consulting|fantasea|prestige-car-wash|norma-sdcc/],
+ ['DW Catalog & Marketing', /^dw-|trending-dw|colorwheel|instagram-agent/],
+ ['Webdev Accelerator', /webdev-accelerator|estimate-instant|trade-portal|sample-box|reno-visualizer|permit-radar/],
+ ['Directories & Verticals', /animals|rentv|model-arena|sdcc-journalists|thesetdecorator|small-business-builder|designers?salesreps|homesonspec/],
+ ['Agents, Ops & Tools', /abrams|agent-cabinet|ai-workforce|ticket-system|^slack-|build-pages|clipreviewbydave|holdforme|kalshi/],
+ ];
+ for (const [cat, re] of rules) if (re.test(s)) return cat;
+ return 'Other';
+}
+
// "small-business-builder" -> "Small Business Builder"; keeps ALLCAPS acronyms.
function pretty(slug) {
return String(slug)
@@ -95,6 +122,7 @@ for (const r of map.values()) {
target,
domain: r.domain, // may be null
port: r.local, // may be null
+ category: categorize(r.slug),
});
}
out.sort((a, b) => a.name.localeCompare(b.name));
diff --git a/projects.json b/projects.json
index 2c5eb24..b3032df 100644
--- a/projects.json
+++ b/projects.json
@@ -5,7 +5,8 @@
"kind": "local",
"target": "http://127.0.0.1:9773",
"domain": null,
- "port": 9773
+ "port": 9773,
+ "category": "Agents, Ops & Tools"
},
{
"slug": "AbramsOS",
@@ -13,7 +14,8 @@
"kind": "local",
"target": "http://127.0.0.1:9774",
"domain": null,
- "port": 9774
+ "port": 9774,
+ "category": "Agents, Ops & Tools"
},
{
"slug": "agent-cabinet",
@@ -21,7 +23,8 @@
"kind": "local",
"target": "http://127.0.0.1:9766",
"domain": null,
- "port": 9766
+ "port": 9766,
+ "category": "Agents, Ops & Tools"
},
{
"slug": "agentabrams-viewer",
@@ -29,7 +32,8 @@
"kind": "local",
"target": "http://127.0.0.1:9783",
"domain": null,
- "port": 9783
+ "port": 9783,
+ "category": "Agents, Ops & Tools"
},
{
"slug": "ai-workforce",
@@ -37,7 +41,8 @@
"kind": "local",
"target": "http://127.0.0.1:9791",
"domain": null,
- "port": 9791
+ "port": 9791,
+ "category": "Agents, Ops & Tools"
},
{
"slug": "all-designerwallcoverings",
@@ -45,7 +50,8 @@
"kind": "local",
"target": "http://127.0.0.1:9958",
"domain": null,
- "port": 9958
+ "port": 9958,
+ "category": "DW Vendor Lines"
},
{
"slug": "animals",
@@ -53,15 +59,17 @@
"kind": "local",
"target": "http://127.0.0.1:9720",
"domain": null,
- "port": 9720
+ "port": 9720,
+ "category": "Directories & Verticals"
},
{
"slug": "build-pages",
"name": "Build Pages",
"kind": "public",
- "target": "https://projects.agentabrams.com",
- "domain": "projects.agentabrams.com",
- "port": null
+ "target": "https://build-pages.agentabrams.com",
+ "domain": "build-pages.agentabrams.com",
+ "port": 9703,
+ "category": "Agents, Ops & Tools"
},
{
"slug": "claude-webdev-accelerator",
@@ -69,7 +77,8 @@
"kind": "local",
"target": "http://127.0.0.1:4901",
"domain": null,
- "port": 4901
+ "port": 4901,
+ "category": "Webdev Accelerator"
},
{
"slug": "clipreviewbydave",
@@ -77,7 +86,8 @@
"kind": "local",
"target": "http://127.0.0.1:9736",
"domain": null,
- "port": 9736
+ "port": 9736,
+ "category": "Agents, Ops & Tools"
},
{
"slug": "colorwheel",
@@ -85,7 +95,8 @@
"kind": "local",
"target": "http://127.0.0.1:9851",
"domain": null,
- "port": 9851
+ "port": 9851,
+ "category": "DW Catalog & Marketing"
},
{
"slug": "consulting-intake",
@@ -93,7 +104,8 @@
"kind": "local",
"target": "http://127.0.0.1:9700",
"domain": null,
- "port": 9700
+ "port": 9700,
+ "category": "Consulting & Client Sites"
},
{
"slug": "consulting-rentv-com",
@@ -101,7 +113,8 @@
"kind": "local",
"target": "http://127.0.0.1:9701",
"domain": null,
- "port": 9701
+ "port": 9701,
+ "category": "Consulting & Client Sites"
},
{
"slug": "crezana-internal",
@@ -109,7 +122,8 @@
"kind": "local",
"target": "http://127.0.0.1:10072",
"domain": null,
- "port": 10072
+ "port": 10072,
+ "category": "DW Vendor Lines"
},
{
"slug": "designersalesreps",
@@ -117,7 +131,8 @@
"kind": "public",
"target": "https://designersalesreps.com",
"domain": "designersalesreps.com",
- "port": null
+ "port": null,
+ "category": "Directories & Verticals"
},
{
"slug": "designerssalesreps",
@@ -125,7 +140,8 @@
"kind": "public",
"target": "https://designerssalesreps.com",
"domain": "designerssalesreps.com",
- "port": null
+ "port": null,
+ "category": "Directories & Verticals"
},
{
"slug": "dw-activation-calendar",
@@ -133,7 +149,8 @@
"kind": "local",
"target": "http://127.0.0.1:9765",
"domain": null,
- "port": 9765
+ "port": 9765,
+ "category": "DW Catalog & Marketing"
},
{
"slug": "dw-cadence-next2",
@@ -141,7 +158,8 @@
"kind": "local",
"target": "http://127.0.0.1:9764",
"domain": null,
- "port": 9764
+ "port": 9764,
+ "category": "DW Catalog & Marketing"
},
{
"slug": "dw-domain-viewer",
@@ -149,7 +167,8 @@
"kind": "local",
"target": "http://127.0.0.1:9784",
"domain": null,
- "port": 9784
+ "port": 9784,
+ "category": "DW Catalog & Marketing"
},
{
"slug": "dw-internal-gateway",
@@ -157,7 +176,8 @@
"kind": "local",
"target": "http://127.0.0.1:8090",
"domain": null,
- "port": 8090
+ "port": 8090,
+ "category": "DW Catalog & Marketing"
},
{
"slug": "dw-marketing-reels",
@@ -165,7 +185,8 @@
"kind": "local",
"target": "http://127.0.0.1:9848",
"domain": null,
- "port": 9848
+ "port": 9848,
+ "category": "DW Catalog & Marketing"
},
{
"slug": "dw-pairs-well",
@@ -173,7 +194,8 @@
"kind": "public",
"target": "https://pairs.designerwallcoverings.com",
"domain": "pairs.designerwallcoverings.com",
- "port": null
+ "port": null,
+ "category": "DW Catalog & Marketing"
},
{
"slug": "dw-pitch-followup",
@@ -181,7 +203,8 @@
"kind": "local",
"target": "http://127.0.0.1:9768",
"domain": null,
- "port": 9768
+ "port": 9768,
+ "category": "DW Catalog & Marketing"
},
{
"slug": "estimate-instant",
@@ -189,7 +212,8 @@
"kind": "local",
"target": "http://127.0.0.1:3901",
"domain": null,
- "port": 3901
+ "port": 3901,
+ "category": "Webdev Accelerator"
},
{
"slug": "fantasea-consulting",
@@ -197,7 +221,8 @@
"kind": "local",
"target": "http://127.0.0.1:9782",
"domain": null,
- "port": 9782
+ "port": 9782,
+ "category": "Consulting & Client Sites"
},
{
"slug": "fentucci-naturals",
@@ -205,7 +230,8 @@
"kind": "local",
"target": "http://127.0.0.1:9981",
"domain": null,
- "port": 9981
+ "port": 9981,
+ "category": "DW Vendor Lines"
},
{
"slug": "fromental-internal",
@@ -213,7 +239,8 @@
"kind": "local",
"target": "http://127.0.0.1:10071",
"domain": null,
- "port": 10071
+ "port": 10071,
+ "category": "DW Vendor Lines"
},
{
"slug": "gracie-internal",
@@ -221,7 +248,8 @@
"kind": "local",
"target": "http://127.0.0.1:10073",
"domain": null,
- "port": 10073
+ "port": 10073,
+ "category": "DW Vendor Lines"
},
{
"slug": "greenland-onboard",
@@ -229,7 +257,8 @@
"kind": "local",
"target": "http://127.0.0.1:9973",
"domain": null,
- "port": 9973
+ "port": 9973,
+ "category": "DW Vendor Lines"
},
{
"slug": "holdforme",
@@ -237,7 +266,8 @@
"kind": "public",
"target": "https://butlr.agentabrams.com",
"domain": "butlr.agentabrams.com",
- "port": null
+ "port": null,
+ "category": "Agents, Ops & Tools"
},
{
"slug": "homesonspec",
@@ -245,7 +275,8 @@
"kind": "local",
"target": "http://127.0.0.1:3100",
"domain": null,
- "port": 3100
+ "port": 3100,
+ "category": "Directories & Verticals"
},
{
"slug": "instagram-agent",
@@ -253,7 +284,8 @@
"kind": "local",
"target": "http://127.0.0.1:9810",
"domain": null,
- "port": 9810
+ "port": 9810,
+ "category": "DW Catalog & Marketing"
},
{
"slug": "kalshi-dash",
@@ -261,7 +293,8 @@
"kind": "local",
"target": "http://127.0.0.1:7810",
"domain": null,
- "port": 7810
+ "port": 7810,
+ "category": "Agents, Ops & Tools"
},
{
"slug": "model-arena",
@@ -269,7 +302,8 @@
"kind": "local",
"target": "http://127.0.0.1:9758",
"domain": null,
- "port": 9758
+ "port": 9758,
+ "category": "Directories & Verticals"
},
{
"slug": "muralsource-internal",
@@ -277,7 +311,8 @@
"kind": "local",
"target": "http://127.0.0.1:9947",
"domain": null,
- "port": 9947
+ "port": 9947,
+ "category": "DW Vendor Lines"
},
{
"slug": "norma-sdcc-pitch",
@@ -285,7 +320,8 @@
"kind": "local",
"target": "http://127.0.0.1:9876",
"domain": null,
- "port": 9876
+ "port": 9876,
+ "category": "Consulting & Client Sites"
},
{
"slug": "pattern-vault",
@@ -293,7 +329,8 @@
"kind": "local",
"target": "http://127.0.0.1:9779",
"domain": null,
- "port": 9779
+ "port": 9779,
+ "category": "Wallpaper's Back & Patterns"
},
{
"slug": "patterndesignlab",
@@ -301,7 +338,8 @@
"kind": "public",
"target": "https://prestige.agentabrams.com",
"domain": "prestige.agentabrams.com",
- "port": 9781
+ "port": 9781,
+ "category": "Wallpaper's Back & Patterns"
},
{
"slug": "permit-radar",
@@ -309,7 +347,8 @@
"kind": "local",
"target": "http://127.0.0.1:3905",
"domain": null,
- "port": 3905
+ "port": 3905,
+ "category": "Webdev Accelerator"
},
{
"slug": "prestige-car-wash",
@@ -317,7 +356,17 @@
"kind": "local",
"target": "http://127.0.0.1:9808",
"domain": null,
- "port": 9808
+ "port": 9808,
+ "category": "Consulting & Client Sites"
+ },
+ {
+ "slug": "projects-agentabrams",
+ "name": "Projects Agentabrams",
+ "kind": "public",
+ "target": "https://projects.agentabrams.com",
+ "domain": "projects.agentabrams.com",
+ "port": 9702,
+ "category": "Agents, Ops & Tools"
},
{
"slug": "reidwitlin-landing",
@@ -325,7 +374,8 @@
"kind": "public",
"target": "https://reidwitlin.designerwallcoverings.com",
"domain": "reidwitlin.designerwallcoverings.com",
- "port": 9952
+ "port": 9952,
+ "category": "DW Vendor Lines"
},
{
"slug": "reno-visualizer",
@@ -333,7 +383,8 @@
"kind": "local",
"target": "http://127.0.0.1:3904",
"domain": null,
- "port": 3904
+ "port": 3904,
+ "category": "Webdev Accelerator"
},
{
"slug": "rentv-v1",
@@ -341,7 +392,8 @@
"kind": "local",
"target": "http://127.0.0.1:9704",
"domain": null,
- "port": 9704
+ "port": 9704,
+ "category": "Directories & Verticals"
},
{
"slug": "rigo-line-viewer",
@@ -349,7 +401,8 @@
"kind": "local",
"target": "http://127.0.0.1:9979",
"domain": null,
- "port": 9979
+ "port": 9979,
+ "category": "DW Vendor Lines"
},
{
"slug": "sample-box",
@@ -357,7 +410,8 @@
"kind": "local",
"target": "http://127.0.0.1:3903",
"domain": null,
- "port": 3903
+ "port": 3903,
+ "category": "Webdev Accelerator"
},
{
"slug": "schumacher-internal",
@@ -365,7 +419,8 @@
"kind": "local",
"target": "http://127.0.0.1:9946",
"domain": null,
- "port": 9946
+ "port": 9946,
+ "category": "DW Vendor Lines"
},
{
"slug": "sdcc-journalists-viewer",
@@ -373,7 +428,8 @@
"kind": "local",
"target": "http://127.0.0.1:9878",
"domain": null,
- "port": 9878
+ "port": 9878,
+ "category": "Directories & Verticals"
},
{
"slug": "slack-idea-board",
@@ -381,7 +437,8 @@
"kind": "local",
"target": "http://127.0.0.1:9820",
"domain": null,
- "port": 9820
+ "port": 9820,
+ "category": "Agents, Ops & Tools"
},
{
"slug": "slack-to-steve",
@@ -389,7 +446,8 @@
"kind": "local",
"target": "http://127.0.0.1:9897",
"domain": null,
- "port": 9897
+ "port": 9897,
+ "category": "Agents, Ops & Tools"
},
{
"slug": "small-business-builder",
@@ -397,7 +455,8 @@
"kind": "local",
"target": "http://127.0.0.1:9760",
"domain": null,
- "port": 9760
+ "port": 9760,
+ "category": "Directories & Verticals"
},
{
"slug": "thesetdecorator",
@@ -405,7 +464,8 @@
"kind": "public",
"target": "https://thesetdecorator.com",
"domain": "thesetdecorator.com",
- "port": null
+ "port": null,
+ "category": "Directories & Verticals"
},
{
"slug": "ticket-system",
@@ -413,7 +473,8 @@
"kind": "local",
"target": "http://127.0.0.1:9794",
"domain": null,
- "port": 9794
+ "port": 9794,
+ "category": "Agents, Ops & Tools"
},
{
"slug": "trade-portal",
@@ -421,7 +482,8 @@
"kind": "local",
"target": "http://127.0.0.1:3902",
"domain": null,
- "port": 3902
+ "port": 3902,
+ "category": "Webdev Accelerator"
},
{
"slug": "trending-dw",
@@ -429,7 +491,8 @@
"kind": "public",
"target": "https://trending.designerwallcoverings.com",
"domain": "trending.designerwallcoverings.com",
- "port": null
+ "port": null,
+ "category": "DW Catalog & Marketing"
},
{
"slug": "vahallan-line-viewer",
@@ -437,7 +500,8 @@
"kind": "local",
"target": "http://127.0.0.1:9982",
"domain": null,
- "port": 9982
+ "port": 9982,
+ "category": "DW Vendor Lines"
},
{
"slug": "viewer-local",
@@ -445,7 +509,8 @@
"kind": "local",
"target": "http://127.0.0.1:9931",
"domain": null,
- "port": 9931
+ "port": 9931,
+ "category": "DW Vendor Lines"
},
{
"slug": "wallco-ai",
@@ -453,7 +518,8 @@
"kind": "public",
"target": "https://wallco.ai",
"domain": "wallco.ai",
- "port": null
+ "port": null,
+ "category": "Wallpaper's Back & Patterns"
},
{
"slug": "wild-wallcoverings",
@@ -461,7 +527,8 @@
"kind": "local",
"target": "http://127.0.0.1:9787",
"domain": null,
- "port": 9787
+ "port": 9787,
+ "category": "Wallpaper's Back & Patterns"
},
{
"slug": "wpb-sales-dashboard",
@@ -469,7 +536,8 @@
"kind": "local",
"target": "http://127.0.0.1:9812",
"domain": null,
- "port": 9812
+ "port": 9812,
+ "category": "Wallpaper's Back & Patterns"
},
{
"slug": "wpb-uploaders",
@@ -477,7 +545,8 @@
"kind": "local",
"target": "http://127.0.0.1:9813",
"domain": null,
- "port": 9813
+ "port": 9813,
+ "category": "Wallpaper's Back & Patterns"
},
{
"slug": "zuber-internal",
@@ -485,6 +554,7 @@
"kind": "local",
"target": "http://127.0.0.1:10074",
"domain": null,
- "port": 10074
+ "port": 10074,
+ "category": "DW Vendor Lines"
}
]
diff --git a/public/index.html b/public/index.html
index d76c3cc..5f624d2 100644
--- a/public/index.html
+++ b/public/index.html
@@ -20,6 +20,16 @@
#filters button { flex:1; padding:5px 6px; font-size:11.5px; }
#filters button.on { border-color:var(--accent); color:var(--accent); background:#16221f; }
#list { flex:1; overflow-y:auto; padding:6px; }
+ .sec { margin-bottom:1px; }
+ .sec-h { display:flex; align-items:center; gap:7px; padding:8px 9px; border-radius:7px; cursor:pointer; user-select:none;
+ color:var(--dim); font-size:11px; font-weight:700; letter-spacing:.05em; text-transform:uppercase; }
+ .sec-h:hover { background:#1e2530; color:var(--txt); }
+ .sec-h .chev { font-size:9px; transition:transform .12s ease; flex:none; color:var(--dim); }
+ .sec-h.open .chev { transform:rotate(90deg); color:var(--accent); }
+ .sec-h .sec-name { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+ .sec-h .cnt { flex:none; color:var(--dim); font-weight:600; display:flex; align-items:center; gap:4px; }
+ .sec-h .up-dot { width:6px; height:6px; border-radius:50%; background:#3fbf6f; display:inline-block; }
+ .sec-body { padding:2px 0 4px 6px; }
.item { display:flex; align-items:center; gap:8px; padding:7px 9px; border-radius:7px; cursor:pointer; color:var(--txt); user-select:none; }
.item:hover { background:#1e2530; }
.item.active { background:#213132; outline:1px solid var(--accent); }
@@ -82,38 +92,85 @@ const cur = document.getElementById('cur'), count = document.getElementById('cou
const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+// Section order for the collapsible rail. Any category not listed sorts last.
+const CATS = ['DW Vendor Lines','DW Catalog & Marketing',"Wallpaper's Back & Patterns",
+ 'Consulting & Client Sites','Directories & Verticals','Webdev Accelerator','Agents, Ops & Tools','Other'];
+// collapsed[cat] === false → user opened it. Undefined → collapsed by default (compact).
+let collapsed = {};
+try { collapsed = JSON.parse(localStorage.getItem('paa-collapsed') || '{}'); } catch (e) {}
+
+function buildRow(p, f) {
+ const s = status[p.slug];
+ const row = document.createElement('div');
+ row.className = 'item' + (p.slug === current ? ' active' : '');
+ const dotCls = !s ? '' : s.authFail ? 'down'
+ : s.authType === 'basic' ? 'auth'
+ : s.authType === 'login' ? 'login'
+ : s.authType === 'down' ? 'down'
+ : s.up ? 'up' : 'down';
+ const tip = !s ? 'probing…' : s.authType === 'down' ? 'unreachable'
+ : s.authFail ? 'basic-auth wall — unified pw rejected'
+ : s.authType === 'basic' ? 'HTTP Basic wall (auto-credential applied)'
+ : s.authType === 'login' ? 'app login / SSO'
+ : 'up — no auth';
+ const en = esc(p.name);
+ const label = f ? en.replace(new RegExp('(' + f.replace(/[.*+?^${}()|[\]\\]/g,'\\$&') + ')','ig'), '<b>$1</b>') : en;
+ const badge = p.kind === 'public'
+ ? `<span class="badge public" title="${esc(p.domain)}">public</span>`
+ : `<span class="badge" title="127.0.0.1:${p.port}">:${p.port}</span>`;
+ row.innerHTML = `<span class="dot ${dotCls}" title="${tip}"></span>`
+ + `<span class="name">${label}</span>${badge}`
+ + (s && s.up ? `<span class="ms">${s.ms}ms</span>` : s ? `<span class="ms">—</span>` : `<span class="ms"></span>`);
+ row.onclick = () => select(p.slug);
+ return row;
+}
+
function render() {
const f = q.value.trim().toLowerCase();
+ const searching = f.length > 0;
const shown = projects.filter(p =>
(filter === 'all' || p.kind === filter) &&
(p.name.toLowerCase().includes(f) || p.slug.toLowerCase().includes(f) || (p.domain || '').includes(f))
);
count.textContent = `(${shown.length}/${projects.length})`;
- list.innerHTML = '';
+
+ // Group into ordered categories (unknown categories appended after CATS).
+ const groups = new Map(CATS.map(c => [c, []]));
for (const p of shown) {
- const s = status[p.slug];
- const row = document.createElement('div');
- row.className = 'item' + (p.slug === current ? ' active' : '');
- const dotCls = !s ? '' : s.authFail ? 'down'
- : s.authType === 'basic' ? 'auth'
- : s.authType === 'login' ? 'login'
- : s.authType === 'down' ? 'down'
- : s.up ? 'up' : 'down';
- const tip = !s ? 'probing…' : s.authType === 'down' ? 'unreachable'
- : s.authFail ? 'basic-auth wall — unified pw rejected'
- : s.authType === 'basic' ? 'HTTP Basic wall (auto-credential applied)'
- : s.authType === 'login' ? 'app login / SSO'
- : 'up — no auth';
- const en = esc(p.name);
- const label = f ? en.replace(new RegExp('(' + f.replace(/[.*+?^${}()|[\]\\]/g,'\\$&') + ')','ig'), '<b>$1</b>') : en;
- const badge = p.kind === 'public'
- ? `<span class="badge public" title="${esc(p.domain)}">public</span>`
- : `<span class="badge" title="127.0.0.1:${p.port}">:${p.port}</span>`;
- row.innerHTML = `<span class="dot ${dotCls}" title="${tip}"></span>`
- + `<span class="name">${label}</span>${badge}`
- + (s && s.up ? `<span class="ms">${s.ms}ms</span>` : s ? `<span class="ms">—</span>` : `<span class="ms"></span>`);
- row.onclick = () => select(p.slug);
- list.appendChild(row);
+ const c = p.category || 'Other';
+ if (!groups.has(c)) groups.set(c, []);
+ groups.get(c).push(p);
+ }
+ const curCat = current ? (projects.find(p => p.slug === current) || {}).category : null;
+
+ list.innerHTML = '';
+ for (const [cat, items] of groups) {
+ if (!items.length) continue;
+ // Open when: actively searching, this holds the current selection, or the
+ // user explicitly opened it. Otherwise collapsed (the compact default).
+ const open = searching || cat === curCat || collapsed[cat] === false;
+ const sec = document.createElement('div');
+ sec.className = 'sec';
+ const head = document.createElement('div');
+ head.className = 'sec-h' + (open ? ' open' : '');
+ const upCount = items.filter(p => (status[p.slug] || {}).up).length;
+ head.innerHTML = `<span class="chev">▸</span><span class="sec-name">${esc(cat)}</span>`
+ + `<span class="cnt">${upCount ? `<i class="up-dot"></i>${upCount}/` : ''}${items.length}</span>`;
+ head.onclick = () => {
+ // Toggle across 3 states (undefined/true = closed, false = open):
+ // undefined→false(open), false→true(closed), true→false(open).
+ collapsed[cat] = (collapsed[cat] === false);
+ localStorage.setItem('paa-collapsed', JSON.stringify(collapsed));
+ render();
+ };
+ sec.appendChild(head);
+ if (open) {
+ const body = document.createElement('div');
+ body.className = 'sec-body';
+ for (const p of items) body.appendChild(buildRow(p, f));
+ sec.appendChild(body);
+ }
+ list.appendChild(sec);
}
}
← d08aba3 deploy.conf: tailnet-proxy model; live at projects.agentabra
·
back to Projects Agentabrams
·
(newest)