feat(rtime): port 14 missing hcc-mem-staging features to rTime module
- Connection & exec state persistence (POST /api/connections, PUT exec-state) - Exec step detail forms for all 5 types (venue, comms, notes, prep, launch) - Step state machine fix: click to expand/collapse, action button to complete - Task editor links field with dynamic add/remove and server persistence - Cyclos-aware launch handler with fallback to demo celebration - Fix dead EXEC_STEPS[taskId] lookup, auto-place first task on empty canvas - DID display truncation for unreadable DIDs in intent routes - Dark/light theme toggle with localStorage persistence - Hex hover stroke, commitment description in hex nodes, edit pencil on tasks Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5b4300db77
commit
87bafc9d74
|
|
@ -292,6 +292,10 @@ class FolkTimebankApp extends HTMLElement {
|
|||
|
||||
// Exec state
|
||||
private execStepStates: Record<string, Record<number, string>> = {};
|
||||
private _restoredConnections: { fromCommitmentId: string; toTaskId: string; skill: string }[] = [];
|
||||
private _currentExecTaskId: string | null = null;
|
||||
private _cyclosMembers: { id: string; name: string; balance: number }[] = [];
|
||||
private _theme: 'dark' | 'light' = 'dark';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
|
@ -309,7 +313,9 @@ class FolkTimebankApp extends HTMLElement {
|
|||
this.space = this.getAttribute('space') || 'demo';
|
||||
this.currentView = (this.getAttribute('view') as any) || 'pool';
|
||||
this.dpr = window.devicePixelRatio || 1;
|
||||
this._theme = (localStorage.getItem('rtime-theme') as 'dark' | 'light') || 'dark';
|
||||
this.render();
|
||||
this.applyTheme();
|
||||
this.setupPool();
|
||||
this.setupWeave();
|
||||
this.setupCollaborate();
|
||||
|
|
@ -329,6 +335,16 @@ class FolkTimebankApp extends HTMLElement {
|
|||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
private applyTheme() {
|
||||
if (this._theme === 'light') {
|
||||
this.setAttribute('data-theme', 'light');
|
||||
} else {
|
||||
this.removeAttribute('data-theme');
|
||||
}
|
||||
const btn = this.shadow.getElementById('themeToggle');
|
||||
if (btn) btn.textContent = this._theme === 'dark' ? '☀' : '☾';
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this.animFrame) cancelAnimationFrame(this.animFrame);
|
||||
}
|
||||
|
|
@ -347,13 +363,49 @@ class FolkTimebankApp extends HTMLElement {
|
|||
if (tResp.ok) {
|
||||
const tData = await tResp.json();
|
||||
this.tasks = tData.tasks || [];
|
||||
// Restore connections
|
||||
this._restoredConnections = (tData.connections || []).map((cn: any) => ({
|
||||
fromCommitmentId: cn.fromCommitmentId,
|
||||
toTaskId: cn.toTaskId,
|
||||
skill: cn.skill,
|
||||
}));
|
||||
// Restore exec states
|
||||
for (const es of (tData.execStates || [])) {
|
||||
if (es.taskId && es.steps) {
|
||||
this.execStepStates[es.taskId] = {};
|
||||
for (const [k, v] of Object.entries(es.steps)) {
|
||||
this.execStepStates[es.taskId][Number(k)] = v as string;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Offline — use empty state
|
||||
}
|
||||
|
||||
// Fetch Cyclos members (silent failure → demo mode)
|
||||
try {
|
||||
const mResp = await fetch(`${base}/api/cyclos/members`);
|
||||
if (mResp.ok) {
|
||||
const mData = await mResp.json();
|
||||
this._cyclosMembers = (mData.members || []).map((m: any) => ({ id: m.id, name: m.name, balance: m.balance || 0 }));
|
||||
}
|
||||
} catch { /* Cyclos not configured — demo mode */ }
|
||||
|
||||
this.buildOrbs();
|
||||
this.updateStats();
|
||||
this.rebuildSidebar();
|
||||
this.applyRestoredConnections();
|
||||
|
||||
// Auto-place first task if canvas is empty
|
||||
if (this.weaveNodes.length === 0 && this.tasks.length > 0) {
|
||||
const svgRect = this.svgEl?.getBoundingClientRect();
|
||||
const x = svgRect ? svgRect.width * 0.55 : 400;
|
||||
const y = svgRect ? svgRect.height * 0.2 : 80;
|
||||
this.weaveNodes.push(this.mkTaskNode(this.tasks[0], x - TASK_W / 2, y));
|
||||
this.renderAll();
|
||||
this.rebuildSidebar();
|
||||
}
|
||||
}
|
||||
|
||||
private render() {
|
||||
|
|
@ -363,6 +415,7 @@ class FolkTimebankApp extends HTMLElement {
|
|||
<div class="tab active" data-view="pool">Commitment Pool</div>
|
||||
<div class="tab" data-view="weave">Weaving Dashboard</div>
|
||||
<div class="tab" data-view="collaborate">Collaborate</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>
|
||||
|
|
@ -538,6 +591,10 @@ class FolkTimebankApp extends HTMLElement {
|
|||
<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 class="task-edit-field"><label>Links</label>
|
||||
<div id="taskEditLinksContainer"></div>
|
||||
<button class="task-edit-add-link" id="taskEditAddLink">+ Add Link</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-edit-footer">
|
||||
<button class="modal-cancel" id="taskEditCancel">Cancel</button>
|
||||
|
|
@ -573,6 +630,13 @@ class FolkTimebankApp extends HTMLElement {
|
|||
});
|
||||
});
|
||||
|
||||
// Theme toggle
|
||||
this.shadow.getElementById('themeToggle')!.addEventListener('click', () => {
|
||||
this._theme = this._theme === 'dark' ? 'light' : 'dark';
|
||||
localStorage.setItem('rtime-theme', this._theme);
|
||||
this.applyTheme();
|
||||
});
|
||||
|
||||
// Add commitment modal
|
||||
const modal = this.shadow.getElementById('modalOverlay')!;
|
||||
this.shadow.getElementById('addBtn')!.addEventListener('click', () => {
|
||||
|
|
@ -598,16 +662,7 @@ class FolkTimebankApp extends HTMLElement {
|
|||
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);
|
||||
});
|
||||
this.shadow.getElementById('execLaunch')!.addEventListener('click', () => this.handleExecLaunch());
|
||||
|
||||
// Task editor
|
||||
this.shadow.getElementById('taskEditCancel')!.addEventListener('click', () => this.shadow.getElementById('taskEditOverlay')!.classList.remove('visible'));
|
||||
|
|
@ -615,6 +670,11 @@ class FolkTimebankApp extends HTMLElement {
|
|||
if (e.target === this.shadow.getElementById('taskEditOverlay')) this.shadow.getElementById('taskEditOverlay')!.classList.remove('visible');
|
||||
});
|
||||
this.shadow.getElementById('taskEditSave')!.addEventListener('click', () => this.saveTaskEdit());
|
||||
this.shadow.getElementById('taskEditAddLink')!.addEventListener('click', () => {
|
||||
const current = this.getTaskEditLinks();
|
||||
current.push({ label: '', url: '' });
|
||||
this.renderTaskEditLinks(current);
|
||||
});
|
||||
|
||||
// Resize
|
||||
const resizeObserver = new ResizeObserver(() => { if (this.currentView === 'pool') this.resizePoolCanvas(); });
|
||||
|
|
@ -716,7 +776,11 @@ class FolkTimebankApp extends HTMLElement {
|
|||
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');
|
||||
if (this._theme === 'light') {
|
||||
bg.addColorStop(0, '#f8f7ff'); bg.addColorStop(1, '#fdf2f8');
|
||||
} else {
|
||||
bg.addColorStop(0, '#0f172a'); bg.addColorStop(1, '#1a1033');
|
||||
}
|
||||
this.ctx.fillStyle = bg; this.ctx.fillRect(0, 0, this.poolW, this.poolH);
|
||||
|
||||
this.drawBasket();
|
||||
|
|
@ -949,6 +1013,15 @@ class FolkTimebankApp extends HTMLElement {
|
|||
hex.setAttribute('filter', 'url(#nodeShadow)');
|
||||
g.appendChild(hex);
|
||||
|
||||
const hexHover = ns('polygon');
|
||||
hexHover.setAttribute('points', hexPolygonStr(cx, cy, hr));
|
||||
hexHover.setAttribute('fill', 'none');
|
||||
hexHover.setAttribute('stroke', col);
|
||||
hexHover.setAttribute('stroke-width', '0');
|
||||
hexHover.setAttribute('class', 'hex-hover-stroke');
|
||||
hexHover.style.pointerEvents = 'none';
|
||||
g.appendChild(hexHover);
|
||||
|
||||
const clipId = 'hexClip-' + node.id;
|
||||
const clipPath = ns('clipPath');
|
||||
clipPath.setAttribute('id', clipId);
|
||||
|
|
@ -968,6 +1041,10 @@ class FolkTimebankApp extends HTMLElement {
|
|||
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'));
|
||||
if (c.desc) {
|
||||
const descText = c.desc.length > 18 ? c.desc.slice(0, 17) + '\u2026' : c.desc;
|
||||
g.appendChild(svgText(descText, cx, cy + 32, 9, '#64748b', '400', 'middle'));
|
||||
}
|
||||
|
||||
const pts = hexPoints(cx, cy, hr);
|
||||
const portHit = ns('circle');
|
||||
|
|
@ -1011,6 +1088,9 @@ class FolkTimebankApp extends HTMLElement {
|
|||
g.appendChild(hm);
|
||||
|
||||
g.appendChild(svgText(t.name, 12, 18, 12, '#fff', '600'));
|
||||
const editPencil = svgText('\u270E', node.w - 10, 18, 11, '#ffffff66', '400', 'middle');
|
||||
editPencil.style.pointerEvents = 'none';
|
||||
g.appendChild(editPencil);
|
||||
g.appendChild(svgText(ready ? 'Ready!' : Math.round(progress * 100) + '%', node.w - 24, 18, 10, '#ffffffcc', '500', 'end'));
|
||||
|
||||
const pbW = node.w - 24;
|
||||
|
|
@ -1170,6 +1250,7 @@ class FolkTimebankApp extends HTMLElement {
|
|||
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;
|
||||
this.persistConnection(fromId, toId, skill);
|
||||
const nowReady = this.isTaskReady(tNode);
|
||||
this.renderAll();
|
||||
this.rebuildSidebar();
|
||||
|
|
@ -1278,11 +1359,55 @@ class FolkTimebankApp extends HTMLElement {
|
|||
document.addEventListener('pointercancel', cancelHandler);
|
||||
}
|
||||
|
||||
// ── Connection/exec persistence ──
|
||||
|
||||
private applyRestoredConnections() {
|
||||
if (this._restoredConnections.length === 0) return;
|
||||
for (const rc of this._restoredConnections) {
|
||||
const fromNodeId = 'cn-' + rc.fromCommitmentId;
|
||||
const toNodeId = rc.toTaskId;
|
||||
const fNode = this.weaveNodes.find(n => n.id === fromNodeId);
|
||||
const tNode = this.weaveNodes.find(n => n.id === toNodeId);
|
||||
if (!fNode || !tNode) continue;
|
||||
if (this.connections.find(c => c.from === fromNodeId && c.to === toNodeId)) continue;
|
||||
this.connections.push({ from: fromNodeId, to: toNodeId, skill: rc.skill });
|
||||
if (!tNode.data.fulfilled) tNode.data.fulfilled = {};
|
||||
tNode.data.fulfilled[rc.skill] = (tNode.data.fulfilled[rc.skill] || 0) + (fNode.data.hours || 0);
|
||||
}
|
||||
this._restoredConnections = [];
|
||||
this.renderAll();
|
||||
this.rebuildSidebar();
|
||||
}
|
||||
|
||||
private async persistConnection(from: string, to: string, skill: string) {
|
||||
const fromCommitmentId = from.replace(/^cn-/, '');
|
||||
try {
|
||||
await fetch(`${this.getApiBase()}/api/connections`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
|
||||
body: JSON.stringify({ fromCommitmentId, toTaskId: to, skill }),
|
||||
});
|
||||
} catch { /* offline */ }
|
||||
}
|
||||
|
||||
private async persistExecState(taskId: string) {
|
||||
const states = this.execStepStates[taskId];
|
||||
if (!states) return;
|
||||
try {
|
||||
await fetch(`${this.getApiBase()}/api/tasks/${taskId}/exec-state`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
|
||||
body: JSON.stringify({ steps: states }),
|
||||
});
|
||||
} catch { /* offline */ }
|
||||
}
|
||||
|
||||
// ── Exec panel ──
|
||||
|
||||
private openExecPanel(taskId: string) {
|
||||
const node = this.weaveNodes.find(n => n.id === taskId);
|
||||
if (!node || node.type !== 'task') return;
|
||||
this._currentExecTaskId = taskId;
|
||||
|
||||
const t = node.data as TaskData;
|
||||
this.shadow.getElementById('execTitle')!.innerHTML = t.name + ' <span class="exec-panel-badge">All Commitments Woven</span>';
|
||||
|
|
@ -1293,7 +1418,7 @@ class FolkTimebankApp extends HTMLElement {
|
|||
return cn ? cn.data.memberName : '';
|
||||
}).filter(Boolean);
|
||||
|
||||
const steps = EXEC_STEPS[taskId] || EXEC_STEPS.default;
|
||||
const steps = EXEC_STEPS.default;
|
||||
if (!this.execStepStates[taskId]) {
|
||||
this.execStepStates[taskId] = {};
|
||||
steps.forEach((_, i) => { this.execStepStates[taskId][i] = 'pending'; });
|
||||
|
|
@ -1304,6 +1429,91 @@ class FolkTimebankApp extends HTMLElement {
|
|||
this.shadow.getElementById('execOverlay')!.classList.add('visible');
|
||||
}
|
||||
|
||||
private renderStepDetail(type: string, members: string[], taskId: string, stepIdx: number): string {
|
||||
const memberOpts = members.map(m => `<option value="${m}">${m}</option>`).join('');
|
||||
switch (type) {
|
||||
case 'venue':
|
||||
return `<div class="exec-step-detail">
|
||||
<div class="exec-step-field"><label>Space Type</label>
|
||||
<select><option>In-person venue</option><option>Virtual room</option><option>Hybrid</option></select></div>
|
||||
<div class="exec-step-field"><label>Location</label><input type="text" placeholder="Address or meeting URL"></div>
|
||||
<div class="exec-step-field"><label>Date & Time</label><input type="datetime-local"></div>
|
||||
<div class="exec-step-field"><label>Space Lead</label><select><option value="">Select…</option>${memberOpts}</select></div>
|
||||
<button class="exec-step-action" data-complete-step="${stepIdx}" data-task-id="${taskId}">Confirm Setup</button>
|
||||
</div>`;
|
||||
case 'comms':
|
||||
return `<div class="exec-step-detail">
|
||||
<div class="exec-step-field"><label>Channels</label>
|
||||
<div class="exec-step-checklist">
|
||||
<label><input type="checkbox" checked> Group Chat</label>
|
||||
<label><input type="checkbox"> Email Thread</label>
|
||||
<label><input type="checkbox"> Signal Group</label>
|
||||
</div></div>
|
||||
<div class="exec-step-field"><label>Invite List</label>
|
||||
<div class="exec-step-invite-list">${members.map(m => `<span class="invite-tag">${m}</span>`).join('')}</div></div>
|
||||
<div class="exec-step-field"><label>Comms Lead</label><select><option value="">Select…</option>${memberOpts}</select></div>
|
||||
<button class="exec-step-action" data-complete-step="${stepIdx}" data-task-id="${taskId}">Set Up Channels</button>
|
||||
</div>`;
|
||||
case 'notes':
|
||||
return `<div class="exec-step-detail">
|
||||
<div class="exec-step-field"><label>Document Type</label>
|
||||
<select><option>Shared Agenda</option><option>Meeting Notes</option><option>Project Brief</option></select></div>
|
||||
<div class="exec-step-field"><label>Initial Sections</label>
|
||||
<div class="exec-step-checklist">
|
||||
<label><input type="checkbox" checked> Objectives</label>
|
||||
<label><input type="checkbox" checked> Agenda</label>
|
||||
<label><input type="checkbox"> Resources</label>
|
||||
<label><input type="checkbox"> Action Items</label>
|
||||
</div></div>
|
||||
<div class="exec-step-field"><label>Drive Link</label><input type="url" placeholder="https://…"></div>
|
||||
<button class="exec-step-action" data-complete-step="${stepIdx}" data-task-id="${taskId}">Create Document</button>
|
||||
</div>`;
|
||||
case 'prep':
|
||||
return `<div class="exec-step-detail">
|
||||
<div class="exec-step-field"><label>Transcript / Notes</label><textarea rows="3" placeholder="Paste transcript or notes…"></textarea></div>
|
||||
<div class="exec-step-field"><label>Processing Mode</label>
|
||||
<select><option>Summarize</option><option>Extract Action Items</option><option>Generate Brief</option></select></div>
|
||||
<button class="exec-step-action" data-complete-step="${stepIdx}" data-task-id="${taskId}">Process Input</button>
|
||||
</div>`;
|
||||
case 'launch':
|
||||
return `<div class="exec-step-detail">
|
||||
<div class="exec-step-field"><label>Pre-Launch Checklist</label>
|
||||
<div class="exec-step-checklist">
|
||||
<label><input type="checkbox"> All roles confirmed</label>
|
||||
<label><input type="checkbox"> Venue / link confirmed</label>
|
||||
<label><input type="checkbox"> Comms channels active</label>
|
||||
<label><input type="checkbox"> Shared docs ready</label>
|
||||
<label><input type="checkbox"> Notifications sent</label>
|
||||
</div></div>
|
||||
<button class="exec-step-action launch-action" data-complete-step="${stepIdx}" data-task-id="${taskId}">Confirm Launch</button>
|
||||
</div>`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private completeExecStep(taskId: string, stepIdx: number) {
|
||||
const states = this.execStepStates[taskId];
|
||||
if (!states || states[stepIdx] === 'done') return;
|
||||
states[stepIdx] = 'done';
|
||||
// Auto-advance to next pending step
|
||||
const steps = EXEC_STEPS.default;
|
||||
let nextPending = -1;
|
||||
for (let j = 0; j < steps.length; j++) {
|
||||
if (states[j] !== 'done') { nextPending = j; break; }
|
||||
}
|
||||
if (nextPending >= 0) {
|
||||
Object.keys(states).forEach(k => { if (states[Number(k)] === 'active') states[Number(k)] = 'pending'; });
|
||||
states[nextPending] = 'active';
|
||||
}
|
||||
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);
|
||||
this.renderExecSteps(taskId, steps, members);
|
||||
this.persistExecState(taskId);
|
||||
}
|
||||
|
||||
private renderExecSteps(taskId: string, steps: typeof EXEC_STEPS['default'], members: string[]) {
|
||||
const container = this.shadow.getElementById('execSteps')!;
|
||||
container.innerHTML = '';
|
||||
|
|
@ -1321,38 +1531,104 @@ class FolkTimebankApp extends HTMLElement {
|
|||
<div class="exec-step-content">
|
||||
<div class="exec-step-title">${step.title}</div>
|
||||
<div class="exec-step-desc">${step.desc}</div>
|
||||
${state === 'active' ? this.renderStepDetail(step.detail, members, taskId, i) : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
div.addEventListener('click', () => {
|
||||
div.addEventListener('click', (e: MouseEvent) => {
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || tag === 'BUTTON') return;
|
||||
if ((e.target as HTMLElement).closest('[data-complete-step]')) return;
|
||||
if (states[i] === 'done') return;
|
||||
if (states[i] === 'active') {
|
||||
states[i] = 'done';
|
||||
states[i] = 'pending';
|
||||
} 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);
|
||||
this.persistExecState(taskId);
|
||||
});
|
||||
|
||||
container.appendChild(div);
|
||||
});
|
||||
|
||||
// Bind action buttons via event delegation
|
||||
container.querySelectorAll('[data-complete-step]').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const idx = parseInt((btn as HTMLElement).dataset.completeStep!);
|
||||
this.completeExecStep(taskId, idx);
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ── Launch handler ──
|
||||
|
||||
private async handleExecLaunch() {
|
||||
const btn = this.shadow.getElementById('execLaunch') as HTMLButtonElement;
|
||||
const taskId = this._currentExecTaskId;
|
||||
|
||||
// Check if connected commitments have cyclosMemberId
|
||||
const connectedCommitments = taskId
|
||||
? this.connections.filter(c => c.to === taskId).map(c => {
|
||||
const cn = this.weaveNodes.find(n => n.id === c.from);
|
||||
return cn?.data;
|
||||
}).filter(Boolean)
|
||||
: [];
|
||||
|
||||
const cyclosCommitments = connectedCommitments.filter(c => {
|
||||
const member = this._cyclosMembers.find(m => m.name === c.memberName);
|
||||
return member?.id;
|
||||
});
|
||||
|
||||
if (cyclosCommitments.length === 0 || this._cyclosMembers.length === 0) {
|
||||
// Demo mode — celebration
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cyclos mode
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Processing…';
|
||||
let successes = 0, failures = 0;
|
||||
|
||||
for (const c of cyclosCommitments) {
|
||||
const member = this._cyclosMembers.find(m => m.name === c.memberName);
|
||||
if (!member) { failures++; continue; }
|
||||
try {
|
||||
const resp = await fetch(`${this.getApiBase()}/api/cyclos/commitments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
|
||||
body: JSON.stringify({ fromUserId: member.id, amount: c.hours, description: `${c.skill}: ${c.desc || 'commitment'}` }),
|
||||
});
|
||||
if (resp.ok) successes++; else failures++;
|
||||
} catch { failures++; }
|
||||
}
|
||||
|
||||
if (failures > 0 && successes === 0) {
|
||||
btn.textContent = `Failed (${failures} errors)`;
|
||||
btn.style.background = '#ef4444';
|
||||
} else {
|
||||
btn.textContent = failures > 0 ? `Launched (${successes} ok, ${failures} failed)` : 'Launched!';
|
||||
btn.style.background = 'linear-gradient(135deg, #8b5cf6, #ec4899)';
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.shadow.getElementById('execOverlay')!.classList.remove('visible');
|
||||
btn.textContent = 'Launch Project'; btn.style.background = ''; btn.disabled = false;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// ── Task editor ──
|
||||
|
||||
private editingTaskNode: WeaveNode | null = null;
|
||||
|
|
@ -1364,15 +1640,56 @@ class FolkTimebankApp extends HTMLElement {
|
|||
(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.renderTaskEditLinks(t.links || []);
|
||||
this.shadow.getElementById('taskEditOverlay')!.classList.add('visible');
|
||||
}
|
||||
|
||||
private renderTaskEditLinks(links: { label: string; url: string }[]) {
|
||||
const container = this.shadow.getElementById('taskEditLinksContainer')!;
|
||||
container.innerHTML = '';
|
||||
links.forEach((link, i) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'task-edit-link-row';
|
||||
row.innerHTML = `<input type="text" class="link-label" placeholder="Label" value="${link.label || ''}">
|
||||
<input type="url" class="link-url" placeholder="https://…" value="${link.url || ''}">
|
||||
<button class="task-edit-link-rm" data-link-idx="${i}">×</button>`;
|
||||
container.appendChild(row);
|
||||
});
|
||||
container.querySelectorAll('.task-edit-link-rm').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const idx = parseInt((btn as HTMLElement).dataset.linkIdx!);
|
||||
const current = this.getTaskEditLinks();
|
||||
current.splice(idx, 1);
|
||||
this.renderTaskEditLinks(current);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private getTaskEditLinks(): { label: string; url: string }[] {
|
||||
const container = this.shadow.getElementById('taskEditLinksContainer')!;
|
||||
const rows = container.querySelectorAll('.task-edit-link-row');
|
||||
const links: { label: string; url: string }[] = [];
|
||||
rows.forEach(row => {
|
||||
const label = (row.querySelector('.link-label') as HTMLInputElement).value.trim();
|
||||
const url = (row.querySelector('.link-url') as HTMLInputElement).value.trim();
|
||||
if (label || url) links.push({ label, url });
|
||||
});
|
||||
return links;
|
||||
}
|
||||
|
||||
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();
|
||||
t.links = this.getTaskEditLinks();
|
||||
// Persist to server
|
||||
fetch(`${this.getApiBase()}/api/tasks/${t.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
|
||||
body: JSON.stringify({ name: t.name, description: t.description, notes: t.notes, links: t.links }),
|
||||
}).catch(() => {});
|
||||
this.renderAll();
|
||||
this.shadow.getElementById('taskEditOverlay')!.classList.remove('visible');
|
||||
this.editingTaskNode = null;
|
||||
|
|
@ -2264,6 +2581,89 @@ const CSS_TEXT = `
|
|||
.price-skill { font-size: 0.78rem; color: #94a3b8; }
|
||||
.price-value { font-size: 0.82rem; font-weight: 600; color: #f1f5f9; margin-left: 0.25rem; }
|
||||
|
||||
/* Theme toggle */
|
||||
.theme-toggle {
|
||||
margin-left: auto; padding: 0.4rem 0.75rem;
|
||||
background: transparent; border: 1px solid #334155; border-radius: 0.375rem;
|
||||
color: #94a3b8; font-size: 1rem; cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.theme-toggle:hover { border-color: #8b5cf6; color: #e2e8f0; }
|
||||
|
||||
/* 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"]) .stat { color: #64748b; }
|
||||
:host([data-theme="light"]) .stat-value { color: #1e293b; }
|
||||
:host([data-theme="light"]) .sidebar { background: #fff; border-right-color: #e2e8f0; }
|
||||
:host([data-theme="light"]) .sidebar-header { color: #64748b; border-bottom-color: #e2e8f0; }
|
||||
:host([data-theme="light"]) .sidebar-item:hover { background: #f1f5f9; }
|
||||
:host([data-theme="light"]) .sidebar-item-name { color: #1e293b; }
|
||||
:host([data-theme="light"]) .canvas-wrap { background: #f8fafc; }
|
||||
:host([data-theme="light"]) .node-rect { fill: #fff; stroke: #e2e8f0; }
|
||||
:host([data-theme="light"]) .theme-toggle { border-color: #e2e8f0; color: #64748b; }
|
||||
|
||||
/* Hex hover stroke */
|
||||
.hex-hover-stroke { transition: stroke-width 0.15s; }
|
||||
.node-group:hover .hex-hover-stroke { stroke-width: 2; }
|
||||
|
||||
/* Task editor links */
|
||||
.task-edit-link-row {
|
||||
display: flex; gap: 0.4rem; margin-bottom: 0.4rem; align-items: center;
|
||||
}
|
||||
.task-edit-link-row input { flex: 1; }
|
||||
.task-edit-link-row .link-label { max-width: 120px; }
|
||||
.task-edit-link-rm {
|
||||
width: 28px; height: 28px; border: 1px solid #475569; border-radius: 0.25rem;
|
||||
background: #0f172a; color: #ef4444; font-size: 1rem; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.task-edit-link-rm:hover { background: #1e293b; }
|
||||
.task-edit-add-link {
|
||||
padding: 0.35rem 0.75rem; border: 1px dashed #475569; border-radius: 0.375rem;
|
||||
background: transparent; color: #8b5cf6; font-size: 0.78rem; font-weight: 500;
|
||||
cursor: pointer; margin-top: 0.35rem; transition: border-color 0.15s;
|
||||
}
|
||||
.task-edit-add-link:hover { border-color: #8b5cf6; }
|
||||
|
||||
/* Exec step detail forms */
|
||||
.exec-step-detail {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: #0f172a;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
.exec-step-field { margin-bottom: 0.6rem; }
|
||||
.exec-step-field label { display: block; font-size: 0.75rem; font-weight: 500; color: #94a3b8; margin-bottom: 0.2rem; }
|
||||
.exec-step-field input, .exec-step-field select, .exec-step-field textarea {
|
||||
width: 100%; padding: 0.4rem 0.6rem; border: 1px solid #475569; border-radius: 0.375rem;
|
||||
font-size: 0.82rem; background: #1e293b; color: #e2e8f0; outline: none; font-family: inherit;
|
||||
}
|
||||
.exec-step-field input:focus, .exec-step-field select:focus, .exec-step-field textarea:focus { border-color: #8b5cf6; }
|
||||
.exec-step-field textarea { resize: vertical; min-height: 50px; }
|
||||
.exec-step-action {
|
||||
margin-top: 0.5rem; padding: 0.45rem 1rem;
|
||||
background: linear-gradient(135deg, #8b5cf6, #ec4899); color: #fff; border: none;
|
||||
border-radius: 0.375rem; font-size: 0.8rem; font-weight: 600; cursor: pointer;
|
||||
transition: opacity 0.15s; width: 100%;
|
||||
}
|
||||
.exec-step-action:hover { opacity: 0.85; }
|
||||
.exec-step-action.launch-action { background: linear-gradient(135deg, #10b981, #059669); }
|
||||
.exec-step-invite-list { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.25rem; }
|
||||
.invite-tag {
|
||||
font-size: 0.72rem; padding: 0.15rem 0.5rem; border-radius: 1rem;
|
||||
background: rgba(139,92,246,0.15); color: #a78bfa;
|
||||
}
|
||||
.exec-step-checklist { display: flex; flex-direction: column; gap: 0.3rem; margin-top: 0.25rem; }
|
||||
.exec-step-checklist label {
|
||||
display: flex; align-items: center; gap: 0.4rem; font-size: 0.8rem; color: #e2e8f0; cursor: pointer;
|
||||
}
|
||||
.exec-step-checklist input[type="checkbox"] { accent-color: #8b5cf6; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { width: 200px; }
|
||||
.exec-panel { width: 95vw; }
|
||||
|
|
|
|||
|
|
@ -100,12 +100,17 @@ export function createIntentRoutes(getSyncServer: () => SyncServer | null): Hono
|
|||
return doc;
|
||||
}
|
||||
|
||||
function truncateDid(did: string): string {
|
||||
if (!did || did.length < 20) return did;
|
||||
return did.slice(0, 12) + '…' + did.slice(-6);
|
||||
}
|
||||
|
||||
async function requireAuth(headers: Headers): Promise<{ did: string; username: string } | null> {
|
||||
const token = extractToken(headers);
|
||||
if (!token) return null;
|
||||
try {
|
||||
const claims = await verifyToken(token);
|
||||
return { did: claims.sub, username: (claims as any).username || claims.sub };
|
||||
return { did: claims.sub, username: (claims as any).username || truncateDid(claims.sub) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue