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
|
// Guided tour
|
||||||
private _tour!: TourEngine;
|
private _tour!: TourEngine;
|
||||||
private static readonly TOUR_STEPS = [
|
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: '.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: '[data-tab="contacts"]', title: "Contacts", message: "View and search your contact directory. Click a contact to see their details.", advanceOnClick: true },
|
|
||||||
{ target: '#crm-search', title: "Search", message: "Search across contacts and companies by name, email, or city. Results filter in real time.", 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: '.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 },
|
||||||
{ 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 },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
constructor() {
|
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() {
|
connectedCallback() {
|
||||||
this.space = this.getAttribute("space") || "demo";
|
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.render();
|
||||||
this.loadData();
|
this.loadData();
|
||||||
// Auto-start tour on first visit
|
// 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 {
|
private getApiBase(): string {
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
const match = path.match(/^(\/[^/]+)?\/rnetwork/);
|
const match = path.match(/^(\/[^/]+)?\/rnetwork/);
|
||||||
|
|
@ -667,14 +688,6 @@ class FolkCrmView extends HTMLElement {
|
||||||
|
|
||||||
// ── Main render ──
|
// ── Main render ──
|
||||||
private 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 = "";
|
let content = "";
|
||||||
if (this.loading) {
|
if (this.loading) {
|
||||||
content = `<div class="loading">Loading CRM data...</div>`;
|
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-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); }
|
.crm-title { font-size: 15px; font-weight: 600; color: var(--rs-text-primary); }
|
||||||
|
.tour-btn {
|
||||||
.tabs { display: flex; gap: 2px; background: var(--rs-input-bg); border-radius: 10px; padding: 3px; margin-bottom: 16px; }
|
font-size: 0.78rem; padding: 4px 10px; border-radius: 8px; border: none;
|
||||||
.tab {
|
background: transparent; color: var(--rs-text-muted); cursor: pointer;
|
||||||
padding: 8px 16px; border-radius: 8px; border: none;
|
font-family: inherit; transition: color 0.15s, background 0.15s;
|
||||||
background: transparent; color: var(--rs-text-muted); cursor: pointer; font-size: 13px;
|
|
||||||
font-weight: 500; transition: all 0.15s;
|
|
||||||
}
|
}
|
||||||
.tab:hover { color: var(--rs-text-secondary); }
|
.tour-btn:hover { color: var(--rs-text-primary); background: var(--rs-bg-surface-raised); }
|
||||||
.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); }
|
|
||||||
|
|
||||||
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; }
|
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; }
|
||||||
.search-input {
|
.search-input {
|
||||||
|
|
@ -820,7 +828,6 @@ class FolkCrmView extends HTMLElement {
|
||||||
}
|
}
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.pipeline-grid { grid-template-columns: 1fr; }
|
.pipeline-grid { grid-template-columns: 1fr; }
|
||||||
.tabs { flex-wrap: wrap; }
|
|
||||||
.toolbar { flex-direction: column; align-items: stretch; }
|
.toolbar { flex-direction: column; align-items: stretch; }
|
||||||
.search-input { width: 100%; }
|
.search-input { width: 100%; }
|
||||||
.data-table td, .data-table th { padding: 8px 10px; font-size: 12px; }
|
.data-table td, .data-table th { padding: 8px 10px; font-size: 12px; }
|
||||||
|
|
@ -831,13 +838,7 @@ class FolkCrmView extends HTMLElement {
|
||||||
<div class="crm-header">
|
<div class="crm-header">
|
||||||
<span class="crm-title">CRM</span>
|
<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>
|
<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>
|
<button class="tour-btn" id="btn-tour">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("")}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${showSearch ? `<div class="toolbar">
|
${showSearch ? `<div class="toolbar">
|
||||||
|
|
@ -857,17 +858,6 @@ class FolkCrmView extends HTMLElement {
|
||||||
// Tour button
|
// Tour button
|
||||||
this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour());
|
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
|
// Search
|
||||||
let searchTimeout: any;
|
let searchTimeout: any;
|
||||||
this.shadow.getElementById("crm-search")?.addEventListener("input", (e) => {
|
this.shadow.getElementById("crm-search")?.addEventListener("input", (e) => {
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,7 @@ routes.get("/api/users", async (c) => {
|
||||||
// ── API: Trust scores for graph visualization ──
|
// ── API: Trust scores for graph visualization ──
|
||||||
routes.get("/api/trust", async (c) => {
|
routes.get("/api/trust", async (c) => {
|
||||||
const space = getTrustSpace(c);
|
const space = getTrustSpace(c);
|
||||||
const authority = c.req.query("authority") || "voting";
|
const authority = c.req.query("authority") || "gov-ops";
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${ENCRYPTID_URL}/api/trust/scores?space=${encodeURIComponent(space)}&authority=${encodeURIComponent(authority)}`,
|
`${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)
|
// Check per-space cache (keyed by space + trust params)
|
||||||
const includeTrust = c.req.query("trust") === "true";
|
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 cacheKey = includeTrust ? `${dataSpace}:trust:${authority}` : dataSpace;
|
||||||
const cached = graphCaches.get(cacheKey);
|
const cached = graphCaches.get(cacheKey);
|
||||||
if (cached && Date.now() - cached.ts < CACHE_TTL) {
|
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)
|
// Trust data uses per-space scoping (not module's global scoping)
|
||||||
if (includeTrust) {
|
if (includeTrust) {
|
||||||
const trustSpace = c.req.param("space") || "demo";
|
const trustSpace = c.req.param("space") || "demo";
|
||||||
|
const isAllAuthority = authority === "all";
|
||||||
try {
|
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/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(delegUrl, { signal: AbortSignal.timeout(5000) }),
|
||||||
fetch(`${ENCRYPTID_URL}/api/delegations/space?space=${encodeURIComponent(trustSpace)}&authority=${encodeURIComponent(authority)}`, { 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) {
|
if (usersRes.ok) {
|
||||||
const userData = await usersRes.json() as { users: Array<{ did: string; username: string; displayName: string | null; trustScores: Record<string, number> }> };
|
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 || []) {
|
for (const u of userData.users || []) {
|
||||||
if (!nodeIds.has(u.did)) {
|
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({
|
nodes.push({
|
||||||
id: u.did,
|
id: u.did,
|
||||||
label: u.displayName || u.username,
|
label: u.displayName || u.username,
|
||||||
type: "rspace_user" as any,
|
type: "rspace_user" as any,
|
||||||
data: { trustScore, authority, role: "member" },
|
data: { trustScore, authority: isAllAuthority ? "all" : authority, role: "member" },
|
||||||
});
|
});
|
||||||
nodeIds.add(u.did);
|
nodeIds.add(u.did);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scoresRes.ok) {
|
// Merge trust scores from all score responses
|
||||||
const scoreData = await scoresRes.json() as { scores: Array<{ did: string; totalScore: number }> };
|
const trustMap = new Map<string, number>();
|
||||||
const trustMap = new Map<string, number>();
|
for (const scoresRes of scoreResponses) {
|
||||||
for (const s of scoreData.scores || []) {
|
if (scoresRes.ok) {
|
||||||
trustMap.set(s.did, s.totalScore);
|
const scoreData = await scoresRes.json() as { scores: Array<{ did: string; totalScore: number }> };
|
||||||
}
|
for (const s of scoreData.scores || []) {
|
||||||
for (const node of nodes) {
|
const existing = trustMap.get(s.did) || 0;
|
||||||
if (trustMap.has(node.id)) {
|
trustMap.set(s.did, isAllAuthority ? Math.max(existing, s.totalScore) : s.totalScore);
|
||||||
(node.data as any).trustScore = trustMap.get(node.id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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) {
|
if (delegRes.ok) {
|
||||||
const delegData = await delegRes.json() as { delegations: Array<{ from: string; to: string; authority: string; weight: number }> };
|
const delegData = await delegRes.json() as { delegations: Array<{ from: string; to: string; authority: string; weight: number }> };
|
||||||
for (const d of delegData.delegations || []) {
|
for (const d of delegData.delegations || []) {
|
||||||
if (nodeIds.has(d.from) && nodeIds.has(d.to)) {
|
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-delegation-manager.js"></script>
|
||||||
<script type="module" src="/modules/rnetwork/folk-trust-sankey.js"></script>`,
|
<script type="module" src="/modules/rnetwork/folk-trust-sankey.js"></script>`,
|
||||||
styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`,
|
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;
|
enabledModules?: string[] | null;
|
||||||
/** Whether this space has client-side encryption enabled */
|
/** Whether this space has client-side encryption enabled */
|
||||||
spaceEncrypted?: boolean;
|
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 {
|
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-header { display: none !important; }
|
||||||
html.rspace-embedded .rstack-tab-row { display: none !important; }
|
html.rspace-embedded .rstack-tab-row { display: none !important; }
|
||||||
html.rspace-embedded .rapp-subnav { 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 #app { padding-top: 0 !important; }
|
||||||
html.rspace-embedded .rspace-iframe-loading,
|
html.rspace-embedded .rspace-iframe-loading,
|
||||||
html.rspace-embedded .rspace-iframe-error { top: 0 !important; }
|
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>${ACCESS_GATE_CSS}</style>
|
||||||
<style>${INFO_PANEL_CSS}</style>
|
<style>${INFO_PANEL_CSS}</style>
|
||||||
<style>${SUBNAV_CSS}</style>
|
<style>${SUBNAV_CSS}</style>
|
||||||
|
<style>${TABBAR_CSS}</style>
|
||||||
</head>
|
</head>
|
||||||
<body data-space-visibility="${escapeAttr(spaceVisibility)}" data-space-slug="${escapeAttr(spaceSlug)}" data-scope-overrides="${escapeAttr(JSON.stringify(scopeOverrides))}">
|
<body data-space-visibility="${escapeAttr(spaceVisibility)}" data-space-slug="${escapeAttr(spaceSlug)}" data-scope-overrides="${escapeAttr(JSON.stringify(scopeOverrides))}">
|
||||||
<header class="rstack-header">
|
<header class="rstack-header">
|
||||||
|
|
@ -166,6 +170,7 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
</rstack-tab-bar>
|
</rstack-tab-bar>
|
||||||
</div>
|
</div>
|
||||||
${renderModuleSubNav(moduleId, spaceSlug, visibleModules)}
|
${renderModuleSubNav(moduleId, spaceSlug, visibleModules)}
|
||||||
|
${opts.tabs ? renderTabBar(opts.tabs) : ''}
|
||||||
<div id="rapp-info-panel" class="rapp-info-panel" style="display:none">
|
<div id="rapp-info-panel" class="rapp-info-panel" style="display:none">
|
||||||
<div class="rapp-info-panel__header">
|
<div class="rapp-info-panel__header">
|
||||||
<span class="rapp-info-panel__title">About</span>
|
<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>`;
|
<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}) ──
|
// ── Module landing page (bare-domain rspace.online/{moduleId}) ──
|
||||||
|
|
||||||
export interface ModuleLandingOptions {
|
export interface ModuleLandingOptions {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue