Compare commits

..

3 Commits

Author SHA1 Message Date
Jeff Emmett c2fb1b2858 Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m28s Details
2026-04-15 17:32:24 -04:00
Jeff Emmett 932e550c66 fix(shell): eliminate phantom tab persistence — validate + dedup on restore
Filter restored tabs against valid moduleIds from moduleList, deduplicate
on every restore path (localStorage, server sync, BroadcastChannel).
Add closed-module tracking to renderExternalAppShell to prevent server
sync from resurrecting tabs closed in the current session.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 17:32:15 -04:00
Jeff Emmett cb521cad12 feat(rtime): shell tab bar + pool legend, remove internal tabs/stats bar
- Add shell tabs: Commitment Pool, Fulfillment, Open in Cyclos
- Move stats (hours, contributors, skill breakdown) into pool legend
- Remove internal tab-bar and stats-bar from component
- Listen for rapp-tab-change shell events for view switching
- Legacy routes redirect to new tab paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 17:27:46 -04:00
3 changed files with 126 additions and 153 deletions

View File

@ -362,6 +362,29 @@ class FolkTimebankApp extends HTMLElement {
if (name === 'view' && (val === 'canvas' || val === 'collaborate' || val === 'dashboard')) this.currentView = val;
}
private _onShellTabChange = (e: Event) => {
const tab = (e as CustomEvent).detail?.tab;
if (!tab) return;
if (tab === 'pool') this._switchView('canvas');
else if (tab === 'fulfillment') this._switchView('dashboard');
// 'cyclos' tab is a redirect, no client-side handling needed
};
private _switchView(view: 'canvas' | 'collaborate' | 'dashboard') {
if (view === this.currentView) return;
this._history.push(view);
this.currentView = view;
const canvasView = this.shadow.getElementById('canvas-view');
const collabView = this.shadow.getElementById('collaborate-view');
const dashView = this.shadow.getElementById('dashboard-view');
if (canvasView) canvasView.style.display = view === 'canvas' ? 'flex' : 'none';
if (collabView) collabView.style.display = view === 'collaborate' ? 'flex' : 'none';
if (dashView) dashView.style.display = view === 'dashboard' ? 'flex' : 'none';
if (view === 'canvas') { this.resizePoolCanvas(); this.rebuildSidebar(); }
if (view === 'collaborate') this.refreshCollaborate();
if (view === 'dashboard') this.refreshDashboard();
}
connectedCallback() {
this.space = this.getAttribute('space') || 'demo';
const rawView = this.getAttribute('view');
@ -383,6 +406,7 @@ class FolkTimebankApp extends HTMLElement {
this.fetchData();
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rtime', context: 'Timebank' }));
if (this.space !== 'demo') this.subscribeOffline();
document.addEventListener('rapp-tab-change', this._onShellTabChange);
window.addEventListener('rspace-view-restored', this._onViewRestored as EventListener);
}
@ -421,6 +445,7 @@ class FolkTimebankApp extends HTMLElement {
disconnectedCallback() {
this._history.destroy();
document.removeEventListener('rapp-tab-change', this._onShellTabChange);
window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener);
if (this.animFrame) cancelAnimationFrame(this.animFrame);
this._stopPresence?.(); this._offlineUnsub?.(); this._offlineUnsub = null;
@ -435,18 +460,7 @@ class FolkTimebankApp extends HTMLElement {
private _onViewRestored = (e: CustomEvent) => {
if (e.detail?.moduleId !== 'rtime') return;
this.currentView = e.detail.view;
// Toggle visibility of view panels
const canvasView = this.shadow.getElementById('canvas-view');
const collabView = this.shadow.getElementById('collaborate-view');
const dashView = this.shadow.getElementById('dashboard-view');
if (canvasView) canvasView.style.display = this.currentView === 'canvas' ? 'flex' : 'none';
if (collabView) collabView.style.display = this.currentView === 'collaborate' ? 'flex' : 'none';
if (dashView) dashView.style.display = this.currentView === 'dashboard' ? 'flex' : 'none';
this.shadow.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', (t as HTMLElement).dataset.view === this.currentView));
if (this.currentView === 'canvas') { this.resizePoolCanvas(); this.rebuildSidebar(); }
if (this.currentView === 'collaborate') this.refreshCollaborate();
if (this.currentView === 'dashboard') this.refreshDashboard();
this._switchView(e.detail.view);
};
private async fetchData() {
@ -528,18 +542,6 @@ class FolkTimebankApp extends HTMLElement {
private render() {
this.shadow.innerHTML = `
<style>${CSS_TEXT}</style>
<div class="tab-bar">
<div class="tab active" data-view="canvas">Canvas</div>
<div class="tab" data-view="collaborate">Collaborate</div>
<div class="tab" data-view="dashboard">Fulfillment</div>
<button class="theme-toggle" id="themeToggle" title="Toggle light/dark theme"></button>
</div>
<div class="stats-bar">
<div class="stat"><span class="stat-value" id="statHours">0</span>&nbsp;hours available</div>
<div class="stat"><span class="stat-value" id="statContributors">0</span>&nbsp;contributors</div>
<div class="skill-bar" id="skillBar"></div>
<div class="skill-legend" id="skillLegend"></div>
</div>
<div class="main">
<div id="canvas-view">
<!-- Left pool panel -->
@ -547,9 +549,18 @@ class FolkTimebankApp extends HTMLElement {
<div class="pool-panel-header">
<span>Commitment Pool</span>
<span class="pool-hint">drag orb to weave \u2192</span>
<button class="theme-toggle" id="themeToggle" title="Toggle light/dark theme"></button>
<button id="poolPanelToggle" title="Collapse panel">\u27E8</button>
</div>
<canvas id="pool-canvas"></canvas>
<div class="pool-legend" id="poolLegend">
<div class="pool-legend-stats">
<span><strong id="statHours">0</strong> hours</span>
<span class="pool-legend-sep">\u00b7</span>
<span><strong id="statContributors">0</strong> contributors</span>
</div>
<div class="pool-legend-skills" id="skillLegend"></div>
</div>
<div class="pool-detail" id="poolDetail">
<div class="pool-detail-header">
<div class="pool-detail-dot" id="detailDot"></div>
@ -781,26 +792,6 @@ class FolkTimebankApp extends HTMLElement {
this.canvas = this.shadow.getElementById('pool-canvas') as HTMLCanvasElement;
this.ctx = this.canvas.getContext('2d')!;
// Tab switching (3 tabs: canvas, collaborate, dashboard)
this.shadow.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
const view = (tab as HTMLElement).dataset.view as 'canvas' | 'collaborate' | 'dashboard';
if (view === this.currentView) return;
this._history.push(view);
this.currentView = view;
this.shadow.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', (t as HTMLElement).dataset.view === view));
const canvasView = this.shadow.getElementById('canvas-view')!;
const collabView = this.shadow.getElementById('collaborate-view')!;
const dashView = this.shadow.getElementById('dashboard-view')!;
canvasView.style.display = view === 'canvas' ? 'flex' : 'none';
collabView.style.display = view === 'collaborate' ? 'flex' : 'none';
dashView.style.display = view === 'dashboard' ? 'flex' : 'none';
if (view === 'canvas') { this.resizePoolCanvas(); this.rebuildSidebar(); }
if (view === 'collaborate') this.refreshCollaborate();
if (view === 'dashboard') this.refreshDashboard();
});
});
// Theme toggle
this.shadow.getElementById('themeToggle')!.addEventListener('click', () => {
this._theme = this._theme === 'dark' ? 'light' : 'dark';
@ -1222,20 +1213,17 @@ class FolkTimebankApp extends HTMLElement {
private updateStats() {
const total = this.commitments.reduce((s, c) => s + c.hours, 0);
this.shadow.getElementById('statHours')!.textContent = String(total);
this.shadow.getElementById('statContributors')!.textContent = String(this.commitments.length);
const hoursEl = this.shadow.getElementById('statHours');
const contribEl = this.shadow.getElementById('statContributors');
if (hoursEl) hoursEl.textContent = String(total);
if (contribEl) contribEl.textContent = String(this.commitments.length);
const bySkill: Record<string, number> = {};
this.commitments.forEach(c => { bySkill[c.skill] = (bySkill[c.skill] || 0) + c.hours; });
const bar = this.shadow.getElementById('skillBar')!;
const legend = this.shadow.getElementById('skillLegend')!;
bar.innerHTML = ''; legend.innerHTML = '';
const legend = this.shadow.getElementById('skillLegend');
if (!legend) return;
legend.innerHTML = '';
if (total === 0) return;
for (const [skill, hrs] of Object.entries(bySkill)) {
const seg = document.createElement('div');
seg.className = 'skill-bar-segment';
seg.style.width = ((hrs as number) / total * 100) + '%';
seg.style.background = SKILL_COLORS[skill] || '#888';
bar.appendChild(seg);
const item = document.createElement('div');
item.className = 'skill-legend-item';
item.innerHTML = '<div class="skill-legend-dot" style="background:' + (SKILL_COLORS[skill] || '#888') + '"></div>' + (SKILL_LABELS[skill] || skill) + ' ' + hrs + 'h';
@ -3040,10 +3028,10 @@ class FolkTimebankApp extends HTMLElement {
if (this._tour) return;
const { TourEngine } = await import('../../../shared/tour-engine');
this._tour = new TourEngine(this.shadow as unknown as ShadowRoot, [
{ target: '.tab-bar', title: 'Canvas & Collaborate', message: 'The Canvas shows commitment orbs alongside the weaving SVG. Collaborate matches intents via the solver.' },
{ target: '.pool-panel-header', title: 'Canvas & Collaborate', message: 'The Canvas shows commitment orbs alongside the weaving SVG. Collaborate matches intents via the solver.' },
{ target: '#pool-canvas', title: 'Commitment Pool', message: 'Each floating orb represents a time commitment — sized by hours, colored by skill. Long-press to drag onto the canvas.' },
{ target: '#addBtn', title: 'Add a Commitment', message: 'Pledge your hours with a skill category. Your commitment joins the pool for others to see.', advanceOnClick: true },
{ target: '.stats-bar', title: 'Community Stats', message: 'See total hours available and how many contributors are in the pool at a glance.' },
{ target: '.pool-legend', title: 'Community Stats', message: 'See total hours available and how many contributors are in the pool at a glance.' },
], 'rtime_tour_done', () => this.shadow.querySelector('.main') as HTMLElement);
}
@ -3400,12 +3388,7 @@ class FolkTimebankApp extends HTMLElement {
this.rebuildSidebar();
// Switch to canvas view
this.currentView = 'canvas';
this.shadow.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', (t as HTMLElement).dataset.view === 'canvas'));
this.shadow.getElementById('canvas-view')!.style.display = 'flex';
this.shadow.getElementById('collaborate-view')!.style.display = 'none';
const dashView = this.shadow.getElementById('dashboard-view');
if (dashView) dashView.style.display = 'none';
this._switchView('canvas');
}
private renderSkillPrices() {
@ -3588,42 +3571,22 @@ const CSS_TEXT = `
overflow: hidden;
}
.tab-bar {
display: flex;
background: #1e293b;
border-bottom: 1px solid #334155;
.pool-legend {
padding: 0.5rem 0.75rem;
background: rgba(30, 27, 75, 0.6);
border-top: 1px solid #334155;
flex-shrink: 0;
}
.tab {
padding: 0.65rem 1.5rem;
font-size: 0.9rem;
font-weight: 500;
color: #64748b;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s;
user-select: none;
.pool-legend-stats {
display: flex; align-items: center; gap: 0.4rem;
font-size: 0.78rem; color: #94a3b8; margin-bottom: 0.3rem;
}
.tab:hover { color: #e2e8f0; }
.tab.active { color: #8b5cf6; border-bottom-color: #8b5cf6; }
.stats-bar {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 0.6rem 1.5rem;
background: linear-gradient(135deg, #1e1b4b 0%, #2d1b3d 100%);
border-bottom: 1px solid #334155;
flex-shrink: 0;
flex-wrap: wrap;
}
.stat { display: flex; align-items: center; gap: 0.4rem; font-size: 0.82rem; color: #94a3b8; }
.stat-value { font-weight: 700; font-size: 1rem; color: #f1f5f9; }
.skill-bar { display: flex; height: 6px; border-radius: 3px; overflow: hidden; flex: 1; min-width: 150px; max-width: 300px; }
.skill-bar-segment { transition: width 0.5s ease; }
.skill-legend { display: flex; gap: 0.75rem; flex-wrap: wrap; }
.skill-legend-item { display: flex; align-items: center; gap: 0.3rem; font-size: 0.75rem; color: #94a3b8; }
.skill-legend-dot { width: 8px; height: 8px; border-radius: 50%; }
.pool-legend-stats strong { font-weight: 700; color: #f1f5f9; }
.pool-legend-sep { color: #475569; }
.pool-legend-skills { display: flex; gap: 0.6rem; flex-wrap: wrap; }
.skill-legend { display: flex; gap: 0.6rem; flex-wrap: wrap; }
.skill-legend-item { display: flex; align-items: center; gap: 0.25rem; font-size: 0.7rem; color: #94a3b8; }
.skill-legend-dot { width: 7px; height: 7px; border-radius: 50%; }
.main { flex: 1; position: relative; overflow: hidden; }
@ -4320,11 +4283,10 @@ const CSS_TEXT = `
/* Light theme overrides */
:host([data-theme="light"]) { background: #f8fafc; color: #1e293b; }
:host([data-theme="light"]) .tab-bar { background: #fff; border-bottom-color: #e2e8f0; }
:host([data-theme="light"]) .tab { color: #94a3b8; }
:host([data-theme="light"]) .tab:hover { color: #1e293b; }
:host([data-theme="light"]) .tab.active { color: #8b5cf6; }
:host([data-theme="light"]) .stats-bar { background: linear-gradient(135deg, #f0ecff 0%, #fdf2f8 100%); border-bottom-color: #e2e8f0; }
:host([data-theme="light"]) .pool-legend { background: rgba(240, 236, 255, 0.6); border-top-color: #e2e8f0; }
:host([data-theme="light"]) .pool-legend-stats { color: #64748b; }
:host([data-theme="light"]) .skill-legend-item { color: #64748b; }
:host([data-theme="light"]) .pool-legend-stats strong { color: #1e293b; }
:host([data-theme="light"]) .stat { color: #64748b; }
:host([data-theme="light"]) .stat-value { color: #1e293b; }
:host([data-theme="light"]) .pool-panel { background: #fff; border-right-color: #e2e8f0; }
@ -4407,9 +4369,7 @@ const CSS_TEXT = `
.exec-step-checklist input[type="checkbox"] { accent-color: #8b5cf6; }
@media (max-width: 768px) {
.tab-bar { gap: 0; }
.tab { padding: 0.45rem 1rem; font-size: 0.82rem; }
.stats-bar { padding: 0.35rem 0.75rem; gap: 0.75rem; }
.pool-legend { padding: 0.35rem 0.5rem; }
.stat { font-size: 0.75rem; }
.stat-value { font-size: 0.85rem; }
.skill-bar { min-width: 80px; max-width: 160px; }
@ -4434,9 +4394,7 @@ const CSS_TEXT = `
.task-edit-panel { width: 95vw; }
}
@media (max-width: 640px) {
.tab { padding: 0.4rem 0.75rem; font-size: 0.78rem; }
.stats-bar { padding: 0.3rem 0.5rem; gap: 0.5rem; }
.skill-legend { display: none; }
.pool-legend-skills { display: none; }
.pool-panel.collapsed { width: 100% !important; }
}
`;

View File

@ -1108,19 +1108,49 @@ routes.post("/api/cyclos/transfers", async (c) => {
// ── Page routes ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
// ── Shell tabs for rTime ──
const RTIME_TABS = [
{ id: "pool", label: "Commitment Pool" },
{ id: "fulfillment", label: "Fulfillment" },
{ id: "cyclos", label: "Open in Cyclos" },
] as const;
return c.html(renderShell({
const RTIME_TAB_IDS = new Set(RTIME_TABS.map(t => t.id));
function renderTimePage(space: string, view: string, activeTab: string, isSubdomain: boolean) {
return renderShell({
title: `${space} — rTime | rSpace`,
moduleId: "rtime",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-timebank-app space="${space}" view="pool"></folk-timebank-app>`,
scripts: `<script type="module" src="/modules/rtime/folk-timebank-app.js?v=1"></script>`,
body: `<folk-timebank-app space="${space}" view="${view}"></folk-timebank-app>`,
scripts: `<script type="module" src="/modules/rtime/folk-timebank-app.js?v=2"></script>`,
styles: `<link rel="stylesheet" href="/modules/rtime/rtime.css">`,
}));
tabs: [...RTIME_TABS],
activeTab,
tabBasePath: isSubdomain ? `/rtime` : `/${space}/rtime`,
});
}
routes.get("/pool", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderTimePage(space, "canvas", "pool", c.get("isSubdomain")));
});
routes.get("/fulfillment", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderTimePage(space, "dashboard", "fulfillment", c.get("isSubdomain")));
});
routes.get("/cyclos", (c) => {
const cyclosUrl = process.env.CYCLOS_URL || "https://www.cyclos.org";
return c.redirect(cyclosUrl, 302);
});
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderTimePage(space, "canvas", "pool", c.get("isSubdomain")));
});
routes.post("/api/tasks/:id/export-to-backlog", async (c) => {
@ -1175,49 +1205,18 @@ routes.post("/api/tasks/:id/export-to-backlog", async (c) => {
});
});
// Legacy routes — redirect to new tab paths
routes.get("/canvas", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
title: `${space} — Canvas | rTime | rSpace`,
moduleId: "rtime",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-timebank-app space="${space}" view="canvas"></folk-timebank-app>`,
scripts: `<script type="module" src="/modules/rtime/folk-timebank-app.js?v=1"></script>`,
styles: `<link rel="stylesheet" href="/modules/rtime/rtime.css">`,
}));
return c.redirect(c.get("isSubdomain") ? `/rtime/pool` : `/${space}/rtime/pool`, 301);
});
routes.get("/collaborate", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
title: `${space} — Collaborate | rTime | rSpace`,
moduleId: "rtime",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-timebank-app space="${space}" view="collaborate"></folk-timebank-app>`,
scripts: `<script type="module" src="/modules/rtime/folk-timebank-app.js?v=1"></script>`,
styles: `<link rel="stylesheet" href="/modules/rtime/rtime.css">`,
}));
return c.redirect(c.get("isSubdomain") ? `/rtime/pool` : `/${space}/rtime/pool`, 301);
});
routes.get("/dashboard", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
title: `${space} — Fulfillment | rTime | rSpace`,
moduleId: "rtime",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-timebank-app space="${space}" view="dashboard"></folk-timebank-app>`,
scripts: `<script type="module" src="/modules/rtime/folk-timebank-app.js?v=1"></script>`,
styles: `<link rel="stylesheet" href="/modules/rtime/rtime.css">`,
}));
return c.redirect(c.get("isSubdomain") ? `/rtime/fulfillment` : `/${space}/rtime/fulfillment`, 301);
});
// ── Module export ──

View File

@ -871,6 +871,9 @@ export function renderShell(opts: ShellOptions): string {
};
}
// Valid module IDs for this space (used to filter out stale/phantom tabs)
const validModuleIds = new Set(moduleList.map(m => m.id));
// ── Restore tabs from localStorage ──
let layers;
try {
@ -879,12 +882,15 @@ export function renderShell(opts: ShellOptions): string {
if (!Array.isArray(layers)) layers = [];
} catch(e) { layers = []; }
// Filter out stale tabs whose moduleId no longer exists
layers = deduplicateLayers(layers.filter(l => validModuleIds.has(l.moduleId)));
// Ensure the current module is in the tab list
if (!layers.find(l => l.moduleId === currentModuleId)) {
layers.push(makeLayer(currentModuleId, layers.length));
}
// Persist immediately (includes the newly-added tab)
// Persist immediately (cleaned list)
localStorage.setItem(TABS_KEY, JSON.stringify(layers));
// Render all tabs with the current one active
@ -954,7 +960,7 @@ export function renderShell(opts: ShellOptions): string {
if (!next.has(mid) && tabCache) tabCache.removePane(mid);
}
layers = deduplicateLayers(remoteLayers);
layers = deduplicateLayers(remoteLayers.filter(l => validModuleIds.has(l.moduleId)));
// Always ensure the current module stays in the tab list
if (currentModuleId && !layers.find(l => l.moduleId === currentModuleId)) {
@ -1001,7 +1007,8 @@ export function renderShell(opts: ShellOptions): string {
// Server-authoritative: adopt server tabs exactly.
// Only skip tabs the user closed in THIS session (prevents
// close-then-immediate-resurrect before save propagates).
var serverTabs = data.tabs.filter(t => !_closedModuleIds.has(t.moduleId));
var serverTabs = data.tabs.filter(t => validModuleIds.has(t.moduleId) && !_closedModuleIds.has(t.moduleId));
serverTabs = deduplicateLayers(serverTabs);
serverTabs.forEach((l, i) => { l.order = i; });
layers = serverTabs;
// Ensure the currently navigated module is present
@ -1711,6 +1718,7 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
if (tabBar) {
tabBar.setModules(moduleList);
const validModuleIds = new Set(moduleList.map(m => m.id));
function getModuleLabel(id) {
const m = moduleList.find(mod => mod.id === id);
return m ? m.name : id;
@ -1718,13 +1726,20 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
function makeLayer(id, order) {
return { id: 'layer-' + id, moduleId: id, label: getModuleLabel(id), order, color: '', visible: true, createdAt: Date.now() };
}
function deduplicateLayers(list) {
const seen = new Set();
return list.filter(l => { if (seen.has(l.moduleId)) return false; seen.add(l.moduleId); return true; });
}
let layers;
try { const saved = localStorage.getItem(TABS_KEY); layers = saved ? JSON.parse(saved) : []; if (!Array.isArray(layers)) layers = []; } catch(e) { layers = []; }
// Filter out stale/phantom tabs and deduplicate
layers = deduplicateLayers(layers.filter(l => validModuleIds.has(l.moduleId)));
if (!layers.find(l => l.moduleId === currentModuleId)) layers.push(makeLayer(currentModuleId, layers.length));
localStorage.setItem(TABS_KEY, JSON.stringify(layers));
tabBar.setLayers(layers);
tabBar.setAttribute('active', 'layer-' + currentModuleId);
if (tabBar.trackRecent) tabBar.trackRecent(currentModuleId);
const _closedModuleIds = new Set();
function saveTabs() {
localStorage.setItem(TABS_KEY, JSON.stringify(layers));
try {
@ -1754,7 +1769,8 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
.then(function(r) { return r.ok ? r.json() : null; })
.then(function(data) {
if (!data?.tabs || !Array.isArray(data.tabs) || data.tabs.length === 0) { saveTabs(); return; }
layers = data.tabs;
// Filter stale moduleIds and tabs closed this session
layers = deduplicateLayers(data.tabs.filter(function(t) { return validModuleIds.has(t.moduleId) && !_closedModuleIds.has(t.moduleId); }));
layers.forEach(function(l, i) { l.order = i; });
if (!layers.find(function(l) { return l.moduleId === currentModuleId; })) layers.push(makeLayer(currentModuleId, layers.length));
tabBar.setLayers(layers);
@ -1765,8 +1781,8 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
})();
tabBar.addEventListener('layer-switch', (e) => { saveTabs(); window.location.href = window.__rspaceNavUrl(spaceSlug, e.detail.moduleId); });
tabBar.addEventListener('layer-add', (e) => { const { moduleId } = e.detail; if (!layers.find(l => l.moduleId === moduleId)) layers.push(makeLayer(moduleId, layers.length)); saveTabs(); window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId); });
tabBar.addEventListener('layer-close', (e) => { const { layerId } = e.detail; tabBar.removeLayer(layerId); layers = layers.filter(l => l.id !== layerId); saveTabs(); if (layerId === 'layer-' + currentModuleId && layers.length > 0) window.location.href = window.__rspaceNavUrl(spaceSlug, layers[0].moduleId); });
tabBar.addEventListener('layers-close-all', () => { layers = []; tabBar.setLayers([]); tabBar.setAttribute('active', ''); saveTabs(); var dashUrl = window.location.hostname.endsWith('.rspace.online') ? '/' : '/' + spaceSlug; window.location.href = dashUrl; });
tabBar.addEventListener('layer-close', (e) => { const { layerId } = e.detail; const closedMid = layerId.replace('layer-', ''); _closedModuleIds.add(closedMid); tabBar.removeLayer(layerId); layers = layers.filter(l => l.id !== layerId); saveTabs(); if (layerId === 'layer-' + currentModuleId && layers.length > 0) window.location.href = window.__rspaceNavUrl(spaceSlug, layers[0].moduleId); });
tabBar.addEventListener('layers-close-all', () => { layers.forEach(l => _closedModuleIds.add(l.moduleId)); layers = []; tabBar.setLayers([]); tabBar.setAttribute('active', ''); saveTabs(); var dashUrl = window.location.hostname.endsWith('.rspace.online') ? '/' : '/' + spaceSlug; window.location.href = dashUrl; });
tabBar.addEventListener('layer-reorder', (e) => { const { layerId, newIndex } = e.detail; const oldIdx = layers.findIndex(l => l.id === layerId); if (oldIdx === -1 || oldIdx === newIndex) return; const [moved] = layers.splice(oldIdx, 1); layers.splice(newIndex, 0, moved); layers.forEach((l, i) => l.order = i); saveTabs(); tabBar.setLayers(layers); });
}
</script>