Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-15 17:04:15 -07:00
commit 51fcf93df7
5 changed files with 501 additions and 28 deletions

View File

@ -185,6 +185,15 @@ class FolkFlowsApp extends HTMLElement {
private collectiveAllocations: { segmentId: string; avgPercentage: number; participantCount: number }[] = []; private collectiveAllocations: { segmentId: string; avgPercentage: number; participantCount: number }[] = [];
private budgetTotalAmount = 0; private budgetTotalAmount = 0;
private budgetParticipantCount = 0; private budgetParticipantCount = 0;
private selectedBudgetSegment: string | null = null;
private _budgetLfcUnsub: (() => void) | null = null;
private budgetSaveTimer: ReturnType<typeof setTimeout> | null = null;
// Pie drag state
private _pieDragging = false;
private _pieDragBoundaryIdx = 0;
private _pieDragStartAngle = 0;
private _pieDragStartPcts: number[] = [];
// Tour engine // Tour engine
private _tour!: TourEngine; private _tour!: TourEngine;
@ -223,7 +232,7 @@ class FolkFlowsApp extends HTMLElement {
this.view = viewAttr === "budgets" ? "budgets" : viewAttr === "mortgage" ? "mortgage" : "detail"; this.view = viewAttr === "budgets" ? "budgets" : viewAttr === "mortgage" ? "mortgage" : "detail";
if (this.view === "budgets") { if (this.view === "budgets") {
this.loadBudgetData(); this.initBudgetView();
return; return;
} }
@ -351,7 +360,10 @@ class FolkFlowsApp extends HTMLElement {
this._offlineUnsub = null; this._offlineUnsub = null;
this._lfcUnsub?.(); this._lfcUnsub?.();
this._lfcUnsub = null; this._lfcUnsub = null;
this._budgetLfcUnsub?.();
this._budgetLfcUnsub = null;
if (this.saveTimer) { clearTimeout(this.saveTimer); this.saveTimer = null; } if (this.saveTimer) { clearTimeout(this.saveTimer); this.saveTimer = null; }
if (this.budgetSaveTimer) { clearTimeout(this.budgetSaveTimer); this.budgetSaveTimer = null; }
this.localFirstClient?.disconnect(); this.localFirstClient?.disconnect();
} }
@ -5496,6 +5508,7 @@ class FolkFlowsApp extends HTMLElement {
const segId = (slider as HTMLElement).dataset.budgetSlider!; const segId = (slider as HTMLElement).dataset.budgetSlider!;
this.myAllocation[segId] = parseInt((e.target as HTMLInputElement).value) || 0; this.myAllocation[segId] = parseInt((e.target as HTMLInputElement).value) || 0;
this.normalizeBudgetSliders(segId); this.normalizeBudgetSliders(segId);
this.scheduleBudgetSave();
this.render(); this.render();
}); });
}); });
@ -5517,6 +5530,23 @@ class FolkFlowsApp extends HTMLElement {
} }
}); });
}); });
this.shadow.querySelector('[data-budget-action="apply-flow"]')?.addEventListener('click', () => {
this.applyBudgetToFlow();
});
// Legend/table row click to select segment
this.shadow.querySelectorAll('[data-pie-legend]').forEach((el) => {
el.addEventListener('click', (e) => {
// Don't intercept remove button clicks
if ((e.target as HTMLElement).dataset.budgetRemove) return;
const segId = (el as HTMLElement).dataset.pieLegend!;
this.selectedBudgetSegment = this.selectedBudgetSegment === segId ? null : segId;
this.render();
});
});
this.attachPieListeners();
} }
} }
@ -5587,6 +5617,205 @@ class FolkFlowsApp extends HTMLElement {
// ─── Budget tab ───────────────────────────────────────── // ─── Budget tab ─────────────────────────────────────────
private async initBudgetView() {
if (!this.isDemo && isAuthenticated()) {
// Multiplayer: init local-first client and sync budget state
try {
this.localFirstClient = new FlowsLocalFirstClient(this.space);
await this.localFirstClient.init();
await this.localFirstClient.subscribe();
this._budgetLfcUnsub = this.localFirstClient.onChange((doc) => {
this.extractBudgetState(doc);
this.render();
});
// Initial extraction from doc
const doc = this.localFirstClient.getFlows();
if (doc) this.extractBudgetState(doc);
} catch (err) {
console.warn('[rBudgets] Local-first init failed, falling back to API:', err);
}
}
// Fall back to API / demo data if no segments loaded from local-first
if (this.budgetSegments.length === 0) {
await this.loadBudgetData();
} else {
this.loading = false;
this.render();
}
}
private extractBudgetState(doc: FlowsDoc) {
// Extract segments
const segments: BudgetSegment[] = [];
if (doc.budgetSegments) {
for (const [id, seg] of Object.entries(doc.budgetSegments)) {
segments.push({ id, name: seg.name, color: seg.color, createdBy: seg.createdBy });
}
}
if (segments.length > 0) this.budgetSegments = segments;
// Extract total amount
if (doc.budgetTotalAmount) this.budgetTotalAmount = doc.budgetTotalAmount;
// Extract allocations and compute collective averages
if (doc.budgetAllocations) {
const allAllocations = Object.values(doc.budgetAllocations);
this.budgetParticipantCount = allAllocations.length;
// My allocation
const session = getSession();
if (session) {
const myDid = (session.claims as any).did || session.claims.sub;
const myAlloc = allAllocations.find(a => a.participantDid === myDid);
if (myAlloc && !this._pieDragging && !this.budgetSaveTimer) {
// Only update from remote if not currently editing
this.myAllocation = { ...myAlloc.allocations };
}
}
// Collective averages
const segIds = this.budgetSegments.map(s => s.id);
this.collectiveAllocations = segIds.map(segId => {
const values = allAllocations
.map(a => a.allocations[segId] || 0)
.filter(v => v > 0 || allAllocations.length > 0);
const count = allAllocations.length;
const avg = count > 0 ? values.reduce((s, v) => s + v, 0) / count : 0;
return { segmentId: segId, avgPercentage: avg, participantCount: count };
});
}
}
private scheduleBudgetSave() {
if (this.budgetSaveTimer) clearTimeout(this.budgetSaveTimer);
this.budgetSaveTimer = setTimeout(() => {
this.budgetSaveTimer = null;
if (this.localFirstClient) {
const session = getSession();
if (session) {
const myDid = (session.claims as any).did || session.claims.sub;
this.localFirstClient.saveBudgetAllocation(myDid, this.myAllocation);
}
} else {
// Fallback: save via API
this.saveBudgetAllocation();
}
}, 1000);
}
private applyBudgetToFlow() {
if (!this.localFirstClient) {
alert('Connect to a space to apply budgets to a flow.');
return;
}
const collectiveData = this.collectiveAllocations.filter(c => c.avgPercentage > 0);
if (collectiveData.length === 0) {
alert('No collective allocation data to apply.');
return;
}
// Get or create active canvas flow
let activeId = this.localFirstClient.getActiveFlowId();
const flows = this.localFirstClient.listCanvasFlows();
if (!activeId && flows.length > 0) {
activeId = flows[0].id;
}
if (!activeId) {
// Create a new flow for budget
const flowId = 'budget-flow-' + Date.now();
const newFlow: CanvasFlow = {
id: flowId,
name: 'Budget Allocation Flow',
nodes: [],
createdAt: Date.now(),
updatedAt: Date.now(),
createdBy: getSession()?.claims?.sub || null,
};
this.localFirstClient.saveCanvasFlow(newFlow);
this.localFirstClient.setActiveFlow(flowId);
activeId = flowId;
}
const flow = this.localFirstClient.getCanvasFlow(activeId);
if (!flow) return;
const nodes: FlowNode[] = [...flow.nodes.map(n => ({ ...n, data: { ...n.data } }))];
// Find or create funnel node for budget pool
let funnelNode = nodes.find(n => n.type === 'funnel' && (n.data as FunnelNodeData).label.includes('Budget'));
if (!funnelNode) {
funnelNode = {
id: 'budget-funnel-' + Date.now(),
type: 'funnel',
position: { x: 400, y: 100 },
data: {
label: 'Budget Pool',
currentValue: this.budgetTotalAmount,
minThreshold: this.budgetTotalAmount * 0.2,
maxThreshold: this.budgetTotalAmount * 0.8,
maxCapacity: this.budgetTotalAmount,
inflowRate: 0,
overflowAllocations: [],
spendingAllocations: [],
} as FunnelNodeData,
};
nodes.push(funnelNode);
}
const funnelData = funnelNode.data as FunnelNodeData;
const colors = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#ec4899', '#14b8a6', '#f97316'];
// Create/update outcome nodes for each segment + build spending allocations
const spendingAllocations: SpendingAllocation[] = [];
let outcomeX = 200;
for (let i = 0; i < collectiveData.length; i++) {
const c = collectiveData[i];
const seg = this.budgetSegments.find(s => s.id === c.segmentId);
if (!seg) continue;
// Find or create outcome node
let outcomeNode = nodes.find(n => n.type === 'outcome' && (n.data as OutcomeNodeData).label === seg.name);
if (!outcomeNode) {
outcomeNode = {
id: 'budget-outcome-' + seg.id,
type: 'outcome',
position: { x: outcomeX, y: 350 },
data: {
label: seg.name,
description: `Budget allocation for ${seg.name}`,
fundingReceived: 0,
fundingTarget: Math.round(this.budgetTotalAmount * c.avgPercentage / 100),
status: 'not-started',
} as OutcomeNodeData,
};
nodes.push(outcomeNode);
} else {
// Update target
(outcomeNode.data as OutcomeNodeData).fundingTarget = Math.round(this.budgetTotalAmount * c.avgPercentage / 100);
}
spendingAllocations.push({
targetId: outcomeNode.id,
percentage: c.avgPercentage,
color: seg.color || colors[i % colors.length],
});
outcomeX += 200;
}
funnelData.spendingAllocations = spendingAllocations;
this.localFirstClient.updateFlowNodes(activeId, nodes);
alert(`Applied ${collectiveData.length} budget segments to flow "${flow.name}". Switch to canvas view to see the result.`);
}
private async loadBudgetData() { private async loadBudgetData() {
this.loading = true; this.loading = true;
this.render(); this.render();
@ -5650,6 +5879,13 @@ class FolkFlowsApp extends HTMLElement {
const session = getSession(); const session = getSession();
if (!session) return; if (!session) return;
if (this.localFirstClient) {
const myDid = (session.claims as any).did || session.claims.sub;
this.localFirstClient.saveBudgetAllocation(myDid, this.myAllocation);
return;
}
// API fallback
const base = this.getApiBase(); const base = this.getApiBase();
try { try {
await fetch(`${base}/api/budgets/allocate`, { await fetch(`${base}/api/budgets/allocate`, {
@ -5657,7 +5893,6 @@ class FolkFlowsApp extends HTMLElement {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` }, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },
body: JSON.stringify({ space: this.space, allocations: this.myAllocation }), body: JSON.stringify({ space: this.space, allocations: this.myAllocation }),
}); });
// Reload to get updated collective
await this.loadBudgetData(); await this.loadBudgetData();
} catch (err) { } catch (err) {
console.error('[rBudgets] Save failed:', err); console.error('[rBudgets] Save failed:', err);
@ -5665,13 +5900,21 @@ class FolkFlowsApp extends HTMLElement {
} }
private async addBudgetSegment() { private async addBudgetSegment() {
const session = getSession();
const name = prompt('Segment name:'); const name = prompt('Segment name:');
if (!name) return; if (!name) return;
const colors = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#ec4899', '#14b8a6', '#f97316']; const colors = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#ec4899', '#14b8a6', '#f97316'];
const color = colors[this.budgetSegments.length % colors.length]; const color = colors[this.budgetSegments.length % colors.length];
const id = 'seg-' + Date.now();
if (this.localFirstClient) {
const session = getSession();
this.localFirstClient.addBudgetSegment(id, name, color, session ? ((session.claims as any).did || session.claims.sub) : null);
this.myAllocation[id] = 0;
return; // onChange will re-render
}
const session = getSession();
if (session) { if (session) {
const base = this.getApiBase(); const base = this.getApiBase();
try { try {
@ -5686,15 +5929,21 @@ class FolkFlowsApp extends HTMLElement {
} }
// Local-only fallback for demo // Local-only fallback for demo
const id = 'seg-' + Date.now();
this.budgetSegments.push({ id, name, color, createdBy: null }); this.budgetSegments.push({ id, name, color, createdBy: null });
this.myAllocation[id] = 0; this.myAllocation[id] = 0;
this.render(); this.render();
} }
private async removeBudgetSegment(segmentId: string) { private async removeBudgetSegment(segmentId: string) {
const session = getSession(); if (this.localFirstClient) {
this.localFirstClient.removeBudgetSegment(segmentId);
delete this.myAllocation[segmentId];
if (this.selectedBudgetSegment === segmentId) this.selectedBudgetSegment = null;
this.normalizeBudgetSliders();
return; // onChange will re-render
}
const session = getSession();
if (session) { if (session) {
const base = this.getApiBase(); const base = this.getApiBase();
try { try {
@ -5712,6 +5961,7 @@ class FolkFlowsApp extends HTMLElement {
this.budgetSegments = this.budgetSegments.filter((s) => s.id !== segmentId); this.budgetSegments = this.budgetSegments.filter((s) => s.id !== segmentId);
delete this.myAllocation[segmentId]; delete this.myAllocation[segmentId];
this.collectiveAllocations = this.collectiveAllocations.filter((c) => c.segmentId !== segmentId); this.collectiveAllocations = this.collectiveAllocations.filter((c) => c.segmentId !== segmentId);
if (this.selectedBudgetSegment === segmentId) this.selectedBudgetSegment = null;
this.normalizeBudgetSliders(); this.normalizeBudgetSliders();
this.render(); this.render();
} }
@ -5764,6 +6014,108 @@ class FolkFlowsApp extends HTMLElement {
} }
} }
private attachPieListeners() {
const svg = this.shadow.getElementById('budget-pie-svg') as SVGSVGElement | null;
if (!svg) return;
const size = svg.viewBox.baseVal.width;
const cx = size / 2, cy = size / 2;
// Click on wedge to select
svg.addEventListener('click', (e) => {
const target = e.target as SVGElement;
const segId = target.getAttribute('data-pie-segment');
if (segId) {
this.selectedBudgetSegment = this.selectedBudgetSegment === segId ? null : segId;
this.render();
// Scroll slider into view
if (this.selectedBudgetSegment) {
const slider = this.shadow.querySelector(`[data-budget-slider="${this.selectedBudgetSegment}"]`) as HTMLElement;
slider?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
});
// Drag on boundary to resize adjacent segments
svg.addEventListener('pointerdown', (e) => {
const target = e.target as SVGElement;
const boundaryIdx = target.getAttribute('data-pie-boundary');
if (boundaryIdx === null) return;
e.preventDefault();
(target as Element).setPointerCapture(e.pointerId);
this._pieDragging = true;
this._pieDragBoundaryIdx = parseInt(boundaryIdx);
// Store starting angle from pointer position
const rect = svg.getBoundingClientRect();
const px = (e.clientX - rect.left) * (size / rect.width);
const py = (e.clientY - rect.top) * (size / rect.height);
this._pieDragStartAngle = Math.atan2(py - cy, px - cx);
// Store starting percentages for all segments
const segIds = this.budgetSegments.map(s => s.id);
this._pieDragStartPcts = segIds.map(id => this.myAllocation[id] || 0);
svg.style.cursor = 'grabbing';
});
svg.addEventListener('pointermove', (e) => {
if (!this._pieDragging) return;
e.preventDefault();
const rect = svg.getBoundingClientRect();
const px = (e.clientX - rect.left) * (size / rect.width);
const py = (e.clientY - rect.top) * (size / rect.height);
const currentAngle = Math.atan2(py - cy, px - cx);
let deltaAngle = currentAngle - this._pieDragStartAngle;
// Normalize to [-PI, PI]
if (deltaAngle > Math.PI) deltaAngle -= 2 * Math.PI;
if (deltaAngle < -Math.PI) deltaAngle += 2 * Math.PI;
const deltaPct = (deltaAngle / (2 * Math.PI)) * 100;
const idx = this._pieDragBoundaryIdx;
const nextIdx = (idx + 1) % this.budgetSegments.length;
const segIds = this.budgetSegments.map(s => s.id);
let newA = this._pieDragStartPcts[idx] + deltaPct;
let newB = this._pieDragStartPcts[nextIdx] - deltaPct;
// Clamp both to [2, 98]
if (newA < 2) { newB += (newA - 2); newA = 2; }
if (newB < 2) { newA += (newB - 2); newB = 2; }
if (newA > 98) { newB -= (newA - 98); newA = 98; }
if (newB > 98) { newA -= (newB - 98); newB = 98; }
this.myAllocation[segIds[idx]] = Math.round(newA);
this.myAllocation[segIds[nextIdx]] = Math.round(newB);
// Normalize total to 100
this.normalizeBudgetSliders();
// Re-render pie chart in-place for responsiveness
this.render();
});
svg.addEventListener('pointerup', () => {
if (!this._pieDragging) return;
this._pieDragging = false;
svg.style.cursor = '';
this.scheduleBudgetSave();
});
svg.addEventListener('lostpointercapture', () => {
if (this._pieDragging) {
this._pieDragging = false;
svg.style.cursor = '';
this.scheduleBudgetSave();
}
});
}
private renderPieChart(data: { id: string; label: string; value: number; color: string }[], size: number): string { private renderPieChart(data: { id: string; label: string; value: number; color: string }[], size: number): string {
const total = data.reduce((s, d) => s + d.value, 0); const total = data.reduce((s, d) => s + d.value, 0);
if (total === 0 || data.length === 0) { if (total === 0 || data.length === 0) {
@ -5777,8 +6129,11 @@ class FolkFlowsApp extends HTMLElement {
let currentAngle = -Math.PI / 2; // Start at top let currentAngle = -Math.PI / 2; // Start at top
const paths: string[] = []; const paths: string[] = [];
const labels: string[] = []; const labels: string[] = [];
const boundaries: string[] = [];
const boundaryAngles: number[] = []; // Angles at each boundary
for (const d of data) { for (let i = 0; i < data.length; i++) {
const d = data[i];
const pct = d.value / total; const pct = d.value / total;
if (pct <= 0) continue; if (pct <= 0) continue;
const angle = pct * Math.PI * 2; const angle = pct * Math.PI * 2;
@ -5787,8 +6142,10 @@ class FolkFlowsApp extends HTMLElement {
const x2 = cx + r * Math.cos(currentAngle + angle); const x2 = cx + r * Math.cos(currentAngle + angle);
const y2 = cy + r * Math.sin(currentAngle + angle); const y2 = cy + r * Math.sin(currentAngle + angle);
const largeArc = angle > Math.PI ? 1 : 0; const largeArc = angle > Math.PI ? 1 : 0;
const isSelected = this.selectedBudgetSegment === d.id;
const scale = isSelected ? 'transform="scale(1.04)" transform-origin="' + cx + ' ' + cy + '"' : '';
paths.push(`<path d="M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} Z" fill="${d.color}" stroke="var(--rs-bg, #0a0a1a)" stroke-width="2" opacity="0.9" data-segment="${d.id}"> paths.push(`<path d="M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} Z" fill="${d.color}" stroke="${isSelected ? 'white' : 'var(--rs-bg, #0a0a1a)'}" stroke-width="${isSelected ? 3 : 2}" opacity="${isSelected ? 1 : 0.9}" data-pie-segment="${d.id}" style="cursor: pointer; transition: opacity 0.15s;" ${scale}>
<title>${d.label}: ${d.value.toFixed(1)}% ($${Math.round(this.budgetTotalAmount * d.value / 100).toLocaleString()})</title> <title>${d.label}: ${d.value.toFixed(1)}% ($${Math.round(this.budgetTotalAmount * d.value / 100).toLocaleString()})</title>
</path>`); </path>`);
@ -5803,6 +6160,15 @@ class FolkFlowsApp extends HTMLElement {
} }
currentAngle += angle; currentAngle += angle;
// Store boundary angle between this segment and next (for drag handles)
if (data.length > 1) {
boundaryAngles.push(currentAngle);
const bx = cx + r * Math.cos(currentAngle);
const by = cy + r * Math.sin(currentAngle);
// Invisible wider line as drag handle
boundaries.push(`<line x1="${cx}" y1="${cy}" x2="${bx}" y2="${by}" stroke="transparent" stroke-width="14" data-pie-boundary="${i}" style="cursor: grab;" />`);
}
} }
// Center label // Center label
@ -5811,8 +6177,9 @@ class FolkFlowsApp extends HTMLElement {
<text x="${cx}" y="${cy + 10}" text-anchor="middle" dominant-baseline="middle" fill="var(--rs-text-primary)" font-size="14" font-weight="700">${this.fmtUsd(this.budgetTotalAmount)}</text>` <text x="${cx}" y="${cy + 10}" text-anchor="middle" dominant-baseline="middle" fill="var(--rs-text-primary)" font-size="14" font-weight="700">${this.fmtUsd(this.budgetTotalAmount)}</text>`
: ''; : '';
return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" style="filter: drop-shadow(0 2px 8px rgba(0,0,0,0.3));"> return `<svg id="budget-pie-svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" style="filter: drop-shadow(0 2px 8px rgba(0,0,0,0.3)); touch-action: none;">
${paths.join('\n')} ${paths.join('\n')}
${boundaries.join('\n')}
${labels.join('\n')} ${labels.join('\n')}
<circle cx="${cx}" cy="${cy}" r="${r * 0.32}" fill="var(--rs-bg, #0a0a1a)" stroke="var(--rs-border, #2a2a3e)" stroke-width="1"/> <circle cx="${cx}" cy="${cy}" r="${r * 0.32}" fill="var(--rs-bg, #0a0a1a)" stroke="var(--rs-border, #2a2a3e)" stroke-width="1"/>
${centerLabel} ${centerLabel}
@ -5827,7 +6194,16 @@ class FolkFlowsApp extends HTMLElement {
return { id: c.segmentId, label: seg?.name || c.segmentId, value: c.avgPercentage, color: seg?.color || '#666' }; return { id: c.segmentId, label: seg?.name || c.segmentId, value: c.avgPercentage, color: seg?.color || '#666' };
}).filter((d) => d.value > 0); }).filter((d) => d.value > 0);
const myPieData = this.budgetSegments.map((seg) => ({
id: seg.id, label: seg.name, value: this.myAllocation[seg.id] || 0, color: seg.color,
})).filter((d) => d.value > 0);
const authenticated = isAuthenticated(); const authenticated = isAuthenticated();
const isLive = this.localFirstClient?.isConnected ?? false;
// Selected segment detail
const selSeg = this.selectedBudgetSegment ? this.budgetSegments.find(s => s.id === this.selectedBudgetSegment) : null;
const selCollective = selSeg ? this.collectiveAllocations.find(c => c.segmentId === selSeg.id) : null;
return ` return `
<div class="budget-tab" style="padding: 24px; max-width: 1200px; margin: 0 auto;"> <div class="budget-tab" style="padding: 24px; max-width: 1200px; margin: 0 auto;">
@ -5836,6 +6212,7 @@ class FolkFlowsApp extends HTMLElement {
<a href="/${this.space === 'demo' ? 'demo/' : ''}rflows" style="color: var(--rs-text-secondary); text-decoration: none; font-size: 14px;">&larr; Back to Flows</a> <a href="/${this.space === 'demo' ? 'demo/' : ''}rflows" style="color: var(--rs-text-secondary); text-decoration: none; font-size: 14px;">&larr; Back to Flows</a>
<h2 style="margin: 0; font-size: 24px; color: var(--rs-text-primary);">rBudgets</h2> <h2 style="margin: 0; font-size: 24px; color: var(--rs-text-primary);">rBudgets</h2>
<span style="background: rgba(99,102,241,0.15); color: #6366f1; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 600;">COLLECTIVE</span> <span style="background: rgba(99,102,241,0.15); color: #6366f1; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 600;">COLLECTIVE</span>
${isLive ? '<span style="display: inline-flex; align-items: center; gap: 4px; font-size: 11px; color: #10b981;"><span style="width: 6px; height: 6px; border-radius: 50%; background: #10b981; display: inline-block;"></span>LIVE</span>' : ''}
<span style="margin-left: auto; color: var(--rs-text-secondary); font-size: 13px;">${this.budgetParticipantCount} participant${this.budgetParticipantCount !== 1 ? 's' : ''}</span> <span style="margin-left: auto; color: var(--rs-text-secondary); font-size: 13px;">${this.budgetParticipantCount} participant${this.budgetParticipantCount !== 1 ? 's' : ''}</span>
</div> </div>
@ -5843,17 +6220,31 @@ class FolkFlowsApp extends HTMLElement {
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 24px;"> <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 24px;">
<!-- Collective Pie Chart --> <!-- Collective Pie Chart -->
<div style="background: var(--rs-surface, #1a1a2e); border-radius: 12px; padding: 24px; border: 1px solid var(--rs-border, #2a2a3e); display: flex; flex-direction: column; align-items: center;"> <div style="background: var(--rs-surface, #1a1a2e); border-radius: 12px; padding: 24px; border: 1px solid var(--rs-border, #2a2a3e); display: flex; flex-direction: column; align-items: center;">
<h3 style="margin: 0 0 16px; font-size: 16px; color: var(--rs-text-primary); width: 100%;">Collective Allocation</h3> <h3 style="margin: 0 0 4px; font-size: 16px; color: var(--rs-text-primary); width: 100%;">Collective Allocation</h3>
${this.renderPieChart(pieData, 280)} <p style="margin: 0 0 16px; font-size: 12px; color: var(--rs-text-secondary); width: 100%;">Click a wedge to select. Drag boundaries to adjust your allocation.</p>
${this.renderPieChart(myPieData.length > 0 ? myPieData : pieData, 280)}
<!-- Legend --> <!-- Legend -->
<div style="display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; justify-content: center;"> <div style="display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; justify-content: center;">
${pieData.map((d) => ` ${pieData.map((d) => `
<div style="display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--rs-text-secondary);"> <div style="display: flex; align-items: center; gap: 4px; font-size: 12px; color: ${this.selectedBudgetSegment === d.id ? 'var(--rs-text-primary)' : 'var(--rs-text-secondary)'}; cursor: pointer; ${this.selectedBudgetSegment === d.id ? 'font-weight: 600;' : ''}" data-pie-legend="${d.id}">
<div style="width: 10px; height: 10px; border-radius: 2px; background: ${d.color};"></div> <div style="width: 10px; height: 10px; border-radius: 2px; background: ${d.color};"></div>
${this.esc(d.label)}: ${d.value.toFixed(1)}% ${this.esc(d.label)}: ${d.value.toFixed(1)}%
</div> </div>
`).join('')} `).join('')}
</div> </div>
${selSeg ? `
<div style="margin-top: 12px; padding: 10px 14px; background: var(--rs-bg, #0a0a1a); border-radius: 8px; border: 1px solid ${selSeg.color}44; width: 100%;">
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 4px;">
<div style="width: 10px; height: 10px; border-radius: 2px; background: ${selSeg.color};"></div>
<span style="font-size: 14px; font-weight: 600; color: var(--rs-text-primary);">${this.esc(selSeg.name)}</span>
</div>
<div style="display: flex; gap: 16px; font-size: 12px; color: var(--rs-text-secondary);">
<span>Collective: ${(selCollective?.avgPercentage || 0).toFixed(1)}%</span>
<span>Your: ${this.myAllocation[selSeg.id] || 0}%</span>
<span>Voters: ${selCollective?.participantCount || 0}</span>
${this.budgetTotalAmount > 0 ? `<span>${this.fmtUsd(Math.round(this.budgetTotalAmount * (selCollective?.avgPercentage || 0) / 100))}</span>` : ''}
</div>
</div>` : ''}
</div> </div>
<!-- My Allocation Sliders --> <!-- My Allocation Sliders -->
@ -5867,8 +6258,9 @@ class FolkFlowsApp extends HTMLElement {
${this.budgetSegments.map((seg) => { ${this.budgetSegments.map((seg) => {
const val = this.myAllocation[seg.id] || 0; const val = this.myAllocation[seg.id] || 0;
const amount = this.budgetTotalAmount > 0 ? Math.round(this.budgetTotalAmount * val / 100) : 0; const amount = this.budgetTotalAmount > 0 ? Math.round(this.budgetTotalAmount * val / 100) : 0;
const isSelected = this.selectedBudgetSegment === seg.id;
return ` return `
<div style="display: flex; align-items: center; gap: 12px;"> <div style="display: flex; align-items: center; gap: 12px; padding: 4px 8px; border-radius: 8px; ${isSelected ? `background: ${seg.color}15; border: 1px solid ${seg.color}44;` : 'border: 1px solid transparent;'} transition: all 0.15s;" data-budget-slider-row="${seg.id}">
<div style="width: 10px; height: 10px; border-radius: 2px; background: ${seg.color}; flex-shrink: 0;"></div> <div style="width: 10px; height: 10px; border-radius: 2px; background: ${seg.color}; flex-shrink: 0;"></div>
<span style="font-size: 13px; color: var(--rs-text-primary); width: 90px; flex-shrink: 0;">${this.esc(seg.name)}</span> <span style="font-size: 13px; color: var(--rs-text-primary); width: 90px; flex-shrink: 0;">${this.esc(seg.name)}</span>
<input type="range" min="0" max="100" value="${val}" data-budget-slider="${seg.id}" <input type="range" min="0" max="100" value="${val}" data-budget-slider="${seg.id}"
@ -5881,9 +6273,10 @@ class FolkFlowsApp extends HTMLElement {
<div style="display: flex; gap: 8px; margin-top: 16px;"> <div style="display: flex; gap: 8px; margin-top: 16px;">
${authenticated ${authenticated
? `<button data-budget-action="save" style="flex: 1; padding: 10px; background: #6366f1; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 600;">Save Allocation</button>` ? `<button data-budget-action="save" style="flex: 1; padding: 10px; background: #6366f1; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 600;">${this.budgetSaveTimer ? 'Auto-saving...' : 'Save Allocation'}</button>`
: `<div style="flex: 1; padding: 10px; background: rgba(99,102,241,0.1); color: var(--rs-text-secondary); border-radius: 8px; text-align: center; font-size: 13px;">Sign in to save your allocation</div>` : `<div style="flex: 1; padding: 10px; background: rgba(99,102,241,0.1); color: var(--rs-text-secondary); border-radius: 8px; text-align: center; font-size: 13px;">Sign in to save your allocation</div>`
} }
${authenticated ? `<button data-budget-action="apply-flow" style="padding: 10px 16px; background: rgba(16,185,129,0.15); color: #10b981; border: 1px solid rgba(16,185,129,0.3); border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 600;" title="Push collective budget into active canvas flow">Apply to Flow</button>` : ''}
</div> </div>
</div> </div>
</div> </div>
@ -5900,7 +6293,7 @@ class FolkFlowsApp extends HTMLElement {
const pct = collective?.avgPercentage || 0; const pct = collective?.avgPercentage || 0;
const amount = this.budgetTotalAmount > 0 ? Math.round(this.budgetTotalAmount * pct / 100) : 0; const amount = this.budgetTotalAmount > 0 ? Math.round(this.budgetTotalAmount * pct / 100) : 0;
return ` return `
<div style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: var(--rs-bg, #0a0a1a); border-radius: 8px; border: 1px solid var(--rs-border, #2a2a3e);"> <div style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: var(--rs-bg, #0a0a1a); border-radius: 8px; border: 1px solid ${this.selectedBudgetSegment === seg.id ? seg.color : 'var(--rs-border, #2a2a3e)'}; cursor: pointer; transition: border-color 0.15s;" data-pie-legend="${seg.id}">
<div style="width: 12px; height: 12px; border-radius: 3px; background: ${seg.color};"></div> <div style="width: 12px; height: 12px; border-radius: 3px; background: ${seg.color};"></div>
<span style="font-size: 13px; color: var(--rs-text-primary);">${this.esc(seg.name)}</span> <span style="font-size: 13px; color: var(--rs-text-primary);">${this.esc(seg.name)}</span>
<span style="font-size: 11px; color: var(--rs-text-secondary);">${pct.toFixed(1)}%${amount > 0 ? ` / $${amount.toLocaleString()}` : ''}</span> <span style="font-size: 11px; color: var(--rs-text-secondary);">${pct.toFixed(1)}%${amount > 0 ? ` / $${amount.toLocaleString()}` : ''}</span>
@ -5930,8 +6323,9 @@ class FolkFlowsApp extends HTMLElement {
const pct = collective?.avgPercentage || 0; const pct = collective?.avgPercentage || 0;
const myPct = this.myAllocation[seg.id] || 0; const myPct = this.myAllocation[seg.id] || 0;
const amount = this.budgetTotalAmount > 0 ? Math.round(this.budgetTotalAmount * pct / 100) : 0; const amount = this.budgetTotalAmount > 0 ? Math.round(this.budgetTotalAmount * pct / 100) : 0;
const isSelected = this.selectedBudgetSegment === seg.id;
return ` return `
<tr style="border-bottom: 1px solid var(--rs-border, #2a2a3e);"> <tr style="border-bottom: 1px solid var(--rs-border, #2a2a3e); ${isSelected ? `background: ${seg.color}10;` : ''} cursor: pointer;" data-pie-legend="${seg.id}">
<td style="padding: 8px; color: var(--rs-text-primary);"> <td style="padding: 8px; color: var(--rs-text-primary);">
<div style="display: flex; align-items: center; gap: 6px;"> <div style="display: flex; align-items: center; gap: 6px;">
<div style="width: 8px; height: 8px; border-radius: 2px; background: ${seg.color};"></div> <div style="width: 8px; height: 8px; border-radius: 2px; background: ${seg.color};"></div>

View File

@ -154,6 +154,54 @@ export class FlowsLocalFirstClient {
return doc?.activeFlowId || ''; return doc?.activeFlowId || '';
} }
// ── Budget CRUD ──
saveBudgetAllocation(did: string, allocations: Record<string, number>): void {
const docId = flowsDocId(this.#space) as DocumentId;
this.#sync.change<FlowsDoc>(docId, `Save budget allocation`, (d) => {
if (!d.budgetAllocations) d.budgetAllocations = {} as any;
d.budgetAllocations[did] = {
participantDid: did,
allocations,
updatedAt: Date.now(),
};
});
}
addBudgetSegment(id: string, name: string, color: string, createdBy: string | null): void {
const docId = flowsDocId(this.#space) as DocumentId;
this.#sync.change<FlowsDoc>(docId, `Add budget segment ${name}`, (d) => {
if (!d.budgetSegments) d.budgetSegments = {} as any;
d.budgetSegments[id] = { name, color, createdBy };
// Initialize all existing allocations with 0 for new segment
if (d.budgetAllocations) {
for (const alloc of Object.values(d.budgetAllocations)) {
if (!alloc.allocations[id]) alloc.allocations[id] = 0;
}
}
});
}
removeBudgetSegment(id: string): void {
const docId = flowsDocId(this.#space) as DocumentId;
this.#sync.change<FlowsDoc>(docId, `Remove budget segment`, (d) => {
if (d.budgetSegments?.[id]) delete d.budgetSegments[id];
// Clean allocations
if (d.budgetAllocations) {
for (const alloc of Object.values(d.budgetAllocations)) {
if (alloc.allocations[id] !== undefined) delete alloc.allocations[id];
}
}
});
}
setBudgetTotalAmount(amount: number): void {
const docId = flowsDocId(this.#space) as DocumentId;
this.#sync.change<FlowsDoc>(docId, `Set budget total`, (d) => {
d.budgetTotalAmount = amount;
});
}
async disconnect(): Promise<void> { async disconnect(): Promise<void> {
await this.#sync.flush(); await this.#sync.flush();
this.#sync.disconnect(); this.#sync.disconnect();

View File

@ -521,15 +521,13 @@ export class FolkSplatViewer extends HTMLElement {
submitBtn.disabled = true; submitBtn.disabled = true;
actions.style.display = "none"; actions.style.display = "none";
progress.style.display = "block"; progress.style.display = "block";
status.textContent = ""; status.textContent = "Preparing image...";
try { try {
const reader = new FileReader(); // Resize image to max 1024px to reduce payload and improve API success
const dataUrl = await new Promise<string>((resolve) => { const dataUrl = await this.resizeImage(selectedFile!, 1024);
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(selectedFile!);
});
status.textContent = "Generating 3D model...";
const res = await fetch("/api/3d-gen", { const res = await fetch("/api/3d-gen", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -572,6 +570,30 @@ export class FolkSplatViewer extends HTMLElement {
}); });
} }
// ── Image helpers ──
private resizeImage(file: File, maxSize: number): Promise<string> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
let { width, height } = img;
if (width > maxSize || height > maxSize) {
const scale = maxSize / Math.max(width, height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(img, 0, 0, width, height);
resolve(canvas.toDataURL("image/jpeg", 0.9));
};
img.onerror = () => reject(new Error("Failed to load image"));
img.src = URL.createObjectURL(file);
});
}
// ── Viewer ── // ── Viewer ──
private renderViewer() { private renderViewer() {

View File

@ -600,12 +600,12 @@ routes.get("/", async (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
head: ` head: `
<link rel="stylesheet" href="/modules/rsplat/splat.css"> <link rel="stylesheet" href="/modules/rsplat/splat.css?v=2">
${IMPORTMAP} ${IMPORTMAP}
`, `,
scripts: ` scripts: `
<script type="module"> <script type="module">
import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js'; import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js?v=2';
const gallery = document.getElementById('gallery'); const gallery = document.getElementById('gallery');
gallery.splats = ${splatsJSON}; gallery.splats = ${splatsJSON};
gallery.spaceSlug = '${spaceSlug}'; gallery.spaceSlug = '${spaceSlug}';
@ -664,12 +664,12 @@ routes.get("/view/:id", async (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
head: ` head: `
<link rel="stylesheet" href="/modules/rsplat/splat.css"> <link rel="stylesheet" href="/modules/rsplat/splat.css?v=2">
${IMPORTMAP} ${IMPORTMAP}
`, `,
scripts: ` scripts: `
<script type="module"> <script type="module">
import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js'; import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js?v=2';
</script> </script>
`, `,
}); });

View File

@ -999,9 +999,18 @@ app.post("/api/3d-gen", async (c) => {
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.text(); const errText = await res.text();
console.error("[3d-gen] fal.ai error:", err); console.error("[3d-gen] fal.ai error:", res.status, errText);
return c.json({ error: "3D generation failed" }, 502); let detail = "3D generation failed";
try {
const parsed = JSON.parse(errText);
if (parsed.detail) {
detail = typeof parsed.detail === "string" ? parsed.detail
: Array.isArray(parsed.detail) ? parsed.detail[0]?.msg || detail
: detail;
}
} catch {}
return c.json({ error: detail }, 502);
} }
const data = await res.json(); const data = await res.json();