diff --git a/deploy/twenty-crm/docker-compose.yml b/deploy/twenty-crm/docker-compose.yml index 197fece..861c021 100644 --- a/deploy/twenty-crm/docker-compose.yml +++ b/deploy/twenty-crm/docker-compose.yml @@ -38,11 +38,7 @@ services: - SIGN_IN_PREFILLED=false - IS_BILLING_ENABLED=false - TELEMETRY_ENABLED=false - # ── Multi-workspace ── - - IS_MULTIWORKSPACE_ENABLED=true - - IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS=true - - DEFAULT_SUBDOMAIN=admin-crm - - FRONT_DOMAIN=rspace.online + - IS_MULTIWORKSPACE_ENABLED=false volumes: - twenty-ch-server-data:/app/.local-storage labels: @@ -81,11 +77,7 @@ services: - STORAGE_LOCAL_PATH=.local-storage - SERVER_URL=https://crm.rspace.online - TELEMETRY_ENABLED=false - # ── Multi-workspace ── - - IS_MULTIWORKSPACE_ENABLED=true - - IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS=true - - DEFAULT_SUBDOMAIN=admin-crm - - FRONT_DOMAIN=rspace.online + - IS_MULTIWORKSPACE_ENABLED=false volumes: - twenty-ch-server-data:/app/.local-storage networks: diff --git a/modules/rflows/components/flows.css b/modules/rflows/components/flows.css index 1780dfd..47736aa 100644 --- a/modules/rflows/components/flows.css +++ b/modules/rflows/components/flows.css @@ -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; } diff --git a/modules/rflows/components/folk-flows-app.ts b/modules/rflows/components/folk-flows-app.ts index 00559ef..b049995 100644 --- a/modules/rflows/components/folk-flows-app.ts +++ b/modules/rflows/components/folk-flows-app.ts @@ -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 | 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 {
+
+ + +
+
@@ -670,6 +931,48 @@ class FolkFlowsApp extends HTMLElement { Tick 0
+ + ${this.flowManagerOpen ? this.renderFlowManagerModal() : ''}`; + } + + private renderFlowDropdownItems(): string { + const flows = this.getFlowList(); + if (flows.length === 0) return '
No flows
'; + return flows.map(f => + `` + ).join(''); + } + + private renderFlowManagerModal(): string { + const flows = this.getFlowList(); + return ` +
+
+
+

Manage Flows

+ +
+
+ ${flows.length === 0 + ? '
No flows yet. Create one to get started.
' + : flows.map(f => ` +
+
${this.esc(f.name)}
+
${f.nodeCount} nodes${f.updatedAt ? ' · ' + new Date(f.updatedAt).toLocaleDateString() : ''}
+
+ + + + +
+
+ `).join('')} +
+ +
`; } @@ -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 = ``; + 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() { diff --git a/modules/rflows/local-first-client.ts b/modules/rflows/local-first-client.ts index e251616..3abb458 100644 --- a/modules/rflows/local-first-client.ts +++ b/modules/rflows/local-first-client.ts @@ -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(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(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(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(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(docId, `Set active flow`, (d) => { + d.activeFlowId = flowId; + }); + } + + getActiveFlowId(): string { + const doc = this.getFlows(); + return doc?.activeFlowId || ''; + } + async disconnect(): Promise { await this.#sync.flush(); this.#sync.disconnect(); diff --git a/modules/rflows/mod.ts b/modules/rflows/mod.ts index af3d67e..51aed82 100644 --- a/modules/rflows/mod.ts +++ b/modules/rflows/mod.ts @@ -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(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(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(docId, 'seed template flow', (d) => { - d.spaceFlows[flowId] = { - id: flowId, spaceSlug: space, flowId: 'demo', - addedBy: 'did:demo:seed', createdAt: now, + _syncServer.changeDoc(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(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 = { diff --git a/modules/rflows/schemas.ts b/modules/rflows/schemas.ts index 2aa2102..b6b8f1e 100644 --- a/modules/rflows/schemas.ts +++ b/modules/rflows/schemas.ts @@ -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; + canvasFlows: Record; + activeFlowId: string; } // ── Schema registration ── @@ -36,17 +48,25 @@ export interface FlowsDoc { export const flowsSchema: DocSchema = { 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 ── diff --git a/server/index.ts b/server/index.ts index 7126a88..e7a0f40 100644 --- a/server/index.ts +++ b/server/index.ts @@ -66,8 +66,8 @@ import { dataModule } from "../modules/rdata/mod"; import { splatModule } from "../modules/rsplat/mod"; import { photosModule } from "../modules/rphotos/mod"; import { socialsModule } from "../modules/rsocials/mod"; -import { docsModule } from "../modules/rdocs/mod"; -import { designModule } from "../modules/rdesign/mod"; +// import { docsModule } from "../modules/rdocs/mod"; +// import { designModule } from "../modules/rdesign/mod"; import { scheduleModule } from "../modules/rschedule/mod"; import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces"; import type { SpaceRoleString } from "./spaces"; @@ -108,8 +108,8 @@ registerModule(dataModule); registerModule(splatModule); registerModule(photosModule); registerModule(socialsModule); -registerModule(docsModule); -registerModule(designModule); +// registerModule(docsModule); // placeholder — not yet an rApp +// registerModule(designModule); // placeholder — not yet an rApp registerModule(scheduleModule); // ── Config ── diff --git a/server/shell.ts b/server/shell.ts index efc741b..4b7c7bc 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -333,12 +333,18 @@ export function renderShell(opts: ShellOptions): string { } catch(e) { tabCache = null; } // ── Tab events ── + // Set active on tab bar ONLY after switchTo() confirms the pane is ready. + // This prevents the visual desync where the tab highlights before content loads. tabBar.addEventListener('layer-switch', (e) => { - const { moduleId } = e.detail; + const { layerId, moduleId } = e.detail; saveTabs(); if (tabCache) { tabCache.switchTo(moduleId).then(ok => { - if (!ok) window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId); + if (ok) { + tabBar.setAttribute('active', layerId); + } else { + window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId); + } }); } else { window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId); @@ -353,7 +359,11 @@ export function renderShell(opts: ShellOptions): string { saveTabs(); if (tabCache) { tabCache.switchTo(moduleId).then(ok => { - if (!ok) window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId); + if (ok) { + tabBar.setAttribute('active', 'layer-' + moduleId); + } else { + window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId); + } }); } else { window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId); diff --git a/shared/components/rstack-tab-bar.ts b/shared/components/rstack-tab-bar.ts index 81a1933..43b7ff8 100644 --- a/shared/components/rstack-tab-bar.ts +++ b/shared/components/rstack-tab-bar.ts @@ -794,14 +794,14 @@ export class RStackTabBar extends HTMLElement { // Clean up previous document-level listeners to prevent leak if (this.#docCleanup) { this.#docCleanup(); this.#docCleanup = null; } - // Tab clicks + // Tab clicks — dispatch event but do NOT set active yet. + // The shell's event handler calls switchTo() and sets active only after success. this.#shadow.querySelectorAll(".tab").forEach(tab => { tab.addEventListener("click", (e) => { const target = e.target as HTMLElement; if (target.classList.contains("tab-close")) return; const layerId = tab.dataset.layerId!; const moduleId = tab.dataset.moduleId!; - this.active = layerId; this.trackRecent(moduleId); this.dispatchEvent(new CustomEvent("layer-switch", { detail: { layerId, moduleId }, @@ -929,12 +929,11 @@ export class RStackTabBar extends HTMLElement { this.#shadow.querySelectorAll(".layer-plane").forEach(plane => { const layerId = plane.dataset.layerId!; - // Click to switch layer + // Click to switch layer — do NOT set active here, let shell handler confirm plane.addEventListener("click", (e) => { if (this.#flowDragSource || this.#orbitDragging) return; const layer = this.#layers.find(l => l.id === layerId); if (layer) { - this.active = layerId; this.dispatchEvent(new CustomEvent("layer-switch", { detail: { layerId, moduleId: layer.moduleId }, bubbles: true, diff --git a/website/canvas.html b/website/canvas.html index fe67b22..29b0716 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -37,7 +37,7 @@ left: 12px; display: flex; flex-direction: column; - align-items: stretch; + align-items: flex-start; gap: 4px; padding: 6px 4px; background: var(--rs-toolbar-bg); @@ -224,56 +224,37 @@ color: white; } - /* Icon-only toolbar: show only emoji, clip text */ + /* Icon-only toolbar: show only emoji, fluid expand on hover/open */ .toolbar-group-toggle { - width: 42px; + max-width: 42px; overflow: hidden; text-overflow: clip; text-align: center; padding-left: 0; padding-right: 0; position: relative; + transition: max-width 0.25s ease, padding 0.2s ease; } - /* Tooltip on hover */ - .toolbar-group-toggle[data-tip]::after { - content: attr(data-tip); - position: absolute; - left: calc(100% + 8px); - top: 50%; - transform: translateY(-50%); - padding: 4px 10px; - border-radius: 6px; - background: var(--rs-toolbar-panel-bg); - color: var(--rs-toolbar-btn-text); - font-size: 12px; - white-space: nowrap; - box-shadow: var(--rs-shadow-md); - opacity: 0; - pointer-events: none; - transition: opacity 0.15s; - z-index: 1002; + /* Fluid expand: show title text on hover or when group is open */ + .toolbar-group:hover > .toolbar-group-toggle, + .toolbar-group.open > .toolbar-group-toggle { + max-width: 160px; + padding-left: 10px; + padding-right: 10px; + text-align: left; } - .toolbar-group-toggle[data-tip]:hover::after { - opacity: 1; - } - - /* Hide tooltip when group is open (dropdown is showing) */ - .toolbar-group.open > .toolbar-group-toggle[data-tip]::after { - display: none; - } - - /* Collapse/expand toggle — small pill at bottom of toolbar */ + /* Collapse/expand toggle — chevron at bottom of toolbar */ #toolbar-collapse { padding: 4px 0 !important; background: transparent !important; - font-size: 11px !important; + font-size: 16px !important; line-height: 1; opacity: 0.4; transition: opacity 0.2s; text-align: center; - letter-spacing: 2px; + letter-spacing: 0; color: var(--rs-text-muted); order: 999; /* always last */ margin-top: auto; @@ -1531,18 +1512,14 @@ #toolbar .toolbar-group-toggle { width: 100%; + max-width: none; text-align: left; - padding: 12px 14px; - font-size: 14px; - min-height: 44px; + padding: 14px 16px; + font-size: 16px; + min-height: 48px; overflow: visible; } - /* Hide tooltips on mobile — full text shown */ - #toolbar .toolbar-group-toggle[data-tip]::after { - display: none; - } - #toolbar .toolbar-dropdown { position: static; box-shadow: none; @@ -1551,16 +1528,17 @@ } #toolbar .toolbar-dropdown button { - padding: 12px 14px; - font-size: 14px; - min-height: 44px; + padding: 14px 16px; + font-size: 16px; + min-height: 48px; } #toolbar > button { width: 100%; text-align: left; - padding: 12px 14px; - min-height: 44px; + padding: 14px 16px; + min-height: 48px; + font-size: 16px; } #toolbar .toolbar-sep { @@ -1592,6 +1570,12 @@ max-height: 50vh; } + #toolbar-panel-body button { + padding: 14px 16px; + font-size: 16px; + min-height: 48px; + } + /* Bottom toolbar: compact on mobile */ #bottom-toolbar { bottom: 8px; @@ -1831,50 +1815,46 @@
+
- +
+
+
+ + +
+ +
+ +
+
- +
- - - - - - - - - - -
-
- -
- -
- - - - + + + +
+
- +
- - - - - + + + + + @@ -1887,41 +1867,68 @@
+
- +
- - + + + + +
+
- +
- - - - - + + + + + + +
+
- +
- - - - - - - - + + + + + +
- + +
+ +
+ + +
+
+ + +
+ +
+ + + + + +
+
+ +
@@ -3587,6 +3594,8 @@ document.getElementById("new-prompt").addEventListener("click", () => setPendingTool("folk-prompt")); document.getElementById("new-transcription").addEventListener("click", () => setPendingTool("folk-transcription")); document.getElementById("new-video-chat").addEventListener("click", () => setPendingTool("folk-video-chat")); + document.getElementById("new-record").addEventListener("click", () => setPendingTool("folk-record")); + document.getElementById("new-stream").addEventListener("click", () => setPendingTool("folk-stream")); document.getElementById("new-workflow").addEventListener("click", () => { setPendingTool("folk-workflow-block", { blockType: "trigger", @@ -4924,7 +4933,7 @@ const collapseBtn = document.getElementById("toolbar-collapse"); collapseBtn.addEventListener("click", () => { const isCollapsed = toolbarEl.classList.toggle("collapsed"); - collapseBtn.textContent = isCollapsed ? "▶" : "···"; + collapseBtn.textContent = isCollapsed ? "›" : "‹"; collapseBtn.title = isCollapsed ? "Expand toolbar" : "Minimize toolbar"; });