Merge remote-tracking branch 'origin/dev'
This commit is contained in:
commit
1e7e0f029d
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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">▾</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">×</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 ? ' · ' + 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">✎</button>
|
||||
<button class="flows-mgmt__row-btn" data-mgmt-action="duplicate" data-mgmt-id="${this.esc(f.id)}" title="Duplicate">⎘</button>
|
||||
<button class="flows-mgmt__row-btn" data-mgmt-action="export" data-mgmt-id="${this.esc(f.id)}" title="Export JSON">⬇</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">✕</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() {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 ──
|
||||
|
|
|
|||
|
|
@ -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 ──
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<HTMLElement>(".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<HTMLElement>(".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,
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
</div>
|
||||
|
||||
<div id="toolbar">
|
||||
<!-- 1. Note -->
|
||||
<div class="toolbar-group">
|
||||
<button class="toolbar-group-toggle" data-tip="Note">📝 Note</button>
|
||||
<button class="toolbar-group-toggle">📝 Note</button>
|
||||
<div class="toolbar-dropdown">
|
||||
<button id="new-markdown" title="New Note">📝 Note</button>
|
||||
<button id="new-slide" title="New Slide">🎞️ Slide</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Connect -->
|
||||
<div class="toolbar-group">
|
||||
<button class="toolbar-group-toggle">🤝 Connect</button>
|
||||
<div class="toolbar-dropdown">
|
||||
<button id="new-chat" title="New Chat">💬 Chat</button>
|
||||
<button id="new-video-chat" title="Video Meet">📹 Video Meet</button>
|
||||
<button id="new-record" title="Record" class="toolbar-disabled">🔴 Record</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Media -->
|
||||
<div class="toolbar-group">
|
||||
<button class="toolbar-group-toggle" data-tip="Creative">🔮 Creative</button>
|
||||
<button class="toolbar-group-toggle">🎵 Media</button>
|
||||
<div class="toolbar-dropdown">
|
||||
<button id="new-image-gen" title="New AI Image">🎨 AI Image</button>
|
||||
<button id="new-video-gen" title="New AI Video">🎬 AI Video</button>
|
||||
<button id="new-zine-gen" title="Zine Generator">📰 Zine Gen</button>
|
||||
<button id="new-splat" title="New 3D Splat">🔮 3D Splat</button>
|
||||
<button id="new-blender" title="New 3D Blender">🧊 3D Blender</button>
|
||||
<button id="new-drawfast" title="New Drawing">✏️ Drawfast</button>
|
||||
<button id="new-freecad" title="New FreeCAD">📐 FreeCAD</button>
|
||||
<button id="new-kicad" title="New KiCAD PCB">🔌 KiCAD PCB</button>
|
||||
<button id="embed-swag" title="Embed rSwag">🎨 rSwag</button>
|
||||
<button id="embed-pubs" title="Embed rPubs">📖 rPubs</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-group">
|
||||
<button class="toolbar-group-toggle" data-tip="Media">🎨 Media</button>
|
||||
<div class="toolbar-dropdown">
|
||||
<button id="new-transcription" title="New Transcription">🎤 Transcribe</button>
|
||||
<button id="new-video-chat" title="New Video Call">📹 Video Call</button>
|
||||
<button id="new-piano" title="New Piano">🎹 Piano</button>
|
||||
<button id="embed-photos" title="Embed rPhotos">📸 rPhotos</button>
|
||||
<button id="new-transcription" title="Transcribe">🎤 Transcribe</button>
|
||||
<button id="new-stream" title="Stream" class="toolbar-disabled">📡 Stream</button>
|
||||
<button id="new-piano" title="Piano">🎹 Piano</button>
|
||||
<button id="embed-tube" title="Embed rTube">🎬 rTube</button>
|
||||
<button id="embed-photos" title="Embed rPhotos">📸 rPhotos</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. Embed -->
|
||||
<div class="toolbar-group">
|
||||
<button class="toolbar-group-toggle" data-tip="Embed">🔗 Embed</button>
|
||||
<button class="toolbar-group-toggle">🔗 Embed</button>
|
||||
<div class="toolbar-dropdown">
|
||||
<button id="new-embed" title="New Embed">🔗 Web Embed</button>
|
||||
<button id="new-google-item" title="New Google Item">📎 Google</button>
|
||||
<button id="new-calendar" title="New Calendar">📅 Calendar</button>
|
||||
<button id="new-map" title="New Map">🗺️ Map</button>
|
||||
<button id="new-social-post" title="New Post">📱 Social Post</button>
|
||||
<button id="new-embed" title="Web Embed">🔗 Web Embed</button>
|
||||
<button id="new-google-item" title="Google">📎 Google</button>
|
||||
<button id="new-calendar" title="Calendar">📅 Calendar</button>
|
||||
<button id="new-map" title="Map">🗺️ Map</button>
|
||||
<button id="new-social-post" title="Social Post">📱 Social Post</button>
|
||||
<button id="embed-notes" title="Embed rNotes">📝 rNotes</button>
|
||||
<button id="embed-books" title="Embed rBooks">📚 rBooks</button>
|
||||
<button id="embed-forum" title="Embed rForum">💬 rForum</button>
|
||||
|
|
@ -1887,41 +1867,68 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 5. AI -->
|
||||
<div class="toolbar-group">
|
||||
<button class="toolbar-group-toggle" data-tip="AI">🤖 AI</button>
|
||||
<button class="toolbar-group-toggle">🤖 AI</button>
|
||||
<div class="toolbar-dropdown">
|
||||
<button id="new-prompt" title="New AI Chat">🤖 AI Chat</button>
|
||||
<button id="new-workflow" title="New Workflow">⚙️ Workflow</button>
|
||||
<button id="new-prompt" title="AI Chat">🤖 AI Chat</button>
|
||||
<button id="new-workflow" title="Workflow">⚙️ Workflow</button>
|
||||
<button id="new-image-gen" title="AI Image">🎨 AI Image</button>
|
||||
<button id="new-video-gen" title="AI Video">🎬 AI Video</button>
|
||||
<button id="new-zine-gen" title="Zine Gen">📰 Zine Gen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 6. Create -->
|
||||
<div class="toolbar-group">
|
||||
<button class="toolbar-group-toggle" data-tip="Travel">✈️ Travel</button>
|
||||
<button class="toolbar-group-toggle">🛠️ Create</button>
|
||||
<div class="toolbar-dropdown">
|
||||
<button id="new-itinerary" title="New Itinerary">🗓️ Itinerary</button>
|
||||
<button id="new-destination" title="New Destination">📍 Destination</button>
|
||||
<button id="new-budget" title="New Budget">💰 Budget</button>
|
||||
<button id="new-packing-list" title="New Packing List">🎒 Packing List</button>
|
||||
<button id="new-booking" title="New Booking">✈️ Booking</button>
|
||||
<button id="new-splat" title="3D Splat">🔮 3D Splat</button>
|
||||
<button id="new-blender" title="3D Blender">🧊 Blender</button>
|
||||
<button id="new-drawfast" title="Drawfast">✏️ Drawfast</button>
|
||||
<button id="new-freecad" title="FreeCAD">📐 FreeCAD</button>
|
||||
<button id="new-kicad" title="KiCAD PCB">🔌 KiCAD</button>
|
||||
<button id="embed-swag" title="Embed rSwag">🎨 rSwag</button>
|
||||
<button id="embed-pubs" title="Embed rPubs">📖 rPubs</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 7. Decide -->
|
||||
<div class="toolbar-group">
|
||||
<button class="toolbar-group-toggle" data-tip="Decide">📊 Decide</button>
|
||||
<button class="toolbar-group-toggle">📊 Decide</button>
|
||||
<div class="toolbar-dropdown">
|
||||
<button id="new-choice-vote" title="New Poll">☑ Poll</button>
|
||||
<button id="new-choice-rank" title="New Ranking">📊 Ranking</button>
|
||||
<button id="new-choice-spider" title="New Scoring">🕸 Scoring</button>
|
||||
<button id="new-spider-3d" title="3D Spider Plot">📊 3D Spider</button>
|
||||
<button id="new-conviction" title="Conviction Ranking">⏳ Conviction</button>
|
||||
<button id="new-token" title="New Token">🪙 Token</button>
|
||||
<button id="embed-flows" title="Embed rFlows">🌊 rFlows</button>
|
||||
<button id="embed-wallet" title="Embed rWallet">💰 rWallet</button>
|
||||
<button id="new-choice-vote" title="Poll">☑ Poll</button>
|
||||
<button id="new-choice-rank" title="Ranking">📊 Ranking</button>
|
||||
<button id="new-choice-spider" title="Scoring">🕸 Scoring</button>
|
||||
<button id="new-spider-3d" title="3D Spider">📊 3D Spider</button>
|
||||
<button id="new-conviction" title="Conviction">⏳ Conviction</button>
|
||||
<button id="new-token" title="Token">🪙 Token</button>
|
||||
<button id="embed-vote" title="Embed rVote">🗳️ rVote</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="toolbar-collapse" title="Minimize toolbar">···</button>
|
||||
<!-- 8. Spend -->
|
||||
<div class="toolbar-group">
|
||||
<button class="toolbar-group-toggle">💰 Spend</button>
|
||||
<div class="toolbar-dropdown">
|
||||
<button id="embed-wallet" title="rWallet">💰 rWallet</button>
|
||||
<button id="embed-flows" title="rFlows">🌊 rFlows</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 9. Travel -->
|
||||
<div class="toolbar-group">
|
||||
<button class="toolbar-group-toggle">✈️ Travel</button>
|
||||
<div class="toolbar-dropdown">
|
||||
<button id="new-itinerary" title="Itinerary">🗓️ Itinerary</button>
|
||||
<button id="new-destination" title="Destination">📍 Destination</button>
|
||||
<button id="new-budget" title="Budget">💰 Budget</button>
|
||||
<button id="new-packing-list" title="Packing List">🎒 Packing List</button>
|
||||
<button id="new-booking" title="Booking">✈️ Booking</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="toolbar-collapse" title="Minimize toolbar">‹</button>
|
||||
</div>
|
||||
|
||||
<div id="toolbar-panel">
|
||||
|
|
@ -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";
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue