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>
This commit is contained in:
parent
c82eca38fa
commit
cb521cad12
|
|
@ -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> hours available</div>
|
||||
<div class="stat"><span class="stat-value" id="statContributors">0</span> 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; }
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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 ──
|
||||
|
|
|
|||
Loading…
Reference in New Issue