1672 lines
62 KiB
TypeScript
1672 lines
62 KiB
TypeScript
/**
|
|
* folk-timebank-app — Timebank commitment pool & weaving dashboard.
|
|
*
|
|
* Port of hcc-mem-staging/html/index.html into a shadow-DOM web component.
|
|
* Two views: "pool" (canvas orbs) and "weave" (SVG node editor).
|
|
*/
|
|
|
|
// ── Constants ──
|
|
|
|
const SKILL_COLORS: Record<string, string> = {
|
|
facilitation: '#8b5cf6', design: '#ec4899', tech: '#3b82f6',
|
|
outreach: '#10b981', logistics: '#f59e0b',
|
|
};
|
|
const SKILL_LABELS: Record<string, string> = {
|
|
facilitation: 'Facilitation', design: 'Design', tech: 'Tech',
|
|
outreach: 'Outreach', logistics: 'Logistics',
|
|
};
|
|
|
|
const EXEC_STEPS: Record<string, { title: string; desc: string; detail: string; icon: string }[]> = {
|
|
default: [
|
|
{ title: 'Set Up Space', desc: 'Configure the physical or virtual venue', detail: 'venue', icon: '1' },
|
|
{ title: 'Communications Hub', desc: 'Create group chat and notification channels', detail: 'comms', icon: '2' },
|
|
{ title: 'Shared Notes & Docs', desc: 'Set up collaborative agenda and resource links', detail: 'notes', icon: '3' },
|
|
{ title: 'Prep from Input', desc: 'Upload transcripts or notes to auto-generate briefs', detail: 'prep', icon: '4' },
|
|
{ title: 'Launch & Coordinate', desc: 'Confirm roles, send notifications, begin execution', detail: 'launch', icon: '5' },
|
|
],
|
|
};
|
|
|
|
const NODE_W = 90, NODE_H = 104;
|
|
const TASK_W = 220, TASK_H_BASE = 52, TASK_ROW = 26;
|
|
const EXEC_BTN_H = 28;
|
|
const PORT_R = 5.5;
|
|
const HEX_R = 52;
|
|
|
|
interface Commitment {
|
|
id: string;
|
|
memberName: string;
|
|
hours: number;
|
|
skill: string;
|
|
desc: string;
|
|
}
|
|
interface TaskData {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
needs: Record<string, number>;
|
|
links: { label: string; url: string }[];
|
|
notes: string;
|
|
fulfilled?: Record<string, number>;
|
|
}
|
|
interface WeaveNode {
|
|
id: string;
|
|
type: 'commitment' | 'task';
|
|
x: number;
|
|
y: number;
|
|
w: number;
|
|
h: number;
|
|
hexR?: number;
|
|
baseH?: number;
|
|
data: any;
|
|
}
|
|
interface Wire {
|
|
from: string;
|
|
to: string;
|
|
skill: string;
|
|
}
|
|
|
|
// ── Orb class ──
|
|
|
|
class Orb {
|
|
c: Commitment;
|
|
baseRadius: number;
|
|
radius: number;
|
|
x: number;
|
|
y: number;
|
|
vx: number;
|
|
vy: number;
|
|
hoverT = 0;
|
|
phase: number;
|
|
opacity = 0;
|
|
color: string;
|
|
|
|
constructor(c: Commitment, basketCX: number, basketCY: number, basketR: number, x?: number, y?: number) {
|
|
this.c = c;
|
|
this.baseRadius = 18 + c.hours * 9;
|
|
this.radius = this.baseRadius;
|
|
if (x != null && y != null) {
|
|
this.x = x; this.y = y;
|
|
} else {
|
|
const a = Math.random() * Math.PI * 2;
|
|
const r = Math.random() * (basketR - this.baseRadius - 10);
|
|
this.x = basketCX + Math.cos(a) * r;
|
|
this.y = basketCY + Math.sin(a) * r;
|
|
}
|
|
this.vx = (Math.random() - 0.5) * 0.4;
|
|
this.vy = (Math.random() - 0.5) * 0.4;
|
|
this.phase = Math.random() * Math.PI * 2;
|
|
this.color = SKILL_COLORS[c.skill] || '#8b5cf6';
|
|
}
|
|
|
|
update(hoveredOrb: Orb | null, basketCX: number, basketCY: number, basketR: number) {
|
|
this.phase += 0.008;
|
|
this.vx += Math.sin(this.phase) * 0.003;
|
|
this.vy += Math.cos(this.phase * 0.73 + 1) * 0.003;
|
|
this.vx *= 0.996;
|
|
this.vy *= 0.996;
|
|
this.x += this.vx;
|
|
this.y += this.vy;
|
|
|
|
const dx = this.x - basketCX, dy = this.y - basketCY;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
const maxDist = basketR - this.radius - 3;
|
|
if (dist > maxDist && dist > 0.1) {
|
|
const nx = dx / dist, ny = dy / dist;
|
|
this.x = basketCX + nx * maxDist;
|
|
this.y = basketCY + ny * maxDist;
|
|
const dot = this.vx * nx + this.vy * ny;
|
|
this.vx -= 1.3 * dot * nx;
|
|
this.vy -= 1.3 * dot * ny;
|
|
this.vx *= 0.6; this.vy *= 0.6;
|
|
}
|
|
|
|
const isH = hoveredOrb === this;
|
|
this.hoverT += ((isH ? 1 : 0) - this.hoverT) * 0.12;
|
|
this.radius = this.baseRadius + this.hoverT * 5;
|
|
if (this.opacity < 1) this.opacity = Math.min(1, this.opacity + 0.025);
|
|
}
|
|
|
|
draw(ctx: CanvasRenderingContext2D) {
|
|
if (this.opacity < 0.01) return;
|
|
ctx.save();
|
|
ctx.globalAlpha = this.opacity;
|
|
if (this.hoverT > 0.05) { ctx.shadowColor = this.color; ctx.shadowBlur = 24 * this.hoverT; }
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
|
|
ctx.fillStyle = this.color + '15';
|
|
ctx.fill();
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(this.x, this.y, this.radius * 0.82, 0, Math.PI * 2);
|
|
const g = ctx.createRadialGradient(
|
|
this.x - this.radius * 0.15, this.y - this.radius * 0.15, 0,
|
|
this.x, this.y, this.radius * 0.82
|
|
);
|
|
g.addColorStop(0, this.color + 'dd');
|
|
g.addColorStop(1, this.color);
|
|
ctx.fillStyle = g;
|
|
ctx.fill();
|
|
ctx.shadowBlur = 0;
|
|
ctx.strokeStyle = this.color;
|
|
ctx.lineWidth = 1.5;
|
|
ctx.stroke();
|
|
|
|
if (this.hoverT > 0.05) {
|
|
ctx.globalAlpha = this.opacity * 0.08 * this.hoverT;
|
|
ctx.beginPath();
|
|
ctx.ellipse(this.x, this.y + this.radius + 4, this.radius * 0.6, 4, 0, 0, Math.PI * 2);
|
|
ctx.fillStyle = '#000';
|
|
ctx.fill();
|
|
ctx.globalAlpha = this.opacity;
|
|
}
|
|
|
|
ctx.fillStyle = '#fff';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
const fs = Math.max(10, this.radius * 0.3);
|
|
ctx.font = '600 ' + fs + 'px -apple-system, sans-serif';
|
|
ctx.fillText(this.c.memberName.split(' ')[0], this.x, this.y - fs * 0.35);
|
|
ctx.font = '500 ' + (fs * 0.78) + 'px -apple-system, sans-serif';
|
|
ctx.fillStyle = '#ffffffcc';
|
|
ctx.fillText(this.c.hours + 'hr \u00b7 ' + (SKILL_LABELS[this.c.skill] || this.c.skill), this.x, this.y + fs * 0.55);
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
contains(px: number, py: number) {
|
|
const dx = px - this.x, dy = py - this.y;
|
|
return dx * dx + dy * dy < this.radius * this.radius;
|
|
}
|
|
}
|
|
|
|
// ── Ripple class ──
|
|
|
|
class Ripple {
|
|
x: number; y: number; r = 8; o = 0.5; color: string;
|
|
constructor(x: number, y: number, color: string) { this.x = x; this.y = y; this.color = color; }
|
|
update() { this.r += 1.5; this.o -= 0.008; return this.o > 0; }
|
|
draw(ctx: CanvasRenderingContext2D) {
|
|
ctx.save(); ctx.globalAlpha = this.o;
|
|
ctx.strokeStyle = this.color; ctx.lineWidth = 2;
|
|
ctx.beginPath(); ctx.arc(this.x, this.y, this.r, 0, Math.PI * 2); ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
// ── Hex helpers ──
|
|
|
|
function hexPoints(cx: number, cy: number, r: number): [number, number][] {
|
|
return [0, 1, 2, 3, 4, 5].map(i => {
|
|
const angle = -Math.PI / 2 + i * Math.PI / 3;
|
|
return [cx + r * Math.cos(angle), cy + r * Math.sin(angle)] as [number, number];
|
|
});
|
|
}
|
|
|
|
function hexPolygonStr(cx: number, cy: number, r: number) {
|
|
return hexPoints(cx, cy, r).map(p => p[0] + ',' + p[1]).join(' ');
|
|
}
|
|
|
|
function bezier(x1: number, y1: number, x2: number, y2: number) {
|
|
const d = Math.max(40, Math.abs(x2 - x1) * 0.5);
|
|
return 'M' + x1 + ',' + y1 + ' C' + (x1 + d) + ',' + y1 + ' ' + (x2 - d) + ',' + y2 + ' ' + x2 + ',' + y2;
|
|
}
|
|
|
|
function ns(tag: string) { return document.createElementNS('http://www.w3.org/2000/svg', tag); }
|
|
|
|
function svgText(txt: string, x: number, y: number, size: number, color: string, weight?: string, anchor?: string) {
|
|
const t = ns('text');
|
|
t.setAttribute('x', String(x)); t.setAttribute('y', String(y));
|
|
t.setAttribute('fill', color);
|
|
t.setAttribute('font-size', String(size));
|
|
t.setAttribute('font-weight', weight || '400');
|
|
t.setAttribute('font-family', '-apple-system, BlinkMacSystemFont, sans-serif');
|
|
if (anchor) t.setAttribute('text-anchor', anchor);
|
|
t.textContent = txt;
|
|
return t;
|
|
}
|
|
|
|
// ── Main component ──
|
|
|
|
class FolkTimebankApp extends HTMLElement {
|
|
private shadow: ShadowRoot;
|
|
private space = 'demo';
|
|
private currentView: 'pool' | 'weave' = 'pool';
|
|
|
|
// Pool state
|
|
private canvas!: HTMLCanvasElement;
|
|
private ctx!: CanvasRenderingContext2D;
|
|
private orbs: Orb[] = [];
|
|
private ripples: Ripple[] = [];
|
|
private animFrame = 0;
|
|
private poolW = 0;
|
|
private poolH = 0;
|
|
private basketCX = 0;
|
|
private basketCY = 0;
|
|
private basketR = 0;
|
|
private hoveredOrb: Orb | null = null;
|
|
private selectedOrb: Orb | null = null;
|
|
private dpr = 1;
|
|
private poolPointerId: number | null = null;
|
|
private poolPointerStart: { x: number; y: number; cx: number; cy: number } | null = null;
|
|
|
|
// Weave state
|
|
private svgEl!: SVGSVGElement;
|
|
private nodesLayer!: SVGGElement;
|
|
private connectionsLayer!: SVGGElement;
|
|
private tempConn!: SVGPathElement;
|
|
private weaveNodes: WeaveNode[] = [];
|
|
private connections: Wire[] = [];
|
|
private dragNode: WeaveNode | null = null;
|
|
private dragOff = { x: 0, y: 0 };
|
|
private connecting: { nodeId: string; portType: string; skill: string | null } | null = null;
|
|
private svgActivePointerId: number | null = null;
|
|
private svgDblTapTime = 0;
|
|
private svgDblTapPos: { x: number; y: number } | null = null;
|
|
private sidebarDragData: { type: string; id: string } | null = null;
|
|
private sidebarGhost: HTMLElement | null = null;
|
|
|
|
// Data
|
|
private commitments: Commitment[] = [];
|
|
private tasks: TaskData[] = [];
|
|
|
|
// Exec state
|
|
private execStepStates: Record<string, Record<number, string>> = {};
|
|
|
|
constructor() {
|
|
super();
|
|
this.shadow = this.attachShadow({ mode: 'open' });
|
|
}
|
|
|
|
static get observedAttributes() { return ['space', 'view']; }
|
|
|
|
attributeChangedCallback(name: string, _old: string, val: string) {
|
|
if (name === 'space') this.space = val;
|
|
if (name === 'view' && (val === 'pool' || val === 'weave')) this.currentView = val;
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.space = this.getAttribute('space') || 'demo';
|
|
this.currentView = (this.getAttribute('view') as any) || 'pool';
|
|
this.dpr = window.devicePixelRatio || 1;
|
|
this.render();
|
|
this.setupPool();
|
|
this.setupWeave();
|
|
this.fetchData();
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
if (this.animFrame) cancelAnimationFrame(this.animFrame);
|
|
}
|
|
|
|
private async fetchData() {
|
|
const base = `/${this.space}/rtime`;
|
|
try {
|
|
const [cResp, tResp] = await Promise.all([
|
|
fetch(`${base}/api/commitments`),
|
|
fetch(`${base}/api/tasks`),
|
|
]);
|
|
if (cResp.ok) {
|
|
const cData = await cResp.json();
|
|
this.commitments = cData.commitments || [];
|
|
}
|
|
if (tResp.ok) {
|
|
const tData = await tResp.json();
|
|
this.tasks = tData.tasks || [];
|
|
}
|
|
} catch {
|
|
// Offline — use empty state
|
|
}
|
|
this.buildOrbs();
|
|
this.updateStats();
|
|
this.rebuildSidebar();
|
|
}
|
|
|
|
private render() {
|
|
this.shadow.innerHTML = `
|
|
<style>${CSS_TEXT}</style>
|
|
<div class="tab-bar">
|
|
<div class="tab active" data-view="pool">Commitment Pool</div>
|
|
<div class="tab" data-view="weave">Weaving Dashboard</div>
|
|
</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="pool-view">
|
|
<canvas id="pool-canvas"></canvas>
|
|
<div class="pool-detail" id="poolDetail">
|
|
<div class="pool-detail-header">
|
|
<div class="pool-detail-dot" id="detailDot"></div>
|
|
<div class="pool-detail-name" id="detailName"></div>
|
|
</div>
|
|
<div class="pool-detail-skill" id="detailSkill"></div>
|
|
<div class="pool-detail-hours" id="detailHours"></div>
|
|
<div class="pool-detail-desc" id="detailDesc"></div>
|
|
</div>
|
|
<button class="add-btn" id="addBtn">+ Add Commitment</button>
|
|
</div>
|
|
<div id="weave-view" style="display:none">
|
|
<div class="sidebar">
|
|
<div class="sidebar-header">Available Commitments</div>
|
|
<div class="sidebar-items" id="sidebarItems"></div>
|
|
<div class="sidebar-section">Task Templates</div>
|
|
<div id="sidebarTasks"></div>
|
|
</div>
|
|
<div class="canvas-wrap" id="canvasWrap">
|
|
<svg id="weave-svg" xmlns="http://www.w3.org/2000/svg">
|
|
<defs>
|
|
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
|
|
<circle cx="1" cy="1" r="0.5" fill="#64748b" opacity="0.4"/>
|
|
</pattern>
|
|
<filter id="nodeShadow" x="-10%" y="-10%" width="130%" height="140%">
|
|
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-opacity="0.08"/>
|
|
</filter>
|
|
<filter id="glowGreen" x="-20%" y="-20%" width="140%" height="140%">
|
|
<feDropShadow dx="0" dy="0" stdDeviation="6" flood-color="#10b981" flood-opacity="0.35"/>
|
|
</filter>
|
|
</defs>
|
|
<rect width="100%" height="100%" fill="url(#grid)"/>
|
|
<g id="connectionsLayer"></g>
|
|
<g id="nodesLayer"></g>
|
|
<path id="tempConnection" class="temp-connection" d="" style="display:none"/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add Commitment Modal -->
|
|
<div class="modal-overlay" id="modalOverlay">
|
|
<div class="modal">
|
|
<h3>Add Commitment</h3>
|
|
<div class="modal-field">
|
|
<label>Your Name</label>
|
|
<input type="text" id="modalName" placeholder="e.g. Dana Lee">
|
|
</div>
|
|
<div class="modal-field">
|
|
<label>Skill Category</label>
|
|
<select id="modalSkill">
|
|
<option value="facilitation">Facilitation</option>
|
|
<option value="design">Design</option>
|
|
<option value="tech">Tech</option>
|
|
<option value="outreach">Outreach</option>
|
|
<option value="logistics">Logistics</option>
|
|
</select>
|
|
</div>
|
|
<div class="modal-field">
|
|
<label>Hours</label>
|
|
<input type="number" id="modalHours" min="1" max="10" value="2">
|
|
</div>
|
|
<div class="modal-field">
|
|
<label>Description</label>
|
|
<input type="text" id="modalDesc" placeholder="What will you contribute?">
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="modal-cancel" id="modalCancel">Cancel</button>
|
|
<button class="modal-submit" id="modalSubmit">Drop into Pool</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Execution Panel -->
|
|
<div class="exec-overlay" id="execOverlay">
|
|
<div class="exec-panel">
|
|
<div class="exec-panel-header">
|
|
<h2 id="execTitle">Project Execution</h2>
|
|
<p id="execSubtitle">All commitments woven — prepare for launch</p>
|
|
</div>
|
|
<div class="exec-steps" id="execSteps"></div>
|
|
<div class="exec-panel-footer">
|
|
<button class="exec-close" id="execClose">Close</button>
|
|
<div class="exec-progress">
|
|
<div class="exec-progress-bar"><div class="exec-progress-fill" id="execProgressFill"></div></div>
|
|
<span id="execProgressText">0/5</span>
|
|
</div>
|
|
<button class="exec-launch" id="execLaunch" disabled>Launch Project</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Task Editor -->
|
|
<div class="task-edit-overlay" id="taskEditOverlay">
|
|
<div class="task-edit-panel">
|
|
<div class="task-edit-header"><h3 id="taskEditTitle">Edit Task</h3></div>
|
|
<div class="task-edit-body">
|
|
<div class="task-edit-field"><label>Task Name</label><input type="text" id="taskEditName"></div>
|
|
<div class="task-edit-field"><label>Description</label><textarea id="taskEditDesc"></textarea></div>
|
|
<div class="task-edit-field"><label>Notes</label><textarea id="taskEditNotes"></textarea></div>
|
|
</div>
|
|
<div class="task-edit-footer">
|
|
<button class="modal-cancel" id="taskEditCancel">Cancel</button>
|
|
<button class="modal-submit" id="taskEditSave">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ── Pool setup ──
|
|
|
|
private setupPool() {
|
|
this.canvas = this.shadow.getElementById('pool-canvas') as HTMLCanvasElement;
|
|
this.ctx = this.canvas.getContext('2d')!;
|
|
|
|
// Tab switching
|
|
this.shadow.querySelectorAll('.tab').forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
const view = (tab as HTMLElement).dataset.view as 'pool' | 'weave';
|
|
if (view === this.currentView) return;
|
|
this.currentView = view;
|
|
this.shadow.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', (t as HTMLElement).dataset.view === view));
|
|
const poolView = this.shadow.getElementById('pool-view')!;
|
|
const weaveView = this.shadow.getElementById('weave-view')!;
|
|
poolView.style.display = view === 'pool' ? 'block' : 'none';
|
|
weaveView.style.display = view === 'weave' ? 'flex' : 'none';
|
|
if (view === 'pool') this.resizePoolCanvas();
|
|
if (view === 'weave') this.rebuildSidebar();
|
|
});
|
|
});
|
|
|
|
// Add commitment modal
|
|
const modal = this.shadow.getElementById('modalOverlay')!;
|
|
this.shadow.getElementById('addBtn')!.addEventListener('click', () => {
|
|
modal.classList.add('visible');
|
|
(this.shadow.getElementById('modalName') as HTMLInputElement).value = '';
|
|
(this.shadow.getElementById('modalHours') as HTMLInputElement).value = '2';
|
|
(this.shadow.getElementById('modalDesc') as HTMLInputElement).value = '';
|
|
(this.shadow.getElementById('modalName') as HTMLInputElement).focus();
|
|
});
|
|
this.shadow.getElementById('modalCancel')!.addEventListener('click', () => modal.classList.remove('visible'));
|
|
modal.addEventListener('click', (e) => { if (e.target === modal) modal.classList.remove('visible'); });
|
|
this.shadow.getElementById('modalSubmit')!.addEventListener('click', () => this.submitCommitment());
|
|
|
|
// Pool pointer events
|
|
this.canvas.addEventListener('pointerdown', (e) => this.onPoolPointerDown(e));
|
|
this.canvas.addEventListener('pointermove', (e) => this.onPoolPointerMove(e));
|
|
this.canvas.addEventListener('pointerup', (e) => this.onPoolPointerUp(e));
|
|
this.canvas.addEventListener('pointercancel', () => { this.poolPointerId = null; this.poolPointerStart = null; });
|
|
this.canvas.addEventListener('pointerleave', (e) => { if (e.pointerType === 'mouse') { this.hoveredOrb = null; this.hideDetail(); } });
|
|
|
|
// Exec panel
|
|
this.shadow.getElementById('execClose')!.addEventListener('click', () => this.shadow.getElementById('execOverlay')!.classList.remove('visible'));
|
|
this.shadow.getElementById('execOverlay')!.addEventListener('click', (e) => {
|
|
if (e.target === this.shadow.getElementById('execOverlay')) this.shadow.getElementById('execOverlay')!.classList.remove('visible');
|
|
});
|
|
this.shadow.getElementById('execLaunch')!.addEventListener('click', () => {
|
|
const btn = this.shadow.getElementById('execLaunch') as HTMLButtonElement;
|
|
btn.textContent = 'Launched!';
|
|
btn.style.background = 'linear-gradient(135deg, #8b5cf6, #ec4899)';
|
|
setTimeout(() => {
|
|
this.shadow.getElementById('execOverlay')!.classList.remove('visible');
|
|
btn.textContent = 'Launch Project';
|
|
btn.style.background = '';
|
|
}, 1500);
|
|
});
|
|
|
|
// Task editor
|
|
this.shadow.getElementById('taskEditCancel')!.addEventListener('click', () => this.shadow.getElementById('taskEditOverlay')!.classList.remove('visible'));
|
|
this.shadow.getElementById('taskEditOverlay')!.addEventListener('click', (e) => {
|
|
if (e.target === this.shadow.getElementById('taskEditOverlay')) this.shadow.getElementById('taskEditOverlay')!.classList.remove('visible');
|
|
});
|
|
this.shadow.getElementById('taskEditSave')!.addEventListener('click', () => this.saveTaskEdit());
|
|
|
|
// Resize
|
|
const resizeObserver = new ResizeObserver(() => { if (this.currentView === 'pool') this.resizePoolCanvas(); });
|
|
resizeObserver.observe(this);
|
|
|
|
this.resizePoolCanvas();
|
|
this.poolFrame();
|
|
}
|
|
|
|
private resizePoolCanvas() {
|
|
const rect = this.canvas.parentElement!.getBoundingClientRect();
|
|
this.poolW = rect.width;
|
|
this.poolH = rect.height;
|
|
this.canvas.width = this.poolW * this.dpr;
|
|
this.canvas.height = this.poolH * this.dpr;
|
|
this.canvas.style.width = this.poolW + 'px';
|
|
this.canvas.style.height = this.poolH + 'px';
|
|
this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
|
this.basketCX = this.poolW / 2;
|
|
this.basketCY = this.poolH / 2 + 10;
|
|
this.basketR = Math.min(this.poolW, this.poolH) * 0.42;
|
|
}
|
|
|
|
private buildOrbs() {
|
|
this.orbs = this.commitments.map(c => new Orb(c, this.basketCX, this.basketCY, this.basketR));
|
|
}
|
|
|
|
private resolveCollisions() {
|
|
for (let i = 0; i < this.orbs.length; i++) {
|
|
for (let j = i + 1; j < this.orbs.length; j++) {
|
|
const a = this.orbs[i], b = this.orbs[j];
|
|
const dx = b.x - a.x, dy = b.y - a.y;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
const min = a.radius + b.radius + 4;
|
|
if (dist < min && dist > 0.1) {
|
|
const nx = dx / dist, ny = dy / dist;
|
|
const ov = (min - dist) * 0.5;
|
|
a.x -= nx * ov * 0.5; a.y -= ny * ov * 0.5;
|
|
b.x += nx * ov * 0.5; b.y += ny * ov * 0.5;
|
|
a.vx -= nx * 0.04; a.vy -= ny * 0.04;
|
|
b.vx += nx * 0.04; b.vy += ny * 0.04;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private drawBasket() {
|
|
const ctx = this.ctx;
|
|
const ig = ctx.createRadialGradient(this.basketCX, this.basketCY - this.basketR * 0.2, 0, this.basketCX, this.basketCY, this.basketR);
|
|
ig.addColorStop(0, 'rgba(139,92,246,0.06)');
|
|
ig.addColorStop(0.7, 'rgba(139,92,246,0.1)');
|
|
ig.addColorStop(1, 'rgba(236,72,153,0.12)');
|
|
ctx.beginPath();
|
|
ctx.arc(this.basketCX, this.basketCY, this.basketR, 0, Math.PI * 2);
|
|
ctx.fillStyle = ig;
|
|
ctx.fill();
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(this.basketCX, this.basketCY, this.basketR, 0, Math.PI * 2);
|
|
ctx.strokeStyle = 'rgba(139,92,246,0.25)';
|
|
ctx.lineWidth = 6;
|
|
ctx.stroke();
|
|
ctx.beginPath();
|
|
ctx.arc(this.basketCX, this.basketCY, this.basketR, 0, Math.PI * 2);
|
|
ctx.strokeStyle = 'rgba(139,92,246,0.4)';
|
|
ctx.lineWidth = 2;
|
|
ctx.stroke();
|
|
|
|
// Woven texture
|
|
ctx.strokeStyle = 'rgba(139,92,246,0.2)';
|
|
ctx.lineWidth = 1.5;
|
|
const segs = 48;
|
|
for (let i = 0; i < segs; i++) {
|
|
const a1 = (i / segs) * Math.PI * 2;
|
|
const a2 = ((i + 0.5) / segs) * Math.PI * 2;
|
|
const r1 = this.basketR - 3, r2 = this.basketR + 3;
|
|
ctx.beginPath();
|
|
ctx.moveTo(this.basketCX + Math.cos(a1) * r1, this.basketCY + Math.sin(a1) * r1);
|
|
ctx.quadraticCurveTo(
|
|
this.basketCX + Math.cos((a1 + a2) / 2) * r2,
|
|
this.basketCY + Math.sin((a1 + a2) / 2) * r2,
|
|
this.basketCX + Math.cos(a2) * r1,
|
|
this.basketCY + Math.sin(a2) * r1
|
|
);
|
|
ctx.stroke();
|
|
}
|
|
|
|
ctx.fillStyle = '#a78bfacc';
|
|
ctx.font = '600 13px -apple-system, sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'bottom';
|
|
ctx.fillText('COMMITMENT BASKET', this.basketCX, this.basketCY - this.basketR - 14);
|
|
}
|
|
|
|
private poolFrame = () => {
|
|
this.ctx.clearRect(0, 0, this.poolW, this.poolH);
|
|
const bg = this.ctx.createLinearGradient(0, 0, 0, this.poolH);
|
|
bg.addColorStop(0, '#0f172a'); bg.addColorStop(1, '#1a1033');
|
|
this.ctx.fillStyle = bg; this.ctx.fillRect(0, 0, this.poolW, this.poolH);
|
|
|
|
this.drawBasket();
|
|
this.ripples = this.ripples.filter(r => r.update());
|
|
this.ripples.forEach(r => r.draw(this.ctx));
|
|
this.orbs.forEach(o => o.update(this.hoveredOrb, this.basketCX, this.basketCY, this.basketR));
|
|
this.resolveCollisions();
|
|
this.orbs.forEach(o => o.draw(this.ctx));
|
|
this.animFrame = requestAnimationFrame(this.poolFrame);
|
|
};
|
|
|
|
// Pool pointer handlers
|
|
|
|
private onPoolPointerDown(e: PointerEvent) {
|
|
if (this.poolPointerId !== null) return;
|
|
this.poolPointerId = e.pointerId;
|
|
this.canvas.setPointerCapture(e.pointerId);
|
|
const r = this.canvas.getBoundingClientRect();
|
|
this.poolPointerStart = { x: e.clientX, y: e.clientY, cx: e.clientX - r.left, cy: e.clientY - r.top };
|
|
}
|
|
|
|
private onPoolPointerMove(e: PointerEvent) {
|
|
const r = this.canvas.getBoundingClientRect();
|
|
const mx = e.clientX - r.left, my = e.clientY - r.top;
|
|
this.hoveredOrb = null;
|
|
for (let i = this.orbs.length - 1; i >= 0; i--) {
|
|
if (this.orbs[i].contains(mx, my)) { this.hoveredOrb = this.orbs[i]; break; }
|
|
}
|
|
this.canvas.style.cursor = this.hoveredOrb ? 'pointer' : 'default';
|
|
if (this.poolPointerStart) {
|
|
const dx = e.clientX - this.poolPointerStart.x, dy = e.clientY - this.poolPointerStart.y;
|
|
if (dx * dx + dy * dy > 100) this.poolPointerStart = null;
|
|
}
|
|
}
|
|
|
|
private onPoolPointerUp(e: PointerEvent) {
|
|
if (e.pointerId !== this.poolPointerId) return;
|
|
this.poolPointerId = null;
|
|
if (!this.poolPointerStart) return;
|
|
const r = this.canvas.getBoundingClientRect();
|
|
const px = e.clientX - r.left, py = e.clientY - r.top;
|
|
let clicked: Orb | null = null;
|
|
for (let i = this.orbs.length - 1; i >= 0; i--) {
|
|
if (this.orbs[i].contains(px, py)) { clicked = this.orbs[i]; break; }
|
|
}
|
|
if (clicked) {
|
|
this.selectedOrb = this.selectedOrb === clicked ? null : clicked;
|
|
if (this.selectedOrb) this.showDetail(this.selectedOrb, e.clientX, e.clientY); else this.hideDetail();
|
|
} else { this.selectedOrb = null; this.hideDetail(); }
|
|
this.poolPointerStart = null;
|
|
}
|
|
|
|
private showDetail(orb: Orb, cx: number, cy: number) {
|
|
const el = this.shadow.getElementById('poolDetail')!;
|
|
const c = orb.c;
|
|
(this.shadow.getElementById('detailDot') as HTMLElement).style.background = SKILL_COLORS[c.skill] || '#8b5cf6';
|
|
this.shadow.getElementById('detailName')!.textContent = c.memberName;
|
|
this.shadow.getElementById('detailSkill')!.textContent = SKILL_LABELS[c.skill] || c.skill;
|
|
this.shadow.getElementById('detailHours')!.textContent = c.hours + ' hour' + (c.hours !== 1 ? 's' : '') + ' pledged';
|
|
this.shadow.getElementById('detailDesc')!.textContent = c.desc;
|
|
const mr = this.shadow.querySelector('.main')!.getBoundingClientRect();
|
|
let left = cx - mr.left + 15, top = cy - mr.top - 30;
|
|
if (left + 240 > mr.width) left = cx - mr.left - 250;
|
|
if (top + 150 > mr.height) top = mr.height - 160;
|
|
if (top < 10) top = 10;
|
|
el.style.left = left + 'px'; el.style.top = top + 'px';
|
|
el.classList.add('visible');
|
|
}
|
|
|
|
private hideDetail() { this.shadow.getElementById('poolDetail')?.classList.remove('visible'); }
|
|
|
|
// ── Submit commitment ──
|
|
|
|
private async submitCommitment() {
|
|
const name = (this.shadow.getElementById('modalName') as HTMLInputElement).value.trim();
|
|
const skill = (this.shadow.getElementById('modalSkill') as HTMLSelectElement).value;
|
|
const hours = Math.max(1, Math.min(10, parseInt((this.shadow.getElementById('modalHours') as HTMLInputElement).value) || 2));
|
|
const desc = (this.shadow.getElementById('modalDesc') as HTMLInputElement).value.trim() || (SKILL_LABELS[skill] || skill) + ' contribution';
|
|
if (!name) { (this.shadow.getElementById('modalName') as HTMLInputElement).focus(); return; }
|
|
|
|
const c: Commitment = { id: 'local-' + Date.now(), memberName: name, skill, hours, desc };
|
|
|
|
// Try server-side persist
|
|
try {
|
|
const resp = await fetch(`/${this.space}/rtime/api/commitments`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ memberName: name, skill, hours, desc }),
|
|
});
|
|
if (resp.ok) {
|
|
const saved = await resp.json();
|
|
c.id = saved.id;
|
|
}
|
|
} catch { /* offline — keep local id */ }
|
|
|
|
this.commitments.push(c);
|
|
const orb = new Orb(c, this.basketCX, this.basketCY, this.basketR,
|
|
this.basketCX + (Math.random() - 0.5) * 40, this.basketCY - this.basketR - 30);
|
|
orb.vy = 2.5;
|
|
this.orbs.push(orb);
|
|
setTimeout(() => {
|
|
this.ripples.push(new Ripple(this.basketCX, this.basketCY, SKILL_COLORS[skill] || '#8b5cf6'));
|
|
this.ripples.push(new Ripple(this.basketCX - 15, this.basketCY + 10, SKILL_COLORS[skill] || '#8b5cf6'));
|
|
}, 400);
|
|
this.updateStats();
|
|
this.shadow.getElementById('modalOverlay')!.classList.remove('visible');
|
|
}
|
|
|
|
// ── Stats bar ──
|
|
|
|
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 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 = '';
|
|
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';
|
|
legend.appendChild(item);
|
|
}
|
|
}
|
|
|
|
// ── Weave setup ──
|
|
|
|
private setupWeave() {
|
|
this.svgEl = this.shadow.getElementById('weave-svg') as any;
|
|
this.nodesLayer = this.shadow.getElementById('nodesLayer') as any;
|
|
this.connectionsLayer = this.shadow.getElementById('connectionsLayer') as any;
|
|
this.tempConn = this.shadow.getElementById('tempConnection') as any;
|
|
|
|
this.svgEl.addEventListener('pointerdown', (e: PointerEvent) => this.onSvgPointerDown(e));
|
|
this.svgEl.addEventListener('pointermove', (e: PointerEvent) => this.onSvgPointerMove(e));
|
|
this.svgEl.addEventListener('pointerup', (e: PointerEvent) => this.onSvgPointerUp(e));
|
|
this.svgEl.addEventListener('pointercancel', () => {
|
|
this.svgActivePointerId = null; this.connecting = null; this.dragNode = null;
|
|
this.tempConn.style.display = 'none';
|
|
});
|
|
this.svgEl.addEventListener('dblclick', (e) => {
|
|
const ng = (e.target as Element).closest('.node-group') as SVGElement;
|
|
if (!ng) return;
|
|
const taskNode = this.weaveNodes.find(n => n.id === ng.dataset.id && n.type === 'task');
|
|
if (taskNode) this.openTaskEditor(taskNode);
|
|
});
|
|
|
|
// Drop zone
|
|
const wrap = this.shadow.getElementById('canvasWrap')!;
|
|
wrap.addEventListener('dragover', (e) => { e.preventDefault(); });
|
|
}
|
|
|
|
private svgPt(cx: number, cy: number) {
|
|
const p = this.svgEl.createSVGPoint();
|
|
p.x = cx; p.y = cy;
|
|
return p.matrixTransform(this.svgEl.getScreenCTM()!.inverse());
|
|
}
|
|
|
|
private mkCommitNode(c: Commitment, x: number, y: number): WeaveNode {
|
|
return { id: 'cn-' + c.id, type: 'commitment', x, y, w: NODE_W, h: NODE_H, hexR: HEX_R, data: c };
|
|
}
|
|
|
|
private mkTaskNode(t: TaskData, x: number, y: number): WeaveNode {
|
|
const n = Object.keys(t.needs).length;
|
|
const baseH = TASK_H_BASE + n * TASK_ROW + 12;
|
|
return { id: t.id, type: 'task', x, y, w: TASK_W, h: baseH, baseH, data: { ...t, fulfilled: {} } };
|
|
}
|
|
|
|
private isTaskReady(node: WeaveNode): boolean {
|
|
if (node.type !== 'task') return false;
|
|
const t = node.data;
|
|
for (const skill of Object.keys(t.needs)) {
|
|
if ((t.fulfilled?.[skill] || 0) < t.needs[skill]) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private renderConnections() {
|
|
this.connectionsLayer.innerHTML = '';
|
|
this.connections.forEach(conn => {
|
|
const from = this.weaveNodes.find(n => n.id === conn.from);
|
|
const to = this.weaveNodes.find(n => n.id === conn.to);
|
|
if (!from || !to) return;
|
|
let x1: number, y1: number;
|
|
if (from.type === 'commitment') {
|
|
const cx = from.x + from.w / 2, cy = from.y + from.h / 2;
|
|
const pts = hexPoints(cx, cy, from.hexR || HEX_R);
|
|
x1 = pts[1][0]; y1 = pts[1][1];
|
|
} else {
|
|
x1 = from.x + from.w; y1 = from.y + from.h / 2;
|
|
}
|
|
let x2 = to.x, y2 = to.y + to.h / 2;
|
|
if (to.type === 'task') {
|
|
const skills = Object.keys(to.data.needs);
|
|
const idx = skills.indexOf(conn.skill);
|
|
if (idx >= 0) y2 = to.y + TASK_H_BASE + idx * TASK_ROW + TASK_ROW / 2;
|
|
}
|
|
const p = ns('path');
|
|
p.setAttribute('d', bezier(x1, y1, x2, y2));
|
|
p.setAttribute('class', 'connection-line active');
|
|
this.connectionsLayer.appendChild(p);
|
|
});
|
|
}
|
|
|
|
private renderNode(node: WeaveNode): SVGGElement {
|
|
const g = ns('g') as SVGGElement;
|
|
g.setAttribute('data-id', node.id);
|
|
g.setAttribute('transform', 'translate(' + node.x + ',' + node.y + ')');
|
|
|
|
if (node.type === 'commitment') {
|
|
const c = node.data as Commitment;
|
|
const col = SKILL_COLORS[c.skill] || '#8b5cf6';
|
|
const cx = node.w / 2, cy = node.h / 2;
|
|
const hr = node.hexR || HEX_R;
|
|
g.setAttribute('class', 'node-group');
|
|
|
|
const hex = ns('polygon');
|
|
hex.setAttribute('points', hexPolygonStr(cx, cy, hr));
|
|
hex.setAttribute('fill', '#1e293b');
|
|
hex.setAttribute('stroke', '#334155');
|
|
hex.setAttribute('stroke-width', '1');
|
|
hex.setAttribute('filter', 'url(#nodeShadow)');
|
|
g.appendChild(hex);
|
|
|
|
const clipId = 'hexClip-' + node.id;
|
|
const clipPath = ns('clipPath');
|
|
clipPath.setAttribute('id', clipId);
|
|
const clipRect = ns('rect');
|
|
clipRect.setAttribute('x', String(cx - hr)); clipRect.setAttribute('y', String(cy - hr));
|
|
clipRect.setAttribute('width', String(hr * 2)); clipRect.setAttribute('height', String(hr * 0.75));
|
|
clipPath.appendChild(clipRect);
|
|
g.appendChild(clipPath);
|
|
|
|
const headerFill = ns('polygon');
|
|
headerFill.setAttribute('points', hexPolygonStr(cx, cy, hr));
|
|
headerFill.setAttribute('fill', col);
|
|
headerFill.setAttribute('clip-path', 'url(#' + clipId + ')');
|
|
g.appendChild(headerFill);
|
|
|
|
const nameText = c.memberName.length > 12 ? c.memberName.slice(0, 11) + '\u2026' : c.memberName;
|
|
g.appendChild(svgText(nameText, cx, cy - hr * 0.35, 11, '#fff', '600', 'middle'));
|
|
g.appendChild(svgText(c.hours + 'hr', cx, cy + 4, 13, '#f1f5f9', '700', 'middle'));
|
|
g.appendChild(svgText(SKILL_LABELS[c.skill] || c.skill, cx, cy + 18, 10, '#94a3b8', '400', 'middle'));
|
|
|
|
const pts = hexPoints(cx, cy, hr);
|
|
const portHit = ns('circle');
|
|
portHit.setAttribute('cx', String(pts[1][0])); portHit.setAttribute('cy', String(pts[1][1]));
|
|
portHit.setAttribute('r', '18'); portHit.setAttribute('fill', 'transparent');
|
|
portHit.setAttribute('class', 'port');
|
|
(portHit as any).dataset = {}; portHit.setAttribute('data-node', node.id); portHit.setAttribute('data-port', 'output');
|
|
g.appendChild(portHit);
|
|
const port = ns('circle');
|
|
port.setAttribute('cx', String(pts[1][0])); port.setAttribute('cy', String(pts[1][1]));
|
|
port.setAttribute('r', String(PORT_R));
|
|
port.setAttribute('fill', col); port.setAttribute('stroke', '#fff'); port.setAttribute('stroke-width', '2');
|
|
port.style.pointerEvents = 'none';
|
|
g.appendChild(port);
|
|
|
|
} else if (node.type === 'task') {
|
|
const t = node.data as TaskData;
|
|
const skills = Object.keys(t.needs);
|
|
const totalNeeded = Object.values(t.needs).reduce((a, b) => a + b, 0);
|
|
const totalFulfilled = Object.values(t.fulfilled || {}).reduce((a, b) => a + b, 0);
|
|
const progress = totalNeeded > 0 ? Math.min(1, totalFulfilled / totalNeeded) : 0;
|
|
const ready = this.isTaskReady(node);
|
|
|
|
node.h = node.baseH! + (ready ? EXEC_BTN_H + 8 : 0);
|
|
g.setAttribute('class', 'node-group task-node' + (ready ? ' ready' : ''));
|
|
|
|
const rect = ns('rect');
|
|
rect.setAttribute('class', 'node-rect');
|
|
rect.setAttribute('width', String(node.w)); rect.setAttribute('height', String(node.h));
|
|
rect.setAttribute('rx', '8'); rect.setAttribute('ry', '8');
|
|
rect.setAttribute('filter', ready ? 'url(#glowGreen)' : 'url(#nodeShadow)');
|
|
g.appendChild(rect);
|
|
|
|
const hCol = ready ? '#10b981' : '#8b5cf6';
|
|
const h = ns('rect');
|
|
h.setAttribute('width', String(node.w)); h.setAttribute('height', '28');
|
|
h.setAttribute('fill', hCol); h.setAttribute('rx', '8'); h.setAttribute('ry', '8');
|
|
g.appendChild(h);
|
|
const hm = ns('rect');
|
|
hm.setAttribute('width', String(node.w)); hm.setAttribute('height', '10'); hm.setAttribute('y', '20'); hm.setAttribute('fill', hCol);
|
|
g.appendChild(hm);
|
|
|
|
g.appendChild(svgText(t.name, 12, 18, 12, '#fff', '600'));
|
|
g.appendChild(svgText(ready ? 'Ready!' : Math.round(progress * 100) + '%', node.w - 24, 18, 10, '#ffffffcc', '500', 'end'));
|
|
|
|
const pbW = node.w - 24;
|
|
const pbBg = ns('rect');
|
|
pbBg.setAttribute('x', '12'); pbBg.setAttribute('y', '36'); pbBg.setAttribute('width', String(pbW)); pbBg.setAttribute('height', '4');
|
|
pbBg.setAttribute('rx', '2'); pbBg.setAttribute('fill', '#334155');
|
|
g.appendChild(pbBg);
|
|
const pbF = ns('rect');
|
|
pbF.setAttribute('x', '12'); pbF.setAttribute('y', '36'); pbF.setAttribute('width', String(progress * pbW)); pbF.setAttribute('height', '4');
|
|
pbF.setAttribute('rx', '2'); pbF.setAttribute('fill', hCol);
|
|
g.appendChild(pbF);
|
|
|
|
skills.forEach((skill, i) => {
|
|
const ry = TASK_H_BASE + i * TASK_ROW;
|
|
const needed = t.needs[skill];
|
|
const ful = (t.fulfilled || {})[skill] || 0;
|
|
const done = ful >= needed;
|
|
|
|
const portHit = ns('circle');
|
|
portHit.setAttribute('cx', '0'); portHit.setAttribute('cy', String(ry + TASK_ROW / 2));
|
|
portHit.setAttribute('r', '18'); portHit.setAttribute('fill', 'transparent');
|
|
portHit.setAttribute('class', 'port');
|
|
portHit.setAttribute('data-node', node.id); portHit.setAttribute('data-port', 'input'); portHit.setAttribute('data-skill', skill);
|
|
g.appendChild(portHit);
|
|
const port = ns('circle');
|
|
port.setAttribute('cx', '0'); port.setAttribute('cy', String(ry + TASK_ROW / 2));
|
|
port.setAttribute('r', String(PORT_R));
|
|
port.setAttribute('fill', done ? '#10b981' : (SKILL_COLORS[skill] || '#888'));
|
|
port.setAttribute('stroke', '#fff'); port.setAttribute('stroke-width', '2');
|
|
port.style.pointerEvents = 'none';
|
|
g.appendChild(port);
|
|
|
|
const dot = ns('circle');
|
|
dot.setAttribute('cx', '20'); dot.setAttribute('cy', String(ry + TASK_ROW / 2));
|
|
dot.setAttribute('r', '4'); dot.setAttribute('fill', SKILL_COLORS[skill] || '#888');
|
|
g.appendChild(dot);
|
|
|
|
const lbl = (SKILL_LABELS[skill] || skill) + ': ' + ful + '/' + needed + 'hr';
|
|
g.appendChild(svgText(lbl, 30, ry + TASK_ROW / 2 + 4, 11, done ? '#10b981' : '#94a3b8', done ? '600' : '400'));
|
|
if (done) g.appendChild(svgText('\u2713', node.w - 16, ry + TASK_ROW / 2 + 4, 13, '#10b981', '600', 'middle'));
|
|
});
|
|
|
|
if (ready) {
|
|
const btnY = node.baseH! + 4;
|
|
const btnR = ns('rect');
|
|
btnR.setAttribute('x', '12'); btnR.setAttribute('y', String(btnY));
|
|
btnR.setAttribute('width', String(node.w - 24)); btnR.setAttribute('height', String(EXEC_BTN_H));
|
|
btnR.setAttribute('rx', '6'); btnR.setAttribute('ry', '6');
|
|
btnR.setAttribute('fill', '#10b981');
|
|
btnR.setAttribute('class', 'exec-btn-rect');
|
|
btnR.setAttribute('data-task-id', node.id);
|
|
g.appendChild(btnR);
|
|
const btnT = svgText('Execute Project \u2192', node.w / 2, btnY + EXEC_BTN_H / 2 + 4, 12, '#fff', '600', 'middle');
|
|
btnT.style.pointerEvents = 'none';
|
|
g.appendChild(btnT);
|
|
}
|
|
}
|
|
|
|
return g;
|
|
}
|
|
|
|
private renderAll() {
|
|
this.nodesLayer.innerHTML = '';
|
|
this.weaveNodes.forEach(n => this.nodesLayer.appendChild(this.renderNode(n)));
|
|
this.renderConnections();
|
|
}
|
|
|
|
// SVG pointer events
|
|
|
|
private onSvgPointerDown(e: PointerEvent) {
|
|
if (this.svgActivePointerId !== null) return;
|
|
this.svgActivePointerId = e.pointerId;
|
|
this.svgEl.setPointerCapture(e.pointerId);
|
|
|
|
if ((e.target as Element).classList.contains('exec-btn-rect')) {
|
|
const taskId = (e.target as HTMLElement).getAttribute('data-task-id');
|
|
if (taskId) this.openExecPanel(taskId);
|
|
return;
|
|
}
|
|
|
|
const port = (e.target as Element).closest('.port') as SVGElement;
|
|
if (port) {
|
|
e.preventDefault();
|
|
this.connecting = { nodeId: port.getAttribute('data-node')!, portType: port.getAttribute('data-port')!, skill: port.getAttribute('data-skill') };
|
|
this.tempConn.style.display = 'block';
|
|
return;
|
|
}
|
|
const ng = (e.target as Element).closest('.node-group') as SVGElement;
|
|
if (ng) {
|
|
const id = ng.dataset.id!;
|
|
this.dragNode = this.weaveNodes.find(n => n.id === id) || null;
|
|
if (this.dragNode) {
|
|
const pt = this.svgPt(e.clientX, e.clientY);
|
|
this.dragOff.x = pt.x - this.dragNode.x;
|
|
this.dragOff.y = pt.y - this.dragNode.y;
|
|
}
|
|
// Double-tap detection
|
|
const now = Date.now();
|
|
if (this.svgDblTapPos && now - this.svgDblTapTime < 300) {
|
|
const dx = e.clientX - this.svgDblTapPos.x, dy = e.clientY - this.svgDblTapPos.y;
|
|
if (dx * dx + dy * dy < 225) {
|
|
const taskNode = this.weaveNodes.find(n => n.id === id && n.type === 'task');
|
|
if (taskNode) { this.openTaskEditor(taskNode); this.dragNode = null; }
|
|
}
|
|
}
|
|
this.svgDblTapTime = now;
|
|
this.svgDblTapPos = { x: e.clientX, y: e.clientY };
|
|
}
|
|
}
|
|
|
|
private onSvgPointerMove(e: PointerEvent) {
|
|
if (e.pointerId !== this.svgActivePointerId) return;
|
|
const pt = this.svgPt(e.clientX, e.clientY);
|
|
if (this.connecting) {
|
|
const fn = this.weaveNodes.find(n => n.id === this.connecting!.nodeId);
|
|
if (!fn) return;
|
|
if (this.connecting.portType === 'input') {
|
|
const skills = fn.type === 'task' ? Object.keys(fn.data.needs) : [];
|
|
const idx = skills.indexOf(this.connecting.skill!);
|
|
const x1 = fn.x;
|
|
const y1 = idx >= 0 ? fn.y + TASK_H_BASE + idx * TASK_ROW + TASK_ROW / 2 : fn.y + fn.h / 2;
|
|
this.tempConn.setAttribute('d', bezier(pt.x, pt.y, x1, y1));
|
|
} else {
|
|
const outX = fn.x + fn.w, outY = fn.y + fn.h / 2;
|
|
this.tempConn.setAttribute('d', bezier(outX, outY, pt.x, pt.y));
|
|
}
|
|
return;
|
|
}
|
|
if (this.dragNode) {
|
|
this.dragNode.x = pt.x - this.dragOff.x;
|
|
this.dragNode.y = pt.y - this.dragOff.y;
|
|
this.renderAll();
|
|
}
|
|
}
|
|
|
|
private onSvgPointerUp(e: PointerEvent) {
|
|
if (e.pointerId !== this.svgActivePointerId) return;
|
|
this.svgActivePointerId = null;
|
|
|
|
if (this.connecting) {
|
|
const port = (e.target as Element).closest('.port') as SVGElement;
|
|
if (port) {
|
|
const tId = port.getAttribute('data-node')!;
|
|
const tType = port.getAttribute('data-port')!;
|
|
const tSkill = port.getAttribute('data-skill');
|
|
let fromId: string | undefined, toId: string | undefined, skill: string | undefined;
|
|
if (this.connecting.portType === 'output' && tType === 'input' && tId !== this.connecting.nodeId) {
|
|
fromId = this.connecting.nodeId; toId = tId; skill = tSkill || undefined;
|
|
} else if (this.connecting.portType === 'input' && tType === 'output' && tId !== this.connecting.nodeId) {
|
|
fromId = tId; toId = this.connecting.nodeId; skill = this.connecting.skill || undefined;
|
|
}
|
|
if (fromId && toId && skill) {
|
|
const fNode = this.weaveNodes.find(n => n.id === fromId);
|
|
const tNode = this.weaveNodes.find(n => n.id === toId);
|
|
if (fNode && tNode && fNode.type === 'commitment' && tNode.type === 'task' && fNode.data.skill === skill) {
|
|
if (!this.connections.find(c => c.from === fromId && c.to === toId)) {
|
|
this.connections.push({ from: fromId, to: toId, skill });
|
|
if (!tNode.data.fulfilled) tNode.data.fulfilled = {};
|
|
tNode.data.fulfilled[skill] = (tNode.data.fulfilled[skill] || 0) + fNode.data.hours;
|
|
const nowReady = this.isTaskReady(tNode);
|
|
this.renderAll();
|
|
this.rebuildSidebar();
|
|
if (nowReady) setTimeout(() => this.openExecPanel(tNode.id), 600);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
this.connecting = null;
|
|
this.tempConn.style.display = 'none';
|
|
this.tempConn.setAttribute('d', '');
|
|
return;
|
|
}
|
|
this.dragNode = null;
|
|
}
|
|
|
|
// ── Sidebar ──
|
|
|
|
private rebuildSidebar() {
|
|
const cont = this.shadow.getElementById('sidebarItems');
|
|
if (!cont) return;
|
|
cont.innerHTML = '';
|
|
const used = new Set(this.weaveNodes.filter(n => n.type === 'commitment').map(n => n.data.id));
|
|
this.commitments.forEach(c => {
|
|
const el = document.createElement('div');
|
|
el.className = 'sidebar-item' + (used.has(c.id) ? ' used' : '');
|
|
el.innerHTML = '<div class="sidebar-dot" style="background:' + (SKILL_COLORS[c.skill] || '#888') + '"></div>' +
|
|
'<div class="sidebar-item-info"><div class="sidebar-item-name">' + c.memberName + '</div>' +
|
|
'<div class="sidebar-item-meta">' + c.hours + 'hr \u00b7 ' + (SKILL_LABELS[c.skill] || c.skill) + '</div></div>';
|
|
if (!used.has(c.id)) {
|
|
el.addEventListener('pointerdown', (e) => this.startSidebarDrag(e, { type: 'commitment', id: c.id }, c.memberName));
|
|
}
|
|
cont.appendChild(el);
|
|
});
|
|
|
|
const tc = this.shadow.getElementById('sidebarTasks');
|
|
if (!tc) return;
|
|
tc.innerHTML = '';
|
|
const usedT = new Set(this.weaveNodes.filter(n => n.type === 'task').map(n => n.id));
|
|
this.tasks.forEach(t => {
|
|
if (usedT.has(t.id)) return;
|
|
const el = document.createElement('div');
|
|
el.className = 'sidebar-task';
|
|
const needs = Object.entries(t.needs).map(([s, h]) => h + 'hr ' + (SKILL_LABELS[s] || s)).join(', ');
|
|
el.innerHTML = '<div class="sidebar-task-icon">\u25C9</div>' +
|
|
'<div class="sidebar-item-info"><div class="sidebar-item-name">' + t.name + '</div>' +
|
|
'<div class="sidebar-item-meta">' + needs + '</div></div>';
|
|
el.addEventListener('pointerdown', (e) => this.startSidebarDrag(e, { type: 'task', id: t.id }, t.name));
|
|
tc.appendChild(el);
|
|
});
|
|
}
|
|
|
|
private startSidebarDrag(e: PointerEvent, data: { type: string; id: string }, label: string) {
|
|
e.preventDefault();
|
|
this.sidebarDragData = data;
|
|
this.sidebarGhost = document.createElement('div');
|
|
this.sidebarGhost.style.cssText = 'position:fixed;padding:0.5rem 0.85rem;background:#1e293b;border:2px solid #8b5cf6;border-radius:0.5rem;font-size:0.8rem;font-weight:600;color:#e2e8f0;pointer-events:none;z-index:9999;box-shadow:0 4px 16px rgba(139,92,246,0.25);white-space:nowrap;';
|
|
this.sidebarGhost.textContent = label;
|
|
this.sidebarGhost.style.left = e.clientX + 'px';
|
|
this.sidebarGhost.style.top = (e.clientY - 20) + 'px';
|
|
document.body.appendChild(this.sidebarGhost);
|
|
|
|
const moveHandler = (ev: PointerEvent) => {
|
|
if (!this.sidebarGhost) return;
|
|
this.sidebarGhost.style.left = ev.clientX + 'px';
|
|
this.sidebarGhost.style.top = (ev.clientY - 20) + 'px';
|
|
};
|
|
const upHandler = (ev: PointerEvent) => {
|
|
document.removeEventListener('pointermove', moveHandler);
|
|
document.removeEventListener('pointerup', upHandler);
|
|
document.removeEventListener('pointercancel', cancelHandler);
|
|
if (this.sidebarGhost) { this.sidebarGhost.remove(); this.sidebarGhost = null; }
|
|
if (!this.sidebarDragData) return;
|
|
|
|
const wrap = this.shadow.getElementById('canvasWrap');
|
|
if (!wrap) return;
|
|
const wr = wrap.getBoundingClientRect();
|
|
if (ev.clientX >= wr.left && ev.clientX <= wr.right && ev.clientY >= wr.top && ev.clientY <= wr.bottom) {
|
|
const pt = this.svgPt(ev.clientX, ev.clientY);
|
|
if (this.sidebarDragData.type === 'commitment') {
|
|
const c = this.commitments.find(x => x.id === this.sidebarDragData!.id);
|
|
if (c && !this.weaveNodes.find(n => n.id === 'cn-' + c.id)) {
|
|
this.weaveNodes.push(this.mkCommitNode(c, pt.x - NODE_W / 2, pt.y - NODE_H / 2));
|
|
}
|
|
} else if (this.sidebarDragData.type === 'task') {
|
|
const t = this.tasks.find(x => x.id === this.sidebarDragData!.id);
|
|
if (t && !this.weaveNodes.find(n => n.id === t.id)) {
|
|
this.weaveNodes.push(this.mkTaskNode(t, pt.x - TASK_W / 2, pt.y - 40));
|
|
}
|
|
}
|
|
this.renderAll();
|
|
this.rebuildSidebar();
|
|
}
|
|
this.sidebarDragData = null;
|
|
};
|
|
const cancelHandler = () => {
|
|
document.removeEventListener('pointermove', moveHandler);
|
|
document.removeEventListener('pointerup', upHandler);
|
|
document.removeEventListener('pointercancel', cancelHandler);
|
|
if (this.sidebarGhost) { this.sidebarGhost.remove(); this.sidebarGhost = null; }
|
|
this.sidebarDragData = null;
|
|
};
|
|
|
|
document.addEventListener('pointermove', moveHandler);
|
|
document.addEventListener('pointerup', upHandler);
|
|
document.addEventListener('pointercancel', cancelHandler);
|
|
}
|
|
|
|
// ── Exec panel ──
|
|
|
|
private openExecPanel(taskId: string) {
|
|
const node = this.weaveNodes.find(n => n.id === taskId);
|
|
if (!node || node.type !== 'task') return;
|
|
|
|
const t = node.data as TaskData;
|
|
this.shadow.getElementById('execTitle')!.innerHTML = t.name + ' <span class="exec-panel-badge">All Commitments Woven</span>';
|
|
this.shadow.getElementById('execSubtitle')!.textContent = 'Set up the project for execution';
|
|
|
|
const members = this.connections.filter(c => c.to === taskId).map(c => {
|
|
const cn = this.weaveNodes.find(n => n.id === c.from);
|
|
return cn ? cn.data.memberName : '';
|
|
}).filter(Boolean);
|
|
|
|
const steps = EXEC_STEPS[taskId] || EXEC_STEPS.default;
|
|
if (!this.execStepStates[taskId]) {
|
|
this.execStepStates[taskId] = {};
|
|
steps.forEach((_, i) => { this.execStepStates[taskId][i] = 'pending'; });
|
|
this.execStepStates[taskId][0] = 'active';
|
|
}
|
|
|
|
this.renderExecSteps(taskId, steps, members);
|
|
this.shadow.getElementById('execOverlay')!.classList.add('visible');
|
|
}
|
|
|
|
private renderExecSteps(taskId: string, steps: typeof EXEC_STEPS['default'], members: string[]) {
|
|
const container = this.shadow.getElementById('execSteps')!;
|
|
container.innerHTML = '';
|
|
const states = this.execStepStates[taskId];
|
|
let doneCount = 0;
|
|
|
|
steps.forEach((step, i) => {
|
|
const state = states[i] || 'pending';
|
|
if (state === 'done') doneCount++;
|
|
|
|
const div = document.createElement('div');
|
|
div.className = 'exec-step ' + state;
|
|
div.innerHTML = `
|
|
<div class="exec-step-num">${state === 'done' ? '\u2713' : step.icon}</div>
|
|
<div class="exec-step-content">
|
|
<div class="exec-step-title">${step.title}</div>
|
|
<div class="exec-step-desc">${step.desc}</div>
|
|
</div>
|
|
`;
|
|
|
|
div.addEventListener('click', () => {
|
|
if (states[i] === 'done') return;
|
|
if (states[i] === 'active') {
|
|
states[i] = 'done';
|
|
} else {
|
|
Object.keys(states).forEach(k => { if (states[Number(k)] === 'active') states[Number(k)] = 'pending'; });
|
|
states[i] = 'active';
|
|
}
|
|
// Auto-advance
|
|
let nextActive = -1;
|
|
for (let j = 0; j < steps.length; j++) {
|
|
if (states[j] !== 'done') { nextActive = j; break; }
|
|
}
|
|
if (nextActive >= 0 && states[nextActive] !== 'active') {
|
|
Object.keys(states).forEach(k => { if (states[Number(k)] === 'active') states[Number(k)] = 'pending'; });
|
|
states[nextActive] = 'active';
|
|
}
|
|
this.renderExecSteps(taskId, steps, members);
|
|
});
|
|
|
|
container.appendChild(div);
|
|
});
|
|
|
|
const pct = steps.length > 0 ? (doneCount / steps.length * 100) : 0;
|
|
(this.shadow.getElementById('execProgressFill') as HTMLElement).style.width = pct + '%';
|
|
this.shadow.getElementById('execProgressText')!.textContent = doneCount + '/' + steps.length;
|
|
(this.shadow.getElementById('execLaunch') as HTMLButtonElement).disabled = doneCount < steps.length;
|
|
}
|
|
|
|
// ── Task editor ──
|
|
|
|
private editingTaskNode: WeaveNode | null = null;
|
|
|
|
private openTaskEditor(node: WeaveNode) {
|
|
this.editingTaskNode = node;
|
|
const t = node.data as TaskData;
|
|
this.shadow.getElementById('taskEditTitle')!.textContent = 'Edit: ' + t.name;
|
|
(this.shadow.getElementById('taskEditName') as HTMLInputElement).value = t.name;
|
|
(this.shadow.getElementById('taskEditDesc') as HTMLTextAreaElement).value = t.description || '';
|
|
(this.shadow.getElementById('taskEditNotes') as HTMLTextAreaElement).value = t.notes || '';
|
|
this.shadow.getElementById('taskEditOverlay')!.classList.add('visible');
|
|
}
|
|
|
|
private saveTaskEdit() {
|
|
if (!this.editingTaskNode) return;
|
|
const t = this.editingTaskNode.data as TaskData;
|
|
t.name = (this.shadow.getElementById('taskEditName') as HTMLInputElement).value.trim() || t.name;
|
|
t.description = (this.shadow.getElementById('taskEditDesc') as HTMLTextAreaElement).value.trim();
|
|
t.notes = (this.shadow.getElementById('taskEditNotes') as HTMLTextAreaElement).value.trim();
|
|
this.renderAll();
|
|
this.shadow.getElementById('taskEditOverlay')!.classList.remove('visible');
|
|
this.editingTaskNode = null;
|
|
}
|
|
|
|
/* ── Guided Tour ── */
|
|
|
|
private _tour: import('../../../shared/tour-engine').TourEngine | null = null;
|
|
|
|
private async _initTour() {
|
|
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: 'Pool & Weave Views', message: 'Switch between the Commitment Pool (visual orbs) and the Weaving Dashboard (SVG node editor).' },
|
|
{ target: '#pool-canvas', title: 'Commitment Pool', message: 'Each floating orb represents a time commitment — sized by hours, colored by skill category.' },
|
|
{ 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.' },
|
|
], 'rtime_tour_done', () => this.shadow.querySelector('.main') as HTMLElement);
|
|
}
|
|
|
|
async startTour() {
|
|
await this._initTour();
|
|
this._tour?.start();
|
|
}
|
|
}
|
|
|
|
// ── CSS ──
|
|
|
|
const CSS_TEXT = `
|
|
:host {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
|
color: #e2e8f0;
|
|
background: #0f172a;
|
|
line-height: 1.6;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.tab-bar {
|
|
display: flex;
|
|
background: #1e293b;
|
|
border-bottom: 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;
|
|
}
|
|
.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%; }
|
|
|
|
.main { flex: 1; position: relative; overflow: hidden; }
|
|
|
|
#pool-view { width: 100%; height: 100%; position: absolute; top: 0; left: 0; }
|
|
#pool-canvas { width: 100%; height: 100%; display: block; cursor: default; touch-action: none; }
|
|
|
|
.pool-detail {
|
|
position: absolute;
|
|
background: #1e293b;
|
|
border-radius: 0.75rem;
|
|
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
|
padding: 1.25rem;
|
|
min-width: 220px;
|
|
pointer-events: none;
|
|
opacity: 0;
|
|
transform: scale(0.9) translateY(8px);
|
|
transition: opacity 0.2s, transform 0.2s;
|
|
z-index: 50;
|
|
}
|
|
.pool-detail.visible { opacity: 1; transform: scale(1) translateY(0); pointer-events: auto; }
|
|
.pool-detail-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
|
|
.pool-detail-dot { width: 10px; height: 10px; border-radius: 50%; }
|
|
.pool-detail-name { font-weight: 600; font-size: 0.95rem; color: #f1f5f9; }
|
|
.pool-detail-skill { font-size: 0.82rem; color: #94a3b8; margin-bottom: 0.3rem; }
|
|
.pool-detail-hours { font-size: 0.85rem; font-weight: 600; color: #8b5cf6; }
|
|
.pool-detail-desc { font-size: 0.8rem; color: #94a3b8; margin-top: 0.35rem; line-height: 1.4; }
|
|
|
|
.add-btn {
|
|
position: absolute;
|
|
bottom: 1.5rem;
|
|
right: 1.5rem;
|
|
padding: 0.65rem 1.25rem;
|
|
background: linear-gradient(135deg, #8b5cf6, #ec4899);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 0.5rem;
|
|
font-size: 0.9rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
box-shadow: 0 4px 16px rgba(139,92,246,0.3);
|
|
transition: all 0.2s;
|
|
z-index: 20;
|
|
}
|
|
.add-btn:hover { transform: translateY(-1px); box-shadow: 0 6px 20px rgba(139,92,246,0.4); }
|
|
|
|
/* Weaving */
|
|
#weave-view {
|
|
width: 100%; height: 100%;
|
|
position: absolute; top: 0; left: 0;
|
|
flex-direction: row;
|
|
}
|
|
|
|
.sidebar {
|
|
width: 260px;
|
|
background: #1e293b;
|
|
border-right: 1px solid #334155;
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex-shrink: 0;
|
|
overflow: hidden;
|
|
}
|
|
.sidebar-header {
|
|
padding: 0.75rem 1rem;
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
color: #94a3b8;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
border-bottom: 1px solid #334155;
|
|
}
|
|
.sidebar-items { flex: 1; overflow-y: auto; padding: 0.5rem; }
|
|
.sidebar-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.6rem;
|
|
padding: 0.6rem 0.75rem;
|
|
border-radius: 0.5rem;
|
|
cursor: grab;
|
|
transition: background 0.15s;
|
|
margin-bottom: 0.25rem;
|
|
touch-action: none;
|
|
}
|
|
.sidebar-item:hover { background: #334155; }
|
|
.sidebar-item.used { opacity: 0.35; pointer-events: none; }
|
|
.sidebar-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
|
.sidebar-item-info { flex: 1; min-width: 0; }
|
|
.sidebar-item-name { font-size: 0.82rem; font-weight: 600; color: #f1f5f9; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.sidebar-item-meta { font-size: 0.72rem; color: #64748b; }
|
|
|
|
.sidebar-section {
|
|
padding: 0.75rem 1rem;
|
|
font-size: 0.78rem;
|
|
font-weight: 600;
|
|
color: #64748b;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
border-top: 1px solid #334155;
|
|
margin-top: 0.25rem;
|
|
}
|
|
.sidebar-task {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.6rem;
|
|
padding: 0.6rem 0.75rem;
|
|
border-radius: 0.5rem;
|
|
cursor: grab;
|
|
transition: background 0.15s;
|
|
margin: 0 0.5rem 0.25rem;
|
|
touch-action: none;
|
|
}
|
|
.sidebar-task:hover { background: #334155; }
|
|
.sidebar-task-icon {
|
|
width: 24px; height: 24px;
|
|
border-radius: 0.375rem;
|
|
background: linear-gradient(135deg, #8b5cf6, #ec4899);
|
|
display: flex; align-items: center; justify-content: center;
|
|
color: #fff; font-size: 0.7rem; flex-shrink: 0;
|
|
}
|
|
|
|
.canvas-wrap { flex: 1; position: relative; overflow: hidden; background: #0f172a; }
|
|
#weave-svg { width: 100%; height: 100%; display: block; touch-action: none; }
|
|
|
|
.node-group { cursor: grab; }
|
|
.node-group:active { cursor: grabbing; }
|
|
.node-rect { fill: #1e293b; stroke: #334155; stroke-width: 1; transition: stroke 0.15s; }
|
|
.node-group:hover .node-rect { stroke: #8b5cf6; }
|
|
.port { cursor: crosshair; transition: r 0.15s; touch-action: none; }
|
|
.port:hover { r: 7; }
|
|
.connection-line { fill: none; stroke: #475569; stroke-width: 2; pointer-events: none; }
|
|
.connection-line.active { stroke: #8b5cf6; stroke-width: 2.5; }
|
|
.temp-connection { fill: none; stroke: #8b5cf6; stroke-width: 2; stroke-dasharray: 6 4; pointer-events: none; opacity: 0.6; }
|
|
.task-node .node-rect { stroke: #8b5cf6; stroke-width: 1.5; }
|
|
.task-node.ready .node-rect { stroke: #10b981; stroke-width: 2; }
|
|
|
|
.exec-btn-rect { cursor: pointer; transition: opacity 0.15s; }
|
|
.exec-btn-rect:hover { opacity: 0.85; }
|
|
|
|
/* Modals & overlays */
|
|
.modal-overlay, .exec-overlay, .task-edit-overlay {
|
|
position: absolute; inset: 0;
|
|
background: rgba(0,0,0,0.7);
|
|
z-index: 200;
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.modal-overlay.visible, .exec-overlay.visible, .task-edit-overlay.visible { display: flex; }
|
|
|
|
.modal {
|
|
background: #1e293b;
|
|
border-radius: 1rem;
|
|
padding: 1.75rem;
|
|
width: 380px;
|
|
max-width: 90vw;
|
|
box-shadow: 0 16px 64px rgba(0,0,0,0.5);
|
|
}
|
|
.modal h3 { font-size: 1.1rem; font-weight: 700; color: #f1f5f9; margin-bottom: 1rem; }
|
|
.modal-field { margin-bottom: 0.85rem; }
|
|
.modal-field label { display: block; font-size: 0.8rem; font-weight: 500; color: #94a3b8; margin-bottom: 0.25rem; }
|
|
.modal-field input, .modal-field select {
|
|
width: 100%;
|
|
padding: 0.55rem 0.75rem;
|
|
border: 1px solid #475569;
|
|
border-radius: 0.375rem;
|
|
font-size: 0.9rem;
|
|
outline: none;
|
|
transition: border-color 0.15s;
|
|
background: #0f172a;
|
|
color: #e2e8f0;
|
|
}
|
|
.modal-field input:focus, .modal-field select:focus { border-color: #8b5cf6; }
|
|
.modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1.25rem; }
|
|
.modal-cancel {
|
|
padding: 0.5rem 1rem;
|
|
border: 1px solid #334155;
|
|
border-radius: 0.375rem;
|
|
background: #1e293b;
|
|
color: #94a3b8;
|
|
font-size: 0.85rem;
|
|
cursor: pointer;
|
|
}
|
|
.modal-submit {
|
|
padding: 0.5rem 1.25rem;
|
|
background: linear-gradient(135deg, #8b5cf6, #ec4899);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 0.375rem;
|
|
font-size: 0.85rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* Exec panel */
|
|
.exec-overlay { z-index: 300; }
|
|
.exec-panel {
|
|
background: #1e293b;
|
|
border-radius: 1rem;
|
|
width: 520px;
|
|
max-width: 95vw;
|
|
max-height: 90vh;
|
|
overflow-y: auto;
|
|
box-shadow: 0 16px 64px rgba(0,0,0,0.5);
|
|
}
|
|
.exec-panel-header {
|
|
padding: 1.5rem 1.75rem 1rem;
|
|
border-bottom: 1px solid #334155;
|
|
}
|
|
.exec-panel-header h2 { font-size: 1.15rem; font-weight: 700; color: #f1f5f9; margin-bottom: 0.2rem; }
|
|
.exec-panel-header p { font-size: 0.85rem; color: #64748b; }
|
|
.exec-panel-badge {
|
|
display: inline-block;
|
|
padding: 0.15rem 0.6rem;
|
|
border-radius: 1rem;
|
|
background: rgba(16,185,129,0.15);
|
|
color: #10b981;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
margin-left: 0.5rem;
|
|
vertical-align: middle;
|
|
}
|
|
.exec-steps { padding: 0.75rem 1.75rem 1.5rem; }
|
|
.exec-step {
|
|
display: flex;
|
|
gap: 1rem;
|
|
padding: 1rem 0.5rem;
|
|
border-bottom: 1px solid #1e293b;
|
|
align-items: flex-start;
|
|
cursor: pointer;
|
|
transition: background 0.1s;
|
|
border-radius: 0.5rem;
|
|
}
|
|
.exec-step:hover { background: #0f172a; }
|
|
.exec-step:last-child { border-bottom: none; }
|
|
.exec-step.active .exec-step-num { background: linear-gradient(135deg, #8b5cf6, #ec4899); color: #fff; }
|
|
.exec-step.done .exec-step-num { background: #10b981; color: #fff; }
|
|
.exec-step-num {
|
|
width: 32px; height: 32px; min-width: 32px;
|
|
border-radius: 50%;
|
|
background: #334155;
|
|
color: #64748b;
|
|
display: flex; align-items: center; justify-content: center;
|
|
font-size: 0.82rem; font-weight: 600;
|
|
transition: all 0.2s;
|
|
}
|
|
.exec-step-content { flex: 1; }
|
|
.exec-step-title { font-size: 0.92rem; font-weight: 600; color: #f1f5f9; margin-bottom: 0.15rem; }
|
|
.exec-step.done .exec-step-title { color: #10b981; }
|
|
.exec-step-desc { font-size: 0.8rem; color: #64748b; line-height: 1.4; }
|
|
.exec-panel-footer {
|
|
padding: 1rem 1.75rem;
|
|
border-top: 1px solid #334155;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
.exec-close {
|
|
padding: 0.45rem 1rem;
|
|
border: 1px solid #334155;
|
|
border-radius: 0.375rem;
|
|
background: #1e293b;
|
|
color: #94a3b8;
|
|
font-size: 0.85rem;
|
|
cursor: pointer;
|
|
}
|
|
.exec-launch {
|
|
padding: 0.5rem 1.5rem;
|
|
background: linear-gradient(135deg, #10b981, #059669);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 0.375rem;
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
}
|
|
.exec-launch:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
.exec-progress { display: flex; gap: 0.35rem; align-items: center; font-size: 0.78rem; color: #64748b; }
|
|
.exec-progress-bar { width: 80px; height: 4px; background: #334155; border-radius: 2px; overflow: hidden; }
|
|
.exec-progress-fill { height: 100%; background: linear-gradient(90deg, #8b5cf6, #10b981); border-radius: 2px; transition: width 0.3s; }
|
|
|
|
/* Task editor */
|
|
.task-edit-overlay { z-index: 250; }
|
|
.task-edit-panel {
|
|
background: #1e293b;
|
|
border-radius: 1rem;
|
|
width: 480px;
|
|
max-width: 95vw;
|
|
max-height: 90vh;
|
|
overflow-y: auto;
|
|
box-shadow: 0 16px 64px rgba(0,0,0,0.5);
|
|
}
|
|
.task-edit-header { padding: 1.25rem 1.5rem 0.75rem; border-bottom: 1px solid #334155; }
|
|
.task-edit-header h3 { font-size: 1.05rem; font-weight: 700; color: #f1f5f9; }
|
|
.task-edit-body { padding: 1rem 1.5rem; }
|
|
.task-edit-field { margin-bottom: 0.85rem; }
|
|
.task-edit-field label { display: block; font-size: 0.78rem; font-weight: 500; color: #94a3b8; margin-bottom: 0.25rem; }
|
|
.task-edit-field input, .task-edit-field textarea {
|
|
width: 100%;
|
|
padding: 0.5rem 0.7rem;
|
|
border: 1px solid #475569;
|
|
border-radius: 0.375rem;
|
|
font-size: 0.85rem;
|
|
font-family: inherit;
|
|
outline: none;
|
|
background: #0f172a;
|
|
color: #e2e8f0;
|
|
}
|
|
.task-edit-field input:focus, .task-edit-field textarea:focus { border-color: #8b5cf6; }
|
|
.task-edit-field textarea { resize: vertical; min-height: 70px; }
|
|
.task-edit-footer {
|
|
padding: 0.75rem 1.5rem;
|
|
border-top: 1px solid #334155;
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.sidebar { width: 200px; }
|
|
.exec-panel { width: 95vw; }
|
|
.task-edit-panel { width: 95vw; }
|
|
}
|
|
@media (max-width: 640px) {
|
|
.sidebar { width: 180px; }
|
|
}
|
|
`;
|
|
|
|
customElements.define('folk-timebank-app', FolkTimebankApp);
|