Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-04 19:20:08 -08:00
commit b28b551ee1
5 changed files with 813 additions and 28 deletions

View File

@ -912,6 +912,123 @@
}
}
/* ── Flow dropdown (toolbar) ──────────────────────── */
.flows-dropdown {
position: relative; display: inline-block;
}
.flows-dropdown__trigger {
display: flex; align-items: center; gap: 4px;
max-width: 180px;
}
.flows-dropdown__name {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.flows-dropdown__chevron {
font-size: 9px; flex-shrink: 0; opacity: 0.7;
}
.flows-dropdown__menu {
position: absolute; top: 100%; left: 0; z-index: 25;
min-width: 200px; max-height: 300px; overflow-y: auto;
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong);
border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.35);
padding: 4px 0; margin-top: 4px;
}
.flows-dropdown__item {
display: block; width: 100%; text-align: left;
padding: 7px 12px; border: none; background: none;
color: var(--rs-text-primary); font-size: 12px; cursor: pointer;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
transition: background 0.1s;
}
.flows-dropdown__item:hover { background: var(--rs-border-strong); }
.flows-dropdown__item--active {
border-left: 3px solid var(--rs-primary); padding-left: 9px;
color: var(--rs-primary);
}
.flows-dropdown__item--new { color: var(--rs-primary); font-weight: 600; }
.flows-dropdown__sep {
height: 1px; background: var(--rs-border-strong); margin: 4px 0;
}
/* ── Flow management modal ───────────────────────── */
.flows-mgmt-overlay {
position: fixed; inset: 0; z-index: 100;
background: var(--rs-bg-overlay); display: flex;
align-items: center; justify-content: center;
animation: modalFadeIn 0.15s ease-out;
}
.flows-mgmt-modal {
background: var(--rs-bg-surface); border-radius: 16px;
width: 520px; max-width: 95vw; max-height: 85vh;
border: 1px solid var(--rs-border-strong); box-shadow: var(--rs-shadow-lg);
display: flex; flex-direction: column;
animation: modalSlideIn 0.2s ease-out;
}
.flows-mgmt__header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px; border-bottom: 1px solid var(--rs-border-strong);
}
.flows-mgmt__header h2 { font-size: 16px; font-weight: 600; margin: 0; }
.flows-mgmt__close {
background: none; border: none; color: var(--rs-text-secondary);
font-size: 22px; cursor: pointer; padding: 2px 6px; border-radius: 4px;
}
.flows-mgmt__close:hover { color: var(--rs-text-primary); }
.flows-mgmt__body {
flex: 1; overflow-y: auto; padding: 8px 0;
max-height: 400px;
}
.flows-mgmt__body::-webkit-scrollbar { width: 6px; }
.flows-mgmt__body::-webkit-scrollbar-thumb { background: var(--rs-bg-surface-raised); border-radius: 3px; }
.flows-mgmt__row {
display: flex; align-items: center; gap: 8px;
padding: 8px 20px; transition: background 0.1s;
border-bottom: 1px solid var(--rs-border-strong);
}
.flows-mgmt__row:hover { background: var(--rs-bg-page); }
.flows-mgmt__row-name {
flex: 1; font-size: 13px; font-weight: 500; color: var(--rs-text-primary);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.flows-mgmt__row-name input {
background: var(--rs-bg-page); border: 1px solid var(--rs-primary);
border-radius: 4px; padding: 2px 6px; color: var(--rs-text-primary);
font-size: 13px; width: 100%; outline: none;
}
.flows-mgmt__row-meta {
font-size: 11px; color: var(--rs-text-muted); white-space: nowrap;
}
.flows-mgmt__row-actions {
display: flex; gap: 4px; flex-shrink: 0;
}
.flows-mgmt__row-btn {
padding: 3px 6px; border: 1px solid var(--rs-border-strong);
border-radius: 4px; background: none; color: var(--rs-text-secondary);
font-size: 11px; cursor: pointer; transition: all 0.1s;
}
.flows-mgmt__row-btn:hover { background: var(--rs-border-strong); color: var(--rs-text-primary); }
.flows-mgmt__row-btn--danger { border-color: var(--rs-error); color: var(--rs-error); }
.flows-mgmt__row-btn--danger:hover { background: rgba(239,68,68,0.15); }
.flows-mgmt__footer {
display: flex; justify-content: flex-end; gap: 8px;
padding: 12px 20px; border-top: 1px solid var(--rs-border-strong);
}
.flows-mgmt__footer button {
padding: 6px 14px; border-radius: 6px; border: 1px solid var(--rs-border-strong);
background: var(--rs-bg-surface); color: var(--rs-text-primary);
font-size: 12px; cursor: pointer; font-weight: 500;
transition: background 0.15s;
}
.flows-mgmt__footer button:hover { background: var(--rs-border-strong); }
.flows-mgmt__footer button.primary {
background: var(--rs-primary); border-color: var(--rs-primary-hover); color: #fff;
}
.flows-mgmt__footer button.primary:hover { opacity: 0.85; }
.flows-mgmt__empty {
text-align: center; color: var(--rs-text-muted); padding: 32px 20px;
font-size: 13px;
}
/* ── Mobile responsive ──────────────────────────────── */
@media (max-width: 768px) {
.flows-diagram { overflow-x: auto; -webkit-overflow-scrolling: touch; }

View File

@ -16,8 +16,9 @@ import { PORT_DEFS, deriveThresholds } from "../lib/types";
import { computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation";
import { demoNodes, SPENDING_COLORS, OVERFLOW_COLORS } from "../lib/presets";
import { mapFlowToNodes } from "../lib/map-flow";
import { flowsSchema, flowsDocId, type FlowsDoc } from "../schemas";
import { flowsSchema, flowsDocId, type FlowsDoc, type CanvasFlow } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
import { FlowsLocalFirstClient } from "../local-first-client";
interface FlowSummary {
id: string;
@ -141,6 +142,14 @@ class FolkFlowsApp extends HTMLElement {
private _boundPointerMove: ((e: PointerEvent) => void) | null = null;
private _boundPointerUp: ((e: PointerEvent) => void) | null = null;
// Flow storage & switching
private localFirstClient: FlowsLocalFirstClient | null = null;
private currentFlowId = "";
private saveTimer: ReturnType<typeof setTimeout> | null = null;
private flowDropdownOpen = false;
private flowManagerOpen = false;
private _lfcUnsub: (() => void) | null = null;
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
@ -151,26 +160,265 @@ class FolkFlowsApp extends HTMLElement {
this.flowId = this.getAttribute("flow-id") || "";
this.isDemo = this.getAttribute("mode") === "demo" || this.space === "demo";
// Canvas-first: always open in detail (canvas) view
this.view = "detail";
if (this.isDemo) {
this.view = "detail";
this.flowName = "TBFF Demo Flow";
this.nodes = demoNodes.map((n) => ({ ...n, data: { ...n.data } }));
this.render();
// Demo/anon: load from localStorage or demoNodes
this.loadDemoOrLocalFlow();
} else if (this.flowId) {
this.view = "detail";
// Direct link to a specific API flow
this.loadFlow(this.flowId);
} else {
this.view = "landing";
this.loadFlows();
// Authenticated: init local-first client and load active flow
this.initLocalFirstClient();
}
}
private loadDemoOrLocalFlow() {
const activeId = localStorage.getItem('rflows:local:active') || '';
if (activeId) {
const raw = localStorage.getItem(`rflows:local:${activeId}`);
if (raw) {
try {
const flow = JSON.parse(raw) as CanvasFlow;
this.currentFlowId = flow.id;
this.flowName = flow.name;
this.nodes = flow.nodes.map((n: FlowNode) => ({ ...n, data: { ...n.data } }));
this.restoreViewport(flow.id);
this.render();
return;
} catch { /* fall through to demoNodes */ }
}
}
// Fallback: demoNodes
this.currentFlowId = 'demo';
this.flowName = "TBFF Demo Flow";
this.nodes = demoNodes.map((n) => ({ ...n, data: { ...n.data } }));
localStorage.setItem('rflows:local:active', 'demo');
this.render();
}
private async initLocalFirstClient() {
this.loading = true;
this.render();
try {
this.localFirstClient = new FlowsLocalFirstClient(this.space);
await this.localFirstClient.init();
await this.localFirstClient.subscribe();
// Listen for remote changes
this._lfcUnsub = this.localFirstClient.onChange((doc) => {
if (!this.currentFlowId) return;
const flow = doc.canvasFlows?.[this.currentFlowId];
if (flow && !this.saveTimer) {
// Only update if we're not in the middle of saving
this.nodes = flow.nodes.map((n: any) => ({ ...n, data: { ...n.data } }));
this.drawCanvasContent();
}
});
// Load active flow or first available or demoNodes
const activeId = this.localFirstClient.getActiveFlowId();
const flows = this.localFirstClient.listCanvasFlows();
if (activeId && this.localFirstClient.getCanvasFlow(activeId)) {
this.loadCanvasFlow(activeId);
} else if (flows.length > 0) {
this.loadCanvasFlow(flows[0].id);
} else {
// No flows yet — create one from demoNodes
const newId = crypto.randomUUID();
const now = Date.now();
const username = getUsername();
const newFlow: CanvasFlow = {
id: newId,
name: 'My First Flow',
nodes: demoNodes.map((n) => ({ ...n, data: { ...n.data } })),
createdAt: now,
updatedAt: now,
createdBy: username ? `did:encryptid:${username}` : null,
};
this.localFirstClient.saveCanvasFlow(newFlow);
this.localFirstClient.setActiveFlow(newId);
this.loadCanvasFlow(newId);
}
} catch {
// Offline or error — fall back to demoNodes
console.warn('[FlowsApp] Local-first init failed, using demo nodes');
this.loadDemoOrLocalFlow();
}
// Subscribe to offline-first Automerge doc for flow associations
if (!this.isDemo) this.subscribeOffline();
this.loading = false;
}
private loadCanvasFlow(flowId: string) {
const flow = this.localFirstClient?.getCanvasFlow(flowId);
if (!flow) return;
this.currentFlowId = flow.id;
this.flowName = flow.name;
this.nodes = flow.nodes.map((n: any) => ({ ...n, data: { ...n.data } }));
this.localFirstClient?.setActiveFlow(flowId);
this.restoreViewport(flowId);
this.loading = false;
this.canvasInitialized = false; // force re-fit on switch
this.render();
}
disconnectedCallback() {
this._offlineUnsub?.();
this._offlineUnsub = null;
this._lfcUnsub?.();
this._lfcUnsub = null;
if (this.saveTimer) { clearTimeout(this.saveTimer); this.saveTimer = null; }
this.localFirstClient?.disconnect();
}
// ─── Auto-save (debounced) ──────────────────────────────
private scheduleSave() {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => { this.executeSave(); this.saveTimer = null; }, 1500);
}
private executeSave() {
if (this.localFirstClient && this.currentFlowId) {
this.localFirstClient.updateFlowNodes(this.currentFlowId, this.nodes);
} else if (this.currentFlowId) {
// Anon/demo: save to localStorage
const flow: CanvasFlow = {
id: this.currentFlowId,
name: this.flowName,
nodes: this.nodes,
createdAt: Date.now(),
updatedAt: Date.now(),
createdBy: null,
};
localStorage.setItem(`rflows:local:${this.currentFlowId}`, JSON.stringify(flow));
// Maintain local flow list
const listRaw = localStorage.getItem('rflows:local:list');
const list: string[] = listRaw ? JSON.parse(listRaw) : [];
if (!list.includes(this.currentFlowId)) {
list.push(this.currentFlowId);
localStorage.setItem('rflows:local:list', JSON.stringify(list));
}
}
}
// ─── Viewport persistence ───────────────────────────────
private saveViewport() {
if (!this.currentFlowId) return;
localStorage.setItem(`rflows:viewport:${this.currentFlowId}`, JSON.stringify({
zoom: this.canvasZoom, panX: this.canvasPanX, panY: this.canvasPanY,
}));
}
private restoreViewport(flowId: string) {
const raw = localStorage.getItem(`rflows:viewport:${flowId}`);
if (raw) {
try {
const { zoom, panX, panY } = JSON.parse(raw);
this.canvasZoom = zoom;
this.canvasPanX = panX;
this.canvasPanY = panY;
} catch { /* ignore corrupt data */ }
}
}
// ─── Flow switching ─────────────────────────────────────
private switchToFlow(flowId: string) {
// Save current flow if dirty
if (this.saveTimer) {
clearTimeout(this.saveTimer);
this.saveTimer = null;
this.executeSave();
}
this.saveViewport();
// Stop simulation if running
if (this.isSimulating) this.toggleSimulation();
// Exit inline edit
if (this.inlineEditNodeId) this.exitInlineEdit();
if (this.localFirstClient) {
this.loadCanvasFlow(flowId);
} else {
// Local/demo mode
const raw = localStorage.getItem(`rflows:local:${flowId}`);
if (raw) {
try {
const flow = JSON.parse(raw) as CanvasFlow;
this.currentFlowId = flow.id;
this.flowName = flow.name;
this.nodes = flow.nodes.map((n: FlowNode) => ({ ...n, data: { ...n.data } }));
} catch { return; }
} else if (flowId === 'demo') {
this.currentFlowId = 'demo';
this.flowName = 'TBFF Demo Flow';
this.nodes = demoNodes.map((n) => ({ ...n, data: { ...n.data } }));
} else { return; }
localStorage.setItem('rflows:local:active', flowId);
this.restoreViewport(flowId);
this.canvasInitialized = false;
this.render();
}
}
private createNewFlow() {
const id = crypto.randomUUID();
const now = Date.now();
const newFlow: CanvasFlow = {
id,
name: 'Untitled Flow',
nodes: [{
id: `source-${Date.now().toString(36)}`,
type: 'source' as const,
position: { x: 400, y: 200 },
data: { label: 'New Source', flowRate: 1000, sourceType: 'card', targetAllocations: [] },
}],
createdAt: now,
updatedAt: now,
createdBy: getUsername() ? `did:encryptid:${getUsername()}` : null,
};
if (this.localFirstClient) {
this.localFirstClient.saveCanvasFlow(newFlow);
this.localFirstClient.setActiveFlow(id);
this.loadCanvasFlow(id);
} else {
localStorage.setItem(`rflows:local:${id}`, JSON.stringify(newFlow));
const listRaw = localStorage.getItem('rflows:local:list');
const list: string[] = listRaw ? JSON.parse(listRaw) : [];
list.push(id);
localStorage.setItem('rflows:local:list', JSON.stringify(list));
localStorage.setItem('rflows:local:active', id);
this.switchToFlow(id);
}
}
private getFlowList(): { id: string; name: string; nodeCount: number; updatedAt: number }[] {
if (this.localFirstClient) {
return this.localFirstClient.listCanvasFlows().map(f => ({
id: f.id, name: f.name, nodeCount: f.nodes?.length || 0, updatedAt: f.updatedAt,
}));
}
// Local mode
const listRaw = localStorage.getItem('rflows:local:list');
const list: string[] = listRaw ? JSON.parse(listRaw) : [];
// Always include demo if not already tracked
if (!list.includes('demo')) list.unshift('demo');
return list.map(id => {
if (id === 'demo') return { id: 'demo', name: 'TBFF Demo Flow', nodeCount: demoNodes.length, updatedAt: 0 };
const raw = localStorage.getItem(`rflows:local:${id}`);
if (!raw) return null;
try {
const f = JSON.parse(raw) as CanvasFlow;
return { id: f.id, name: f.name, nodeCount: f.nodes?.length || 0, updatedAt: f.updatedAt };
} catch { return null; }
}).filter(Boolean) as any[];
}
private async subscribeOffline() {
@ -629,6 +877,19 @@ class FolkFlowsApp extends HTMLElement {
</div>
</div>
<div class="flows-canvas-toolbar">
<div class="flows-dropdown" id="flow-dropdown">
<button class="flows-canvas-btn flows-dropdown__trigger" data-canvas-action="flow-picker">
<span class="flows-dropdown__name">${this.esc(this.flowName || 'Untitled')}</span>
<span class="flows-dropdown__chevron">&#x25BE;</span>
</button>
<div class="flows-dropdown__menu" id="flow-dropdown-menu" style="display:none">
${this.renderFlowDropdownItems()}
<div class="flows-dropdown__sep"></div>
<button class="flows-dropdown__item flows-dropdown__item--new" data-flow-action="new-flow">+ New Flow</button>
<button class="flows-dropdown__item" data-flow-action="manage-flows">Manage Flows...</button>
</div>
</div>
<div class="flows-canvas-sep"></div>
<button class="flows-canvas-btn flows-canvas-btn--source" data-canvas-action="add-source">+ Source</button>
<button class="flows-canvas-btn flows-canvas-btn--funnel" data-canvas-action="add-funnel">+ Funnel</button>
<button class="flows-canvas-btn flows-canvas-btn--outcome" data-canvas-action="add-outcome">+ Outcome</button>
@ -670,6 +931,48 @@ class FolkFlowsApp extends HTMLElement {
<span class="flows-timeline__tick" id="timeline-tick">Tick 0</span>
</div>
<div class="flows-node-tooltip" id="node-tooltip" style="display:none"></div>
</div>
${this.flowManagerOpen ? this.renderFlowManagerModal() : ''}`;
}
private renderFlowDropdownItems(): string {
const flows = this.getFlowList();
if (flows.length === 0) return '<div class="flows-dropdown__item" style="color:var(--rs-text-muted);pointer-events:none">No flows</div>';
return flows.map(f =>
`<button class="flows-dropdown__item ${f.id === this.currentFlowId ? 'flows-dropdown__item--active' : ''}" data-flow-switch="${this.esc(f.id)}">${this.esc(f.name)}</button>`
).join('');
}
private renderFlowManagerModal(): string {
const flows = this.getFlowList();
return `
<div class="flows-mgmt-overlay" id="flow-manager-overlay">
<div class="flows-mgmt-modal">
<div class="flows-mgmt__header">
<h2>Manage Flows</h2>
<button class="flows-mgmt__close" data-mgmt-action="close">&times;</button>
</div>
<div class="flows-mgmt__body">
${flows.length === 0
? '<div class="flows-mgmt__empty">No flows yet. Create one to get started.</div>'
: flows.map(f => `
<div class="flows-mgmt__row" data-mgmt-flow="${this.esc(f.id)}">
<div class="flows-mgmt__row-name">${this.esc(f.name)}</div>
<div class="flows-mgmt__row-meta">${f.nodeCount} nodes${f.updatedAt ? ' &middot; ' + new Date(f.updatedAt).toLocaleDateString() : ''}</div>
<div class="flows-mgmt__row-actions">
<button class="flows-mgmt__row-btn" data-mgmt-action="rename" data-mgmt-id="${this.esc(f.id)}" title="Rename">&#x270E;</button>
<button class="flows-mgmt__row-btn" data-mgmt-action="duplicate" data-mgmt-id="${this.esc(f.id)}" title="Duplicate">&#x2398;</button>
<button class="flows-mgmt__row-btn" data-mgmt-action="export" data-mgmt-id="${this.esc(f.id)}" title="Export JSON">&#x2B07;</button>
<button class="flows-mgmt__row-btn flows-mgmt__row-btn--danger" data-mgmt-action="delete" data-mgmt-id="${this.esc(f.id)}" title="Delete">&#x2715;</button>
</div>
</div>
`).join('')}
</div>
<div class="flows-mgmt__footer">
<button data-mgmt-action="import">Import JSON</button>
<button class="primary" data-mgmt-action="new">+ New Flow</button>
</div>
</div>
</div>`;
}
@ -697,6 +1000,7 @@ class FolkFlowsApp extends HTMLElement {
private updateCanvasTransform() {
const g = this.shadow.getElementById("canvas-transform");
if (g) g.setAttribute("transform", `translate(${this.canvasPanX},${this.canvasPanY}) scale(${this.canvasZoom})`);
this.saveViewport();
}
private fitView() {
@ -873,6 +1177,8 @@ class FolkFlowsApp extends HTMLElement {
this.selectedNodeId = clickedNodeId;
this.selectedEdgeKey = null;
this.updateSelectionHighlight();
} else {
this.scheduleSave();
}
}
};
@ -988,9 +1294,37 @@ class FolkFlowsApp extends HTMLElement {
else if (action === "share") this.shareState();
else if (action === "zoom-in") { this.canvasZoom = Math.min(4, this.canvasZoom * 1.2); this.updateCanvasTransform(); }
else if (action === "zoom-out") { this.canvasZoom = Math.max(0.1, this.canvasZoom * 0.8); this.updateCanvasTransform(); }
else if (action === "flow-picker") this.toggleFlowDropdown();
});
});
// Flow dropdown items
this.shadow.querySelectorAll("[data-flow-switch]").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const flowId = (btn as HTMLElement).dataset.flowSwitch;
if (flowId && flowId !== this.currentFlowId) this.switchToFlow(flowId);
this.closeFlowDropdown();
});
});
this.shadow.querySelectorAll("[data-flow-action]").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const action = (btn as HTMLElement).dataset.flowAction;
if (action === "new-flow") { this.closeFlowDropdown(); this.createNewFlow(); }
else if (action === "manage-flows") { this.closeFlowDropdown(); this.openFlowManager(); }
});
});
// Close dropdown on outside click
this.shadow.addEventListener("click", (e) => {
const dropdown = this.shadow.getElementById("flow-dropdown");
if (dropdown && !dropdown.contains(e.target as Node)) this.closeFlowDropdown();
});
// Management modal listeners
this.attachFlowManagerListeners();
// Speed slider
const speedSlider = this.shadow.getElementById("sim-speed-slider") as HTMLInputElement | null;
if (speedSlider) {
@ -1934,6 +2268,7 @@ class FolkFlowsApp extends HTMLElement {
this.cancelWiring();
this.drawCanvasContent();
this.openEditor(this.wiringSourceNodeId || sourceNode.id);
this.scheduleSave();
}
private normalizeAllocations(allocs: { targetId: string; percentage: number; color: string }[]) {
@ -2043,6 +2378,7 @@ class FolkFlowsApp extends HTMLElement {
this.redrawEdges();
this.refreshEditorIfOpen(fromId);
this.scheduleSave();
}
// ─── Editor panel ─────────────────────────────────────
@ -2516,6 +2852,7 @@ class FolkFlowsApp extends HTMLElement {
if (newG) {
g.replaceWith(newG);
}
this.scheduleSave();
}
private redrawNodeInlineEdit(node: FlowNode) {
@ -2805,6 +3142,7 @@ class FolkFlowsApp extends HTMLElement {
}
this.drawCanvasContent();
this.updateSufficiencyBadge();
this.scheduleSave();
});
});
@ -3215,6 +3553,7 @@ class FolkFlowsApp extends HTMLElement {
this.selectedNodeId = id;
this.updateSelectionHighlight();
this.openEditor(id);
this.scheduleSave();
}
private deleteNode(nodeId: string) {
@ -3238,6 +3577,7 @@ class FolkFlowsApp extends HTMLElement {
if (this.selectedNodeId === nodeId) this.selectedNodeId = null;
this.drawCanvasContent();
this.updateSufficiencyBadge();
this.scheduleSave();
}
// ─── Simulation ───────────────────────────────────────
@ -3508,6 +3848,216 @@ class FolkFlowsApp extends HTMLElement {
}
}
// ─── Flow dropdown & management modal ────────────────
private toggleFlowDropdown() {
const menu = this.shadow.getElementById("flow-dropdown-menu");
if (!menu) return;
this.flowDropdownOpen = !this.flowDropdownOpen;
menu.style.display = this.flowDropdownOpen ? "block" : "none";
}
private closeFlowDropdown() {
this.flowDropdownOpen = false;
const menu = this.shadow.getElementById("flow-dropdown-menu");
if (menu) menu.style.display = "none";
}
private openFlowManager() {
this.flowManagerOpen = true;
this.render();
}
private closeFlowManager() {
this.flowManagerOpen = false;
this.render();
}
private attachFlowManagerListeners() {
const overlay = this.shadow.getElementById("flow-manager-overlay");
if (!overlay) return;
// Close button & backdrop click
overlay.querySelector('[data-mgmt-action="close"]')?.addEventListener("click", () => this.closeFlowManager());
overlay.addEventListener("click", (e) => {
if (e.target === overlay) this.closeFlowManager();
});
// Row actions
overlay.querySelectorAll("[data-mgmt-action]").forEach((btn) => {
const action = (btn as HTMLElement).dataset.mgmtAction;
const id = (btn as HTMLElement).dataset.mgmtId;
if (!action || action === "close") return;
btn.addEventListener("click", (e) => {
e.stopPropagation();
if (action === "new") { this.closeFlowManager(); this.createNewFlow(); }
else if (action === "import") this.importFlowJson();
else if (action === "rename" && id) this.renameFlowInline(id, btn as HTMLElement);
else if (action === "duplicate" && id) this.duplicateFlow(id);
else if (action === "export" && id) this.exportFlowJson(id);
else if (action === "delete" && id) this.deleteFlowConfirm(id);
});
});
// Click a row to switch to that flow
overlay.querySelectorAll("[data-mgmt-flow]").forEach((row) => {
row.addEventListener("dblclick", () => {
const flowId = (row as HTMLElement).dataset.mgmtFlow;
if (flowId) { this.closeFlowManager(); this.switchToFlow(flowId); }
});
});
}
private renameFlowInline(flowId: string, triggerBtn: HTMLElement) {
const row = triggerBtn.closest("[data-mgmt-flow]");
const nameDiv = row?.querySelector(".flows-mgmt__row-name");
if (!nameDiv) return;
const currentName = nameDiv.textContent?.trim() || '';
nameDiv.innerHTML = `<input type="text" value="${this.esc(currentName)}" />`;
const input = nameDiv.querySelector("input") as HTMLInputElement;
input.focus();
input.select();
const commitRename = () => {
const newName = input.value.trim() || currentName;
if (this.localFirstClient) {
this.localFirstClient.renameCanvasFlow(flowId, newName);
} else {
const raw = localStorage.getItem(`rflows:local:${flowId}`);
if (raw) {
const flow = JSON.parse(raw) as CanvasFlow;
flow.name = newName;
flow.updatedAt = Date.now();
localStorage.setItem(`rflows:local:${flowId}`, JSON.stringify(flow));
}
}
if (flowId === this.currentFlowId) this.flowName = newName;
nameDiv.textContent = newName;
};
input.addEventListener("blur", commitRename);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); input.blur(); }
if (e.key === "Escape") { nameDiv.textContent = currentName; }
});
}
private duplicateFlow(flowId: string) {
let sourceFlow: CanvasFlow | undefined;
if (this.localFirstClient) {
sourceFlow = this.localFirstClient.getCanvasFlow(flowId);
} else {
const raw = localStorage.getItem(`rflows:local:${flowId}`);
if (raw) sourceFlow = JSON.parse(raw);
}
if (!sourceFlow) return;
const newId = crypto.randomUUID();
const now = Date.now();
const copy: CanvasFlow = {
id: newId,
name: `${sourceFlow.name} (Copy)`,
nodes: sourceFlow.nodes.map((n: any) => ({ ...n, data: { ...n.data } })),
createdAt: now,
updatedAt: now,
createdBy: sourceFlow.createdBy,
};
if (this.localFirstClient) {
this.localFirstClient.saveCanvasFlow(copy);
} else {
localStorage.setItem(`rflows:local:${newId}`, JSON.stringify(copy));
const listRaw = localStorage.getItem('rflows:local:list');
const list: string[] = listRaw ? JSON.parse(listRaw) : [];
list.push(newId);
localStorage.setItem('rflows:local:list', JSON.stringify(list));
}
this.closeFlowManager();
this.switchToFlow(newId);
}
private exportFlowJson(flowId: string) {
let flow: CanvasFlow | undefined;
if (this.localFirstClient) {
flow = this.localFirstClient.getCanvasFlow(flowId);
} else {
const raw = localStorage.getItem(`rflows:local:${flowId}`);
if (raw) flow = JSON.parse(raw);
else if (flowId === 'demo') {
flow = { id: 'demo', name: 'TBFF Demo Flow', nodes: demoNodes, createdAt: 0, updatedAt: 0, createdBy: null };
}
}
if (!flow) return;
const blob = new Blob([JSON.stringify(flow.nodes, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${flow.name.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`;
a.click();
URL.revokeObjectURL(url);
}
private importFlowJson() {
const input = document.createElement("input");
input.type = "file";
input.accept = ".json";
input.addEventListener("change", async () => {
const file = input.files?.[0];
if (!file) return;
try {
const text = await file.text();
const nodes = JSON.parse(text);
if (!Array.isArray(nodes) || nodes.length === 0) { alert("Invalid flow JSON: expected a non-empty array of nodes."); return; }
// Basic validation: each node should have id, type, position, data
for (const n of nodes) {
if (!n.id || !n.type || !n.position || !n.data) { alert("Invalid node structure in JSON."); return; }
}
const id = crypto.randomUUID();
const now = Date.now();
const name = file.name.replace(/\.json$/i, '');
const flow: CanvasFlow = { id, name, nodes, createdAt: now, updatedAt: now, createdBy: null };
if (this.localFirstClient) {
this.localFirstClient.saveCanvasFlow(flow);
} else {
localStorage.setItem(`rflows:local:${id}`, JSON.stringify(flow));
const listRaw = localStorage.getItem('rflows:local:list');
const list: string[] = listRaw ? JSON.parse(listRaw) : [];
list.push(id);
localStorage.setItem('rflows:local:list', JSON.stringify(list));
}
this.closeFlowManager();
this.switchToFlow(id);
} catch { alert("Failed to parse JSON file."); }
});
input.click();
}
private deleteFlowConfirm(flowId: string) {
if (!confirm("Delete this flow? This cannot be undone.")) return;
if (this.localFirstClient) {
this.localFirstClient.deleteCanvasFlow(flowId);
} else {
localStorage.removeItem(`rflows:local:${flowId}`);
localStorage.removeItem(`rflows:viewport:${flowId}`);
const listRaw = localStorage.getItem('rflows:local:list');
const list: string[] = listRaw ? JSON.parse(listRaw) : [];
localStorage.setItem('rflows:local:list', JSON.stringify(list.filter(id => id !== flowId)));
}
if (flowId === this.currentFlowId) {
// Switch to another flow or create new
const remaining = this.getFlowList();
if (remaining.length > 0) this.switchToFlow(remaining[0].id);
else this.createNewFlow();
} else {
this.closeFlowManager();
this.openFlowManager(); // refresh list
}
}
// ─── Event listeners ──────────────────────────────────
private attachListeners() {

View File

@ -12,7 +12,8 @@ import { EncryptedDocStore } from '../../shared/local-first/storage';
import { DocSyncManager } from '../../shared/local-first/sync';
import { DocCrypto } from '../../shared/local-first/crypto';
import { flowsSchema, flowsDocId } from './schemas';
import type { FlowsDoc, SpaceFlow } from './schemas';
import type { FlowsDoc, SpaceFlow, CanvasFlow } from './schemas';
import type { FlowNode } from './lib/types';
export class FlowsLocalFirstClient {
#space: string;
@ -89,6 +90,70 @@ export class FlowsLocalFirstClient {
onConnect(cb: () => void): () => void { return this.#sync.onConnect(cb); }
onDisconnect(cb: () => void): () => void { return this.#sync.onDisconnect(cb); }
// ── Canvas flow CRUD ──
listCanvasFlows(): CanvasFlow[] {
const doc = this.getFlows();
if (!doc?.canvasFlows) return [];
return Object.values(doc.canvasFlows).sort((a, b) => b.updatedAt - a.updatedAt);
}
getCanvasFlow(id: string): CanvasFlow | undefined {
const doc = this.getFlows();
return doc?.canvasFlows?.[id];
}
saveCanvasFlow(flow: CanvasFlow): void {
const docId = flowsDocId(this.#space) as DocumentId;
this.#sync.change<FlowsDoc>(docId, `Save canvas flow ${flow.name}`, (d) => {
if (!d.canvasFlows) d.canvasFlows = {} as any;
flow.updatedAt = Date.now();
d.canvasFlows[flow.id] = flow;
});
}
updateFlowNodes(flowId: string, nodes: FlowNode[]): void {
const docId = flowsDocId(this.#space) as DocumentId;
this.#sync.change<FlowsDoc>(docId, `Update flow nodes`, (d) => {
const flow = d.canvasFlows?.[flowId];
if (flow) {
flow.nodes = nodes as any;
flow.updatedAt = Date.now();
}
});
}
renameCanvasFlow(flowId: string, name: string): void {
const docId = flowsDocId(this.#space) as DocumentId;
this.#sync.change<FlowsDoc>(docId, `Rename flow to ${name}`, (d) => {
const flow = d.canvasFlows?.[flowId];
if (flow) {
flow.name = name;
flow.updatedAt = Date.now();
}
});
}
deleteCanvasFlow(flowId: string): void {
const docId = flowsDocId(this.#space) as DocumentId;
this.#sync.change<FlowsDoc>(docId, `Delete canvas flow`, (d) => {
if (d.canvasFlows?.[flowId]) delete d.canvasFlows[flowId];
if (d.activeFlowId === flowId) d.activeFlowId = '';
});
}
setActiveFlow(flowId: string): void {
const docId = flowsDocId(this.#space) as DocumentId;
this.#sync.change<FlowsDoc>(docId, `Set active flow`, (d) => {
d.activeFlowId = flowId;
});
}
getActiveFlowId(): string {
const doc = this.getFlows();
return doc?.activeFlowId || '';
}
async disconnect(): Promise<void> {
await this.#sync.flush();
this.#sync.disconnect();

View File

@ -12,7 +12,8 @@ import { getModuleInfoList } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
import { renderLanding } from "./landing";
import type { SyncServer } from '../../server/local-first/sync-server';
import { flowsSchema, flowsDocId, type FlowsDoc, type SpaceFlow } from './schemas';
import { flowsSchema, flowsDocId, type FlowsDoc, type SpaceFlow, type CanvasFlow } from './schemas';
import { demoNodes } from './lib/presets';
let _syncServer: SyncServer | null = null;
@ -27,9 +28,20 @@ function ensureDoc(space: string): FlowsDoc {
d.meta = init.meta;
d.meta.spaceSlug = space;
d.spaceFlows = {};
d.canvasFlows = {} as any;
d.activeFlowId = '';
});
_syncServer!.setDoc(docId, doc);
}
// Migrate v1 → v2: add canvasFlows and activeFlowId
if (!doc.canvasFlows || doc.meta.version < 2) {
_syncServer!.changeDoc<FlowsDoc>(docId, 'migrate to v2', (d) => {
if (!d.canvasFlows) d.canvasFlows = {} as any;
if (!d.activeFlowId) d.activeFlowId = '' as any;
d.meta.version = 2;
});
doc = _syncServer!.getDoc<FlowsDoc>(docId)!;
}
return doc;
}
@ -303,22 +315,43 @@ routes.get("/flow/:flowId", (c) => {
function seedTemplateFlows(space: string) {
if (!_syncServer) return;
const doc = ensureDoc(space);
if (Object.keys(doc.spaceFlows).length > 0) return;
const docId = flowsDocId(space);
const now = Date.now();
const flowId = crypto.randomUUID();
// Seed SpaceFlow association if empty
if (Object.keys(doc.spaceFlows).length === 0) {
const docId = flowsDocId(space);
const now = Date.now();
const flowId = crypto.randomUUID();
// Create a SpaceFlow entry pointing to "demo" — the frontend
// already renders demoNodes from presets.ts in demo mode.
_syncServer.changeDoc<FlowsDoc>(docId, 'seed template flow', (d) => {
d.spaceFlows[flowId] = {
id: flowId, spaceSlug: space, flowId: 'demo',
addedBy: 'did:demo:seed', createdAt: now,
_syncServer.changeDoc<FlowsDoc>(docId, 'seed template flow', (d) => {
d.spaceFlows[flowId] = {
id: flowId, spaceSlug: space, flowId: 'demo',
addedBy: 'did:demo:seed', createdAt: now,
};
});
}
// Seed a canvas flow with demoNodes if none exist
if (Object.keys(doc.canvasFlows || {}).length === 0) {
const docId = flowsDocId(space);
const now = Date.now();
const canvasFlowId = crypto.randomUUID();
const seedFlow: CanvasFlow = {
id: canvasFlowId,
name: 'TBFF Demo Flow',
nodes: demoNodes.map((n) => ({ ...n, data: { ...n.data } })),
createdAt: now,
updatedAt: now,
createdBy: 'did:demo:seed',
};
});
console.log(`[Flows] Template seeded for "${space}": 1 demo flow association`);
_syncServer.changeDoc<FlowsDoc>(docId, 'seed canvas flow', (d) => {
d.canvasFlows[canvasFlowId] = seedFlow as any;
d.activeFlowId = canvasFlowId as any;
});
console.log(`[Flows] Template seeded for "${space}": 1 canvas flow + association`);
}
}
export const flowsModule: RSpaceModule = {

View File

@ -4,11 +4,12 @@
* Granularity: one Automerge document per space (flow associations).
* DocId format: {space}:flows:data
*
* Actual flow logic stays in the external payment-flow service.
* This doc tracks which flows are associated with which spaces.
* v1: space-flow associations only
* v2: adds canvasFlows (full node data) and activeFlowId
*/
import type { DocSchema } from '../../shared/local-first/document';
import type { FlowNode } from './lib/types';
// ── Document types ──
@ -20,6 +21,15 @@ export interface SpaceFlow {
createdAt: number;
}
export interface CanvasFlow {
id: string;
name: string;
nodes: FlowNode[];
createdAt: number;
updatedAt: number;
createdBy: string | null;
}
export interface FlowsDoc {
meta: {
module: string;
@ -29,6 +39,8 @@ export interface FlowsDoc {
createdAt: number;
};
spaceFlows: Record<string, SpaceFlow>;
canvasFlows: Record<string, CanvasFlow>;
activeFlowId: string;
}
// ── Schema registration ──
@ -36,17 +48,25 @@ export interface FlowsDoc {
export const flowsSchema: DocSchema<FlowsDoc> = {
module: 'flows',
collection: 'data',
version: 1,
version: 2,
init: (): FlowsDoc => ({
meta: {
module: 'flows',
collection: 'data',
version: 1,
version: 2,
spaceSlug: '',
createdAt: Date.now(),
},
spaceFlows: {},
canvasFlows: {},
activeFlowId: '',
}),
migrate: (doc: any, _fromVersion: number) => {
if (!doc.canvasFlows) doc.canvasFlows = {};
if (!doc.activeFlowId) doc.activeFlowId = '';
doc.meta.version = 2;
return doc;
},
};
// ── Helpers ──