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 <noreply@anthropic.com>
This commit is contained in:
parent
adb0d173d8
commit
f2d575d1a2
|
|
@ -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 = `<div class="loading">Loading CRM data...</div>`;
|
||||
|
|
@ -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 {
|
|||
<div class="crm-header">
|
||||
<span class="crm-title">CRM</span>
|
||||
<a href="https://crm.rspace.online" target="_blank" rel="noopener" style="margin-left:auto;font-size:0.78rem;padding:4px 10px;color:var(--link,#60a5fa);text-decoration:none" title="Open full Twenty CRM">Full CRM →</a>
|
||||
<button class="tab" id="btn-tour" style="font-size:0.78rem;padding:4px 10px">Tour</button>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
${tabs.map(t => `<button class="tab ${this.activeTab === t.id ? "active" : ""}" data-tab="${t.id}">
|
||||
${t.label}${t.count > 0 ? `<span class="tab-count">${t.count}</span>` : ""}
|
||||
</button>`).join("")}
|
||||
<button class="tour-btn" id="btn-tour">Tour</button>
|
||||
</div>
|
||||
|
||||
${showSearch ? `<div class="toolbar">
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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<Response>[] = [
|
||||
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<string, number> }> };
|
||||
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<string, number>();
|
||||
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<string, number>();
|
||||
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) => {
|
|||
<script type="module" src="/modules/rnetwork/folk-delegation-manager.js"></script>
|
||||
<script type="module" src="/modules/rnetwork/folk-trust-sankey.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`,
|
||||
tabs: [
|
||||
{ id: "pipeline", label: "Pipeline" },
|
||||
{ id: "contacts", label: "Contacts" },
|
||||
{ id: "companies", label: "Companies" },
|
||||
{ id: "graph", label: "Graph" },
|
||||
{ id: "delegations", label: "Delegations" },
|
||||
],
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
<style>${ACCESS_GATE_CSS}</style>
|
||||
<style>${INFO_PANEL_CSS}</style>
|
||||
<style>${SUBNAV_CSS}</style>
|
||||
<style>${TABBAR_CSS}</style>
|
||||
</head>
|
||||
<body data-space-visibility="${escapeAttr(spaceVisibility)}" data-space-slug="${escapeAttr(spaceSlug)}" data-scope-overrides="${escapeAttr(JSON.stringify(scopeOverrides))}">
|
||||
<header class="rstack-header">
|
||||
|
|
@ -166,6 +170,7 @@ export function renderShell(opts: ShellOptions): string {
|
|||
</rstack-tab-bar>
|
||||
</div>
|
||||
${renderModuleSubNav(moduleId, spaceSlug, visibleModules)}
|
||||
${opts.tabs ? renderTabBar(opts.tabs) : ''}
|
||||
<div id="rapp-info-panel" class="rapp-info-panel" style="display:none">
|
||||
<div class="rapp-info-panel__header">
|
||||
<span class="rapp-info-panel__title">About</span>
|
||||
|
|
@ -1212,6 +1217,70 @@ function renderModuleSubNav(moduleId: string, spaceSlug: string, modules: Module
|
|||
<script>(function(){var ps=document.querySelectorAll('.rapp-subnav__pill'),p=location.pathname.replace(/\\/$/,'');var matched=false;ps.forEach(function(a){var h=a.getAttribute('href');if(h&&h===p){a.classList.add('rapp-subnav__pill--active');matched=true}else if(h&&p.startsWith(h+'/')&&!a.hasAttribute('data-subnav-root')){a.classList.add('rapp-subnav__pill--active');matched=true}});if(!matched){var root=document.querySelector('[data-subnav-root]');if(root)root.classList.add('rapp-subnav__pill--active')}})()</script>`;
|
||||
}
|
||||
|
||||
// ── rApp tab bar (in-page tabs via ?tab= query params) ──
|
||||
|
||||
const TABBAR_CSS = `
|
||||
.rapp-tabbar {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 1rem;
|
||||
overflow-x: auto;
|
||||
border-bottom: 1px solid var(--rs-border);
|
||||
background: var(--rs-bg-surface);
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.rapp-tabbar::-webkit-scrollbar { display: none; }
|
||||
.rapp-tabbar__pill {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
color: var(--rs-text-muted);
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.rapp-tabbar__pill:hover { color: var(--rs-text-primary); background: var(--rs-bg-surface-raised); }
|
||||
.rapp-tabbar__pill--active {
|
||||
color: var(--rs-text-primary);
|
||||
background: var(--rs-bg-surface-raised);
|
||||
border-color: var(--rs-border);
|
||||
font-weight: 500;
|
||||
}
|
||||
`;
|
||||
|
||||
function renderTabBar(tabs: Array<{ id: string; label: string; icon?: string }>): string {
|
||||
if (tabs.length === 0) return '';
|
||||
|
||||
const pills = tabs.map(t =>
|
||||
`<button class="rapp-tabbar__pill" data-tab-id="${escapeAttr(t.id)}">${t.icon ? escapeHtml(t.icon) + ' ' : ''}${escapeHtml(t.label)}</button>`
|
||||
).join('');
|
||||
|
||||
return `<nav class="rapp-tabbar" id="rapp-tabbar">${pills}</nav>
|
||||
<script>(function(){
|
||||
var pills = document.querySelectorAll('.rapp-tabbar__pill');
|
||||
var params = new URLSearchParams(location.search);
|
||||
var active = params.get('tab') || pills[0]?.dataset.tabId || '';
|
||||
pills.forEach(function(btn) {
|
||||
if (btn.dataset.tabId === active) btn.classList.add('rapp-tabbar__pill--active');
|
||||
btn.addEventListener('click', function() {
|
||||
var id = btn.dataset.tabId;
|
||||
pills.forEach(function(p) { p.classList.remove('rapp-tabbar__pill--active'); });
|
||||
btn.classList.add('rapp-tabbar__pill--active');
|
||||
var u = new URL(location.href);
|
||||
u.searchParams.set('tab', id);
|
||||
history.replaceState(null, '', u);
|
||||
document.dispatchEvent(new CustomEvent('rapp-tab-change', { detail: { tab: id } }));
|
||||
});
|
||||
});
|
||||
// Dispatch initial tab on load so components can read it
|
||||
document.dispatchEvent(new CustomEvent('rapp-tab-change', { detail: { tab: active } }));
|
||||
})()</script>`;
|
||||
}
|
||||
|
||||
// ── Module landing page (bare-domain rspace.online/{moduleId}) ──
|
||||
|
||||
export interface ModuleLandingOptions {
|
||||
|
|
|
|||
Loading…
Reference in New Issue