From f2d575d1a2b36314434a9b8221e6afd1daf79eb5 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 11 Mar 2026 20:50:26 -0700 Subject: [PATCH] feat(shell): add consistent rApp tab bar system with URL-backed ?tab= params Server-rendered tab bar via renderShell tabs option. Tabs use ?tab= query params with history.replaceState and dispatch rapp-tab-change events. Migrated rNetwork CRM from internal Shadow DOM tabs to the shared system. Co-Authored-By: Claude Opus 4.6 --- modules/rnetwork/components/folk-crm-view.ts | 72 +++++++++----------- modules/rnetwork/mod.ts | 70 +++++++++++++------ server/shell.ts | 69 +++++++++++++++++++ 3 files changed, 151 insertions(+), 60 deletions(-) diff --git a/modules/rnetwork/components/folk-crm-view.ts b/modules/rnetwork/components/folk-crm-view.ts index 77dd61e..8082e70 100644 --- a/modules/rnetwork/components/folk-crm-view.ts +++ b/modules/rnetwork/components/folk-crm-view.ts @@ -105,11 +105,9 @@ class FolkCrmView extends HTMLElement { // Guided tour private _tour!: TourEngine; private static readonly TOUR_STEPS = [ - { target: '[data-tab="pipeline"]', title: "Pipeline", message: "Track deals through stages — from incoming leads to closed-won. Drag cards to update their stage.", advanceOnClick: true }, - { target: '[data-tab="contacts"]', title: "Contacts", message: "View and search your contact directory. Click a contact to see their details.", advanceOnClick: true }, + { target: '.crm-header', title: "CRM Overview", message: "Track deals through stages — from incoming leads to closed-won. Use the tab bar above to switch between Pipeline, Contacts, Companies, Graph, and Delegations.", advanceOnClick: false }, { target: '#crm-search', title: "Search", message: "Search across contacts and companies by name, email, or city. Results filter in real time.", advanceOnClick: false }, - { target: '[data-tab="graph"]', title: "Relationship Graph", message: "Visualise connections between people and companies as an interactive network graph.", advanceOnClick: true }, - { target: '[data-tab="delegations"]', title: "Delegations", message: "Manage delegative trust — assign voting, moderation, and other authority to community members. View flows as a Sankey diagram.", advanceOnClick: true }, + { target: '.crm-content', title: "Content Area", message: "Each tab shows a different view: Pipeline cards, contact/company tables, a relationship graph, or delegation management.", advanceOnClick: false }, ]; constructor() { @@ -123,8 +121,27 @@ class FolkCrmView extends HTMLElement { ); } + private _onTabChange = (e: Event) => { + const tab = (e as CustomEvent).detail?.tab; + if (tab && tab !== this.activeTab) { + this.activeTab = tab as Tab; + this.searchQuery = ""; + this.sortColumn = ""; + this.sortAsc = true; + this.render(); + } + }; + connectedCallback() { this.space = this.getAttribute("space") || "demo"; + // Read initial tab from URL + const params = new URLSearchParams(window.location.search); + const urlTab = params.get("tab"); + if (urlTab && ["pipeline", "contacts", "companies", "graph", "delegations"].includes(urlTab)) { + this.activeTab = urlTab as Tab; + } + // Listen for server-rendered tab bar changes + document.addEventListener("rapp-tab-change", this._onTabChange); this.render(); this.loadData(); // Auto-start tour on first visit @@ -133,6 +150,10 @@ class FolkCrmView extends HTMLElement { } } + disconnectedCallback() { + document.removeEventListener("rapp-tab-change", this._onTabChange); + } + private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^(\/[^/]+)?\/rnetwork/); @@ -667,14 +688,6 @@ class FolkCrmView extends HTMLElement { // ── Main render ── private render() { - const tabs: { id: Tab; label: string; count: number }[] = [ - { id: "pipeline", label: "Pipeline", count: this.opportunities.length }, - { id: "contacts", label: "Contacts", count: this.people.length }, - { id: "companies", label: "Companies", count: this.companies.length }, - { id: "graph", label: "Graph", count: 0 }, - { id: "delegations", label: "Delegations", count: 0 }, - ]; - let content = ""; if (this.loading) { content = `
Loading CRM data...
`; @@ -699,17 +712,12 @@ class FolkCrmView extends HTMLElement { .crm-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; } .crm-title { font-size: 15px; font-weight: 600; color: var(--rs-text-primary); } - - .tabs { display: flex; gap: 2px; background: var(--rs-input-bg); border-radius: 10px; padding: 3px; margin-bottom: 16px; } - .tab { - padding: 8px 16px; border-radius: 8px; border: none; - background: transparent; color: var(--rs-text-muted); cursor: pointer; font-size: 13px; - font-weight: 500; transition: all 0.15s; + .tour-btn { + font-size: 0.78rem; padding: 4px 10px; border-radius: 8px; border: none; + background: transparent; color: var(--rs-text-muted); cursor: pointer; + font-family: inherit; transition: color 0.15s, background 0.15s; } - .tab:hover { color: var(--rs-text-secondary); } - .tab.active { background: var(--rs-bg-surface); color: var(--rs-text-primary); } - .tab-count { font-size: 11px; color: var(--rs-text-muted); margin-left: 4px; } - .tab.active .tab-count { color: var(--rs-primary-hover); } + .tour-btn:hover { color: var(--rs-text-primary); background: var(--rs-bg-surface-raised); } .toolbar { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; } .search-input { @@ -820,7 +828,6 @@ class FolkCrmView extends HTMLElement { } @media (max-width: 600px) { .pipeline-grid { grid-template-columns: 1fr; } - .tabs { flex-wrap: wrap; } .toolbar { flex-direction: column; align-items: stretch; } .search-input { width: 100%; } .data-table td, .data-table th { padding: 8px 10px; font-size: 12px; } @@ -831,13 +838,7 @@ class FolkCrmView extends HTMLElement {
CRM Full CRM → - -
- -
- ${tabs.map(t => ``).join("")} +
${showSearch ? `
@@ -857,17 +858,6 @@ class FolkCrmView extends HTMLElement { // Tour button this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour()); - // Tab switching - this.shadow.querySelectorAll("[data-tab]").forEach(el => { - el.addEventListener("click", () => { - this.activeTab = (el as HTMLElement).dataset.tab as Tab; - this.searchQuery = ""; - this.sortColumn = ""; - this.sortAsc = true; - this.render(); - }); - }); - // Search let searchTimeout: any; this.shadow.getElementById("crm-search")?.addEventListener("input", (e) => { diff --git a/modules/rnetwork/mod.ts b/modules/rnetwork/mod.ts index e5de31a..7cf27c7 100644 --- a/modules/rnetwork/mod.ts +++ b/modules/rnetwork/mod.ts @@ -161,7 +161,7 @@ routes.get("/api/users", async (c) => { // ── API: Trust scores for graph visualization ── routes.get("/api/trust", async (c) => { const space = getTrustSpace(c); - const authority = c.req.query("authority") || "voting"; + const authority = c.req.query("authority") || "gov-ops"; try { const res = await fetch( `${ENCRYPTID_URL}/api/trust/scores?space=${encodeURIComponent(space)}&authority=${encodeURIComponent(authority)}`, @@ -198,7 +198,7 @@ routes.get("/api/graph", async (c) => { // Check per-space cache (keyed by space + trust params) const includeTrust = c.req.query("trust") === "true"; - const authority = c.req.query("authority") || "voting"; + const authority = c.req.query("authority") || "gov-ops"; const cacheKey = includeTrust ? `${dataSpace}:trust:${authority}` : dataSpace; const cached = graphCaches.get(cacheKey); if (cached && Date.now() - cached.ts < CACHE_TTL) { @@ -264,48 +264,73 @@ routes.get("/api/graph", async (c) => { // Trust data uses per-space scoping (not module's global scoping) if (includeTrust) { const trustSpace = c.req.param("space") || "demo"; + const isAllAuthority = authority === "all"; try { - const [usersRes, scoresRes, delegRes] = await Promise.all([ + // For "all" mode: fetch delegations without authority filter, scores for all authorities + const delegUrl = new URL(`${ENCRYPTID_URL}/api/delegations/space`); + delegUrl.searchParams.set("space", trustSpace); + if (!isAllAuthority) delegUrl.searchParams.set("authority", authority); + + const fetches: Promise[] = [ fetch(`${ENCRYPTID_URL}/api/users/directory?space=${encodeURIComponent(trustSpace)}`, { signal: AbortSignal.timeout(5000) }), - fetch(`${ENCRYPTID_URL}/api/trust/scores?space=${encodeURIComponent(trustSpace)}&authority=${encodeURIComponent(authority)}`, { signal: AbortSignal.timeout(5000) }), - fetch(`${ENCRYPTID_URL}/api/delegations/space?space=${encodeURIComponent(trustSpace)}&authority=${encodeURIComponent(authority)}`, { signal: AbortSignal.timeout(5000) }), - ]); + fetch(delegUrl, { signal: AbortSignal.timeout(5000) }), + ]; + if (isAllAuthority) { + for (const a of ["gov-ops", "fin-ops", "dev-ops"]) { + fetches.push(fetch(`${ENCRYPTID_URL}/api/trust/scores?space=${encodeURIComponent(trustSpace)}&authority=${encodeURIComponent(a)}`, { signal: AbortSignal.timeout(5000) })); + } + } else { + fetches.push(fetch(`${ENCRYPTID_URL}/api/trust/scores?space=${encodeURIComponent(trustSpace)}&authority=${encodeURIComponent(authority)}`, { signal: AbortSignal.timeout(5000) })); + } + + const responses = await Promise.all(fetches); + const [usersRes, delegRes, ...scoreResponses] = responses; if (usersRes.ok) { const userData = await usersRes.json() as { users: Array<{ did: string; username: string; displayName: string | null; trustScores: Record }> }; for (const u of userData.users || []) { if (!nodeIds.has(u.did)) { - const trustScore = u.trustScores?.[authority] ?? 0; + let trustScore = 0; + if (isAllAuthority && u.trustScores) { + const vals = Object.values(u.trustScores).filter(v => v > 0); + trustScore = vals.length > 0 ? vals.reduce((a, b) => a + b, 0) / vals.length : 0; + } else { + trustScore = u.trustScores?.[authority] ?? 0; + } nodes.push({ id: u.did, label: u.displayName || u.username, type: "rspace_user" as any, - data: { trustScore, authority, role: "member" }, + data: { trustScore, authority: isAllAuthority ? "all" : authority, role: "member" }, }); nodeIds.add(u.did); } } } - if (scoresRes.ok) { - const scoreData = await scoresRes.json() as { scores: Array<{ did: string; totalScore: number }> }; - const trustMap = new Map(); - for (const s of scoreData.scores || []) { - trustMap.set(s.did, s.totalScore); - } - for (const node of nodes) { - if (trustMap.has(node.id)) { - (node.data as any).trustScore = trustMap.get(node.id); + // Merge trust scores from all score responses + const trustMap = new Map(); + for (const scoresRes of scoreResponses) { + if (scoresRes.ok) { + const scoreData = await scoresRes.json() as { scores: Array<{ did: string; totalScore: number }> }; + for (const s of scoreData.scores || []) { + const existing = trustMap.get(s.did) || 0; + trustMap.set(s.did, isAllAuthority ? Math.max(existing, s.totalScore) : s.totalScore); } } } + for (const node of nodes) { + if (trustMap.has(node.id)) { + (node.data as any).trustScore = trustMap.get(node.id); + } + } - // Add delegation edges + // Add delegation edges (with authority tag for per-authority coloring) if (delegRes.ok) { const delegData = await delegRes.json() as { delegations: Array<{ from: string; to: string; authority: string; weight: number }> }; for (const d of delegData.delegations || []) { if (nodeIds.has(d.from) && nodeIds.has(d.to)) { - edges.push({ source: d.from, target: d.to, type: "delegates_to", weight: d.weight } as any); + edges.push({ source: d.from, target: d.to, type: "delegates_to", weight: d.weight, authority: d.authority } as any); } } } @@ -369,6 +394,13 @@ routes.get("/crm", (c) => { `, styles: ``, + tabs: [ + { id: "pipeline", label: "Pipeline" }, + { id: "contacts", label: "Contacts" }, + { id: "companies", label: "Companies" }, + { id: "graph", label: "Graph" }, + { id: "delegations", label: "Delegations" }, + ], })); }); diff --git a/server/shell.ts b/server/shell.ts index 19d1511..3c76639 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -63,6 +63,8 @@ export interface ShellOptions { enabledModules?: string[] | null; /** Whether this space has client-side encryption enabled */ spaceEncrypted?: boolean; + /** Optional tab bar rendered below the subnav. Uses ?tab= query params. */ + tabs?: Array<{ id: string; label: string; icon?: string }>; } export function renderShell(opts: ShellOptions): string { @@ -128,6 +130,7 @@ export function renderShell(opts: ShellOptions): string { html.rspace-embedded .rstack-header { display: none !important; } html.rspace-embedded .rstack-tab-row { display: none !important; } html.rspace-embedded .rapp-subnav { display: none !important; } + html.rspace-embedded .rapp-tabbar { display: none !important; } html.rspace-embedded #app { padding-top: 0 !important; } html.rspace-embedded .rspace-iframe-loading, html.rspace-embedded .rspace-iframe-error { top: 0 !important; } @@ -139,6 +142,7 @@ export function renderShell(opts: ShellOptions): string { +
@@ -166,6 +170,7 @@ export function renderShell(opts: ShellOptions): string {
${renderModuleSubNav(moduleId, spaceSlug, visibleModules)} + ${opts.tabs ? renderTabBar(opts.tabs) : ''}