mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Merge pull request #302 from ArnasDon/feat/flows-editor-redesign
Redesign flow editor UI to match Flow Builder design
This commit is contained in:
commit
b2ed58a41f
@ -46,6 +46,8 @@ import { cn } from "@/lib/utils";
|
||||
import { type ValidationIssue } from "@/lib/flows/validate";
|
||||
import {
|
||||
NODE_META,
|
||||
NodeIconChip,
|
||||
nodeColors,
|
||||
slugify,
|
||||
summarizeNode,
|
||||
type BuilderNode,
|
||||
@ -150,7 +152,7 @@ export function FlowBuilder() {
|
||||
}, [flashKey]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-7">
|
||||
<TriggerPanel
|
||||
state={state}
|
||||
setState={setState}
|
||||
@ -390,13 +392,14 @@ function NodeCard({
|
||||
onSetEntry: () => void;
|
||||
}) {
|
||||
const meta = NODE_META[node.node_type];
|
||||
const c = nodeColors(node.node_type);
|
||||
const hasError = issues.some((i) => i.severity === "error");
|
||||
const preview = summarizeNode(node);
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card transition-shadow duration-500",
|
||||
"relative overflow-hidden rounded-xl border bg-card transition-shadow duration-500",
|
||||
hasError
|
||||
? "border-red-500/40"
|
||||
: isEntry
|
||||
@ -406,15 +409,23 @@ function NodeCard({
|
||||
"ring-2 ring-primary ring-offset-2 ring-offset-background",
|
||||
)}
|
||||
>
|
||||
{/* type-colored left rail, ties the list row to the canvas hue */}
|
||||
<span
|
||||
className="absolute inset-y-0 left-0 w-[3px]"
|
||||
style={{ background: c.solid }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left"
|
||||
className="flex w-full items-center gap-3 px-4 py-3 pl-5 text-left"
|
||||
>
|
||||
<meta.icon className={cn("h-4 w-4 shrink-0", meta.color)} />
|
||||
<NodeIconChip type={node.node_type} size={32} iconSize={16} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
<span
|
||||
className="truncate text-[11px] font-semibold uppercase tracking-wider"
|
||||
style={{ color: c.text }}
|
||||
>
|
||||
{meta.label}
|
||||
</span>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
|
||||
@ -40,6 +40,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
applyNodeChanges,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Controls,
|
||||
Handle,
|
||||
MiniMap,
|
||||
@ -76,6 +77,8 @@ import {
|
||||
import { autoLayout, shouldAutoLayout } from "@/lib/flows/layout";
|
||||
import {
|
||||
NODE_META,
|
||||
NodeIconChip,
|
||||
nodeColors,
|
||||
summarizeNode,
|
||||
type BuilderNode,
|
||||
type NodeType,
|
||||
@ -109,11 +112,26 @@ const NODE_HEIGHT = 90;
|
||||
// view's collapsed card so the two views feel like the same product.
|
||||
// ============================================================
|
||||
|
||||
// Echo the design's green/red branch ports for the condition node's
|
||||
// true / false slots; every other slot inherits the node's own hue.
|
||||
// The true/false hues are derived from the start (emerald) and handoff
|
||||
// (rose) node colors so all branch/port colors stay single-sourced in
|
||||
// NODE_HUE — a palette tweak there can't leave these stale.
|
||||
function slotColor(nodeType: NodeType, slotId: string, fallback: string) {
|
||||
if (nodeType === "condition" && slotId === "true") {
|
||||
return nodeColors("start").solid;
|
||||
}
|
||||
if (nodeType === "condition" && slotId === "false") {
|
||||
return nodeColors("handoff").solid;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function FlowNodeCard({ data, selected }: NodeProps) {
|
||||
const { node, isEntry, isFlashed } = data as NodeData;
|
||||
const meta = NODE_META[node.node_type];
|
||||
const c = nodeColors(node.node_type);
|
||||
const summary = summarizeNode(node);
|
||||
const Icon = meta.icon;
|
||||
const slots = outgoingSlots(node);
|
||||
// Start nodes are entry-only; nothing ever targets them, so they
|
||||
// don't need an incoming Handle. Every other node type accepts
|
||||
@ -127,11 +145,23 @@ function FlowNodeCard({ data, selected }: NodeProps) {
|
||||
const isMultiSlot = slots.length > 1;
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--nc": c.solid,
|
||||
"--nc-soft": c.soft,
|
||||
"--nc-ring": c.ring,
|
||||
"--nc-text": c.text,
|
||||
borderColor: selected ? c.solid : undefined,
|
||||
boxShadow: selected
|
||||
? `0 0 0 1px ${c.solid}, 0 14px 36px -12px ${c.ring}`
|
||||
: undefined,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"relative min-w-[220px] max-w-[260px] rounded-lg border bg-card/95 px-3 py-2 text-left shadow-lg backdrop-blur transition-colors",
|
||||
"relative min-w-[220px] max-w-[260px] rounded-xl border bg-card px-3.5 py-3 text-left shadow-[0_2px_6px_rgba(0,0,0,0.18)] transition-[box-shadow,border-color]",
|
||||
selected
|
||||
? "border-primary ring-1 ring-primary/40"
|
||||
: "border-border hover:border-border",
|
||||
? "border-[var(--nc)]"
|
||||
: "border-border hover:border-[var(--nc-ring)]",
|
||||
// Flash overrides hover/selected colors briefly. Tailwind's
|
||||
// built-in `animate-pulse` is too gentle; a ring with the
|
||||
// amber accent matches the list view's flash semantics.
|
||||
@ -142,32 +172,40 @@ function FlowNodeCard({ data, selected }: NodeProps) {
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!h-2.5 !w-2.5 !border-border !bg-muted"
|
||||
className="!h-2.5 !w-2.5 !border-2 !border-[var(--nc-ring)] !bg-card"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={cn("h-3.5 w-3.5 shrink-0", meta.color)} />
|
||||
<span className="truncate text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<NodeIconChip
|
||||
type={node.node_type}
|
||||
size={24}
|
||||
iconSize={14}
|
||||
className="rounded-md"
|
||||
/>
|
||||
<span
|
||||
className="truncate text-[10.5px] font-semibold uppercase tracking-wider"
|
||||
style={{ color: c.text }}
|
||||
>
|
||||
{meta.label}
|
||||
</span>
|
||||
{isEntry && (
|
||||
<span className="ml-auto rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-emerald-300">
|
||||
<span className="ml-auto rounded border border-border px-1.5 py-0.5 text-[8.5px] font-bold uppercase tracking-[0.1em] text-muted-foreground">
|
||||
Entry
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 truncate font-mono text-[11px] text-muted-foreground">
|
||||
<div className="mt-2 truncate font-mono text-[11px] text-muted-foreground">
|
||||
{node.node_key}
|
||||
</div>
|
||||
{summary && (
|
||||
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||
<div className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
|
||||
{summary}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isMultiSlot && (
|
||||
<div className="mt-2 flex flex-col gap-1 border-t border-border pt-2">
|
||||
<div className="mt-2.5 flex flex-col gap-1 border-t border-border pt-2.5">
|
||||
{slots.map((slot) => (
|
||||
<div
|
||||
key={slot.id}
|
||||
@ -180,11 +218,14 @@ function FlowNodeCard({ data, selected }: NodeProps) {
|
||||
type="source"
|
||||
id={slot.id}
|
||||
position={Position.Right}
|
||||
style={{
|
||||
borderColor: slotColor(node.node_type, slot.id, c.solid),
|
||||
}}
|
||||
// Override default absolute positioning so the handle
|
||||
// sits flush with the right edge of the card instead
|
||||
// of floating at vertical center. The negative offset
|
||||
// matches the card's px-3 + the handle's own radius.
|
||||
className="!relative !right-auto !top-auto !h-2.5 !w-2.5 !translate-x-[12px] !transform-none !border-border !bg-muted"
|
||||
className="!relative !right-auto !top-auto !h-2.5 !w-2.5 !translate-x-[14px] !transform-none !border-2 !bg-card"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@ -196,7 +237,8 @@ function FlowNodeCard({ data, selected }: NodeProps) {
|
||||
type="source"
|
||||
id={slots[0].id}
|
||||
position={Position.Right}
|
||||
className="!h-2.5 !w-2.5 !border-border !bg-muted"
|
||||
style={{ borderColor: c.solid }}
|
||||
className="!h-2.5 !w-2.5 !border-2 !bg-card"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -457,7 +499,7 @@ function FlowCanvasInner() {
|
||||
|
||||
if (rfNodes.length === 0) {
|
||||
return (
|
||||
<div className="flex h-[60vh] flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-background text-sm text-muted-foreground">
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
|
||||
<p>No nodes yet.</p>
|
||||
<CanvasAddNodeButton />
|
||||
</div>
|
||||
@ -466,7 +508,7 @@ function FlowCanvasInner() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-[70vh] w-full overflow-hidden rounded-lg border border-border bg-background">
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<ReactFlow
|
||||
nodes={rfNodes}
|
||||
edges={rfEdges}
|
||||
@ -492,19 +534,29 @@ function FlowCanvasInner() {
|
||||
minZoom={0.2}
|
||||
maxZoom={1.5}
|
||||
>
|
||||
<Background gap={24} size={1} color="var(--border)" />
|
||||
{/* Dot grid, matching the design's faint canvas backdrop. */}
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={22}
|
||||
size={1.4}
|
||||
color="var(--border)"
|
||||
/>
|
||||
<Controls
|
||||
className="!border-border !bg-card [&_button]:!border-border [&_button]:!bg-card [&_button:hover]:!bg-muted [&_button_svg]:!fill-foreground"
|
||||
className="!overflow-hidden !rounded-xl !border !border-border !bg-card !shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)] [&_button]:!border-border [&_button]:!bg-card [&_button:hover]:!bg-muted [&_button_svg]:!fill-foreground"
|
||||
showInteractive={false}
|
||||
/>
|
||||
<MiniMap
|
||||
pannable
|
||||
zoomable
|
||||
nodeColor="var(--muted-foreground)"
|
||||
nodeColor={(n) =>
|
||||
nodeColors((n.data as NodeData).node.node_type).solid
|
||||
}
|
||||
nodeStrokeWidth={0}
|
||||
nodeBorderRadius={3}
|
||||
maskColor="color-mix(in oklch, var(--background) 70%, transparent)"
|
||||
className="!border !border-border !bg-card"
|
||||
className="!rounded-xl !border !border-border !bg-card !shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)]"
|
||||
/>
|
||||
<Panel position="bottom-right" className="!bottom-4 !right-4">
|
||||
<Panel position="top-left" className="!left-4 !top-4">
|
||||
<CanvasAddNodeButton />
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
@ -557,26 +609,31 @@ function NodeEditSheet({
|
||||
);
|
||||
}
|
||||
const meta = NODE_META[node.node_type];
|
||||
const Icon = meta.icon;
|
||||
const c = nodeColors(node.node_type);
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex w-full flex-col gap-0 border-l border-border bg-popover p-0 sm:max-w-md"
|
||||
>
|
||||
<SheetHeader className="border-b border-border px-5 py-4">
|
||||
<SheetTitle className="flex items-center gap-2 text-popover-foreground">
|
||||
<Icon className={cn("h-4 w-4 shrink-0", meta.color)} />
|
||||
<span>{meta.label}</span>
|
||||
<SheetHeader className="flex-row items-center gap-3 space-y-0 border-b border-border px-5 py-4">
|
||||
<NodeIconChip type={node.node_type} size={36} iconSize={18} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<SheetTitle className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider">
|
||||
<span style={{ color: c.text }}>{meta.label}</span>
|
||||
{isEntry && (
|
||||
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-300">
|
||||
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-emerald-300">
|
||||
Entry
|
||||
</span>
|
||||
)}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="font-mono text-[11px] text-muted-foreground">
|
||||
{node.node_key}
|
||||
<SheetDescription className="mt-0.5 text-xs text-muted-foreground">
|
||||
{meta.blurb}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
<code className="shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||||
{node.node_key}
|
||||
</code>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-5 py-4">
|
||||
@ -658,20 +715,36 @@ function CanvasAddNodeButton() {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-xs font-medium text-foreground shadow-lg transition-colors hover:bg-muted"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3.5 py-2 text-[13px] font-medium text-primary-foreground shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)] transition-colors hover:bg-primary-hover"
|
||||
aria-label="Add node"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
<Plus className="h-4 w-4" />
|
||||
Add node
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="border-border bg-popover">
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-[268px] border-border bg-popover p-1.5"
|
||||
>
|
||||
<div className="px-2 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Add a step
|
||||
</div>
|
||||
{ADD_NODE_TYPES.map((t) => {
|
||||
const meta = NODE_META[t];
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<DropdownMenuItem key={t} onClick={() => handleAdd(t)}>
|
||||
<Icon className={cn("h-3.5 w-3.5", meta.color)} />
|
||||
<DropdownMenuItem
|
||||
key={t}
|
||||
onClick={() => handleAdd(t)}
|
||||
className="gap-3 py-2"
|
||||
>
|
||||
<NodeIconChip type={t} size={28} iconSize={16} className="rounded-md" />
|
||||
<span className="flex flex-col">
|
||||
<span className="text-[13px] font-semibold text-popover-foreground">
|
||||
{meta.label}
|
||||
</span>
|
||||
<span className="text-[11.5px] text-muted-foreground">
|
||||
{meta.blurb}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -1,11 +1,19 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* View-switcher for the flow editor.
|
||||
* View-switcher + chrome for the flow editor.
|
||||
*
|
||||
* Renders a small Canvas / List pill above whichever view is active,
|
||||
* and conditionally mounts `<FlowCanvas>` or `<FlowBuilder>`. Why a
|
||||
* separate component:
|
||||
* Lays the editor out as one app-like column that fills the dashboard
|
||||
* content area (toolbar → mode row → stage → validation bar), matching
|
||||
* the Flow Builder design handoff:
|
||||
* - A segmented Canvas / List control on the left of the mode row.
|
||||
* - A node-type legend on the right so the canvas's per-type colors
|
||||
* are decodable at a glance.
|
||||
* - The active view is mounted inside a rounded "stage" that owns its
|
||||
* own scroll/overflow, so the canvas can fill available height and
|
||||
* the list scrolls internally.
|
||||
*
|
||||
* Why a separate component:
|
||||
* - The page itself stays trivially small (loading + error + this).
|
||||
* - Either view can stay unaware of the other — they share data
|
||||
* (`{flow, nodes}`) and nothing else.
|
||||
@ -17,13 +25,14 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { LayoutGrid, ListTree } from "lucide-react";
|
||||
import { GitFork, List } from "lucide-react";
|
||||
|
||||
import { FlowBuilder } from "./flow-builder";
|
||||
import { FlowCanvas } from "./flow-canvas";
|
||||
import { FlowEditorProvider } from "./flow-editor-state";
|
||||
import { EditorHeader } from "./header";
|
||||
import { ValidationPanel } from "./validation-panel";
|
||||
import { NODE_META, nodeColors, type NodeType } from "./shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FlowRow, FlowNodeRow } from "@/lib/flows/types";
|
||||
|
||||
@ -39,6 +48,11 @@ type View = "canvas" | "list";
|
||||
|
||||
const STORAGE_KEY = "wacrm.flowEditor.view";
|
||||
|
||||
// Legend covers every node type, derived from NODE_META so a new type
|
||||
// can't silently go undocumented. NODE_META's key order already reads
|
||||
// the way a flow flows: start → talk → capture → branch → mutate → end.
|
||||
const LEGEND_TYPES = Object.keys(NODE_META) as NodeType[];
|
||||
|
||||
interface Props {
|
||||
initialFlow: FlowRow;
|
||||
initialNodes: FlowNodeRow[];
|
||||
@ -78,37 +92,63 @@ export function FlowEditorShell({ initialFlow, initialNodes }: Props) {
|
||||
|
||||
return (
|
||||
<FlowEditorProvider initialFlow={initialFlow} initialNodes={initialNodes}>
|
||||
<div className="mx-auto flex h-full max-w-4xl flex-col gap-6 p-6">
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<EditorHeader />
|
||||
|
||||
{/* ---- mode row: view toggle + node-type legend ----
|
||||
Omitted entirely on mobile (canvas is unavailable there and
|
||||
the legend is lg-only), so there's no empty band above the
|
||||
stage on small screens. */}
|
||||
{!isMobile && (
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex items-center gap-4 px-6 py-3.5">
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Editor view"
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border bg-card p-0.5 text-xs"
|
||||
className="inline-flex gap-0.5 rounded-lg border border-border bg-muted p-0.5"
|
||||
>
|
||||
<ToggleButton
|
||||
<SegButton
|
||||
active={effectiveView === "canvas"}
|
||||
onClick={() => choose("canvas")}
|
||||
icon={<LayoutGrid className="h-3 w-3" />}
|
||||
icon={<GitFork className="h-3.5 w-3.5" />}
|
||||
label="Canvas"
|
||||
/>
|
||||
<ToggleButton
|
||||
<SegButton
|
||||
active={effectiveView === "list"}
|
||||
onClick={() => choose("list")}
|
||||
icon={<ListTree className="h-3 w-3" />}
|
||||
icon={<List className="h-3.5 w-3.5" />}
|
||||
label="List"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-auto hidden flex-wrap items-center gap-x-3.5 gap-y-1.5 lg:flex">
|
||||
{LEGEND_TYPES.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="inline-flex items-center gap-1.5 text-[11.5px] text-muted-foreground"
|
||||
>
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ background: nodeColors(t).solid }}
|
||||
/>
|
||||
{NODE_META[t].label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectiveView === "canvas" ? <FlowCanvas /> : <FlowBuilder />}
|
||||
{/* ---- stage: the active view, owning its own overflow ---- */}
|
||||
<div className="relative mx-6 min-h-0 flex-1 overflow-hidden rounded-xl border border-border bg-card-2">
|
||||
{effectiveView === "canvas" ? (
|
||||
<FlowCanvas />
|
||||
) : (
|
||||
<div className="absolute inset-0 overflow-y-auto">
|
||||
<FlowBuilder />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sticky-bottom validation panel mirrors the placement used
|
||||
when this lived inside FlowBuilder — the activate-readiness
|
||||
status follows the user as they scroll, in either view. */}
|
||||
<div className="sticky bottom-4 z-10 shadow-xl shadow-background/60">
|
||||
{/* ---- validation / activate-readiness bar ---- */}
|
||||
<div className="px-6 pb-5 pt-3">
|
||||
<ValidationPanel />
|
||||
</div>
|
||||
</div>
|
||||
@ -138,7 +178,7 @@ function useMatchMedia(query: string): boolean {
|
||||
return matches;
|
||||
}
|
||||
|
||||
function ToggleButton({
|
||||
function SegButton({
|
||||
active,
|
||||
onClick,
|
||||
icon,
|
||||
@ -155,10 +195,10 @@ function ToggleButton({
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded px-2 py-1 transition-colors",
|
||||
"inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[12.5px] font-medium transition-colors",
|
||||
active
|
||||
? "bg-secondary text-secondary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
? "bg-card text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
|
||||
@ -1,11 +1,18 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Editor header — flow name / description, status badge, dirty
|
||||
* Editor toolbar — flow name / description, status chip, dirty
|
||||
* indicator, and the action buttons (Save, Activate/Pause, Delete,
|
||||
* View runs, Back).
|
||||
*
|
||||
* Lifted out of flow-builder.tsx so the same header renders above
|
||||
* Restyled to the Flow Builder design handoff: a single compact
|
||||
* toolbar row (back · icon · inline-editable name · status chip ·
|
||||
* edited dot on the left; Runs · Delete · Activate · Save on the
|
||||
* right) followed by a subtle, full-width description "note" line.
|
||||
* Replaces the old three-row stack so the editor reads as one app
|
||||
* chrome bar above the canvas/list stage.
|
||||
*
|
||||
* Lifted out of flow-builder.tsx so the same toolbar renders above
|
||||
* both views in FlowEditorShell. Without this, canvas users had no
|
||||
* way to save without toggling to list view.
|
||||
*
|
||||
@ -18,6 +25,7 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CircleDot,
|
||||
History,
|
||||
Loader2,
|
||||
PauseCircle,
|
||||
@ -27,9 +35,7 @@ import {
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
useFlowEditor,
|
||||
@ -52,32 +58,33 @@ export function EditorHeader() {
|
||||
} = useFlowEditor();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<div className="flex flex-col gap-1.5 px-6 pt-5">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* ---- left: back · icon · name · status · edited ---- */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/flows")}
|
||||
className="inline-flex items-center gap-1 hover:text-foreground"
|
||||
title="Back to Flows"
|
||||
aria-label="Back to Flows"
|
||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-3 w-3" />
|
||||
Flows
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<Workflow className="h-5 w-5 shrink-0 text-primary" />
|
||||
<Input
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary-soft text-primary">
|
||||
<Workflow className="h-[18px] w-[18px]" />
|
||||
</span>
|
||||
<input
|
||||
value={state.name}
|
||||
onChange={(e) =>
|
||||
setState((s) => ({ ...s, name: e.target.value }))
|
||||
}
|
||||
onChange={(e) => setState((s) => ({ ...s, name: e.target.value }))}
|
||||
placeholder="Flow name"
|
||||
className="max-w-md bg-card text-lg font-semibold"
|
||||
spellCheck={false}
|
||||
aria-label="Flow name"
|
||||
className="min-w-[120px] max-w-[340px] rounded-lg border border-transparent bg-transparent px-2 py-1 text-lg font-bold leading-tight tracking-tight text-foreground outline-none transition-colors hover:bg-muted focus:border-primary focus:bg-transparent focus:shadow-[0_0_0_3px_var(--primary-soft)]"
|
||||
/>
|
||||
<StatusBadge status={state.status} />
|
||||
<StatusChip status={state.status} />
|
||||
{dirty && (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-amber-300"
|
||||
className="inline-flex shrink-0 items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-amber-300"
|
||||
title="Unsaved changes — hit Save to persist"
|
||||
aria-live="polite"
|
||||
>
|
||||
@ -85,8 +92,9 @@ export function EditorHeader() {
|
||||
Edited
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
|
||||
{/* ---- right: runs · delete · activate · save ---- */}
|
||||
<div className="ml-auto flex flex-wrap items-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@ -94,6 +102,9 @@ export function EditorHeader() {
|
||||
>
|
||||
<History className="h-3.5 w-3.5" />
|
||||
Runs
|
||||
<span className="ml-0.5 rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground">
|
||||
{flow.execution_count}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@ -148,27 +159,47 @@ export function EditorHeader() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
|
||||
{/* ---- description note (subtle, inline-editable) ---- */}
|
||||
<input
|
||||
value={state.description}
|
||||
onChange={(e) =>
|
||||
setState((s) => ({ ...s, description: e.target.value }))
|
||||
}
|
||||
placeholder="Optional description (internal — customers don't see this)"
|
||||
className="bg-card text-sm"
|
||||
placeholder="Add a short description (internal — customers don't see this)"
|
||||
aria-label="Flow description"
|
||||
className="w-full max-w-[78ch] rounded-md border border-transparent bg-transparent px-2 py-1 text-[13px] text-muted-foreground outline-none transition-colors placeholder:text-muted-foreground/60 hover:bg-muted/50 focus:border-primary focus:bg-transparent focus:text-foreground"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: BuilderState["status"] }) {
|
||||
const cls = {
|
||||
draft: "border-border bg-muted text-muted-foreground",
|
||||
active: "border-emerald-600/40 bg-emerald-500/10 text-emerald-300",
|
||||
archived: "border-border bg-muted/50 text-muted-foreground",
|
||||
function StatusChip({ status }: { status: BuilderState["status"] }) {
|
||||
const cfg = {
|
||||
draft: {
|
||||
// Neutral, not amber — amber is reserved for the adjacent
|
||||
// "Edited" dirty signal, so the two don't read as the same alert.
|
||||
cls: "border-border bg-muted text-muted-foreground",
|
||||
label: "Draft",
|
||||
},
|
||||
active: {
|
||||
cls: "border-emerald-600/40 bg-emerald-500/10 text-emerald-300",
|
||||
label: "Active",
|
||||
},
|
||||
archived: {
|
||||
cls: "border-border bg-muted/50 text-muted-foreground",
|
||||
label: "Archived",
|
||||
},
|
||||
}[status];
|
||||
return (
|
||||
<Badge variant="outline" className={cn("shrink-0", cls)}>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</Badge>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11.5px] font-medium",
|
||||
cfg.cls,
|
||||
)}
|
||||
>
|
||||
<CircleDot className="h-3 w-3" />
|
||||
{cfg.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@ -30,6 +30,8 @@ import {
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ============================================================
|
||||
// Node-type union — single source of truth for every place the UI
|
||||
// enumerates types (add menu, type pickers, switch statements). Kept
|
||||
@ -67,52 +69,159 @@ export interface BuilderNode {
|
||||
|
||||
export const NODE_META: Record<
|
||||
NodeType,
|
||||
{ label: string; icon: typeof Workflow; color: string }
|
||||
{ label: string; icon: typeof Workflow; color: string; blurb: string }
|
||||
> = {
|
||||
start: { label: "Start", icon: PlayCircle, color: "text-emerald-400" },
|
||||
start: {
|
||||
label: "Start",
|
||||
icon: PlayCircle,
|
||||
color: "text-emerald-400",
|
||||
blurb: "Entry point of the flow",
|
||||
},
|
||||
send_message: {
|
||||
label: "Send message",
|
||||
icon: MessageCircle,
|
||||
color: "text-sky-400",
|
||||
blurb: "Sends a WhatsApp text message",
|
||||
},
|
||||
send_buttons: {
|
||||
label: "Send buttons",
|
||||
icon: ListChecks,
|
||||
color: "text-primary",
|
||||
blurb: "Sends quick-reply buttons",
|
||||
},
|
||||
send_list: {
|
||||
label: "Send list",
|
||||
icon: ListPlus,
|
||||
color: "text-indigo-400",
|
||||
blurb: "Sends a tappable list of options",
|
||||
},
|
||||
send_media: {
|
||||
label: "Send media",
|
||||
icon: Paperclip,
|
||||
color: "text-cyan-400",
|
||||
blurb: "Sends an image, video, or document",
|
||||
},
|
||||
collect_input: {
|
||||
label: "Collect input",
|
||||
icon: Inbox,
|
||||
color: "text-teal-400",
|
||||
blurb: "Asks a question, saves the reply",
|
||||
},
|
||||
condition: {
|
||||
label: "If / else",
|
||||
icon: GitFork,
|
||||
color: "text-fuchsia-400",
|
||||
blurb: "Branches on a rule",
|
||||
},
|
||||
set_tag: {
|
||||
label: "Tag contact",
|
||||
icon: Tag,
|
||||
color: "text-pink-400",
|
||||
blurb: "Adds or removes a contact tag",
|
||||
},
|
||||
handoff: {
|
||||
label: "Handoff to agent",
|
||||
icon: UserPlus,
|
||||
color: "text-amber-400",
|
||||
blurb: "Hands the conversation to a human",
|
||||
},
|
||||
end: {
|
||||
label: "End",
|
||||
icon: Flag,
|
||||
color: "text-muted-foreground",
|
||||
blurb: "Ends the flow",
|
||||
},
|
||||
end: { label: "End", icon: Flag, color: "text-muted-foreground" },
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Per-node-type color system.
|
||||
//
|
||||
// Each node type gets its own hue so the canvas reads at a glance —
|
||||
// what KIND of step is this. Kept as raw oklch (not Tailwind classes)
|
||||
// so a node card can tint its icon chip, type label, selection ring,
|
||||
// and edge ports from one source, the way the Flow Builder design
|
||||
// handoff does. Hues sit in the same oklch family as the app tokens
|
||||
// in globals.css; they don't replace --primary (the accent), they
|
||||
// complement it. `nodeColors()` derives the soft/ring/text variants.
|
||||
// ============================================================
|
||||
|
||||
const NODE_HUE: Record<NodeType, { l: number; c: number; h: number }> = {
|
||||
start: { l: 0.62, c: 0.13, h: 162 }, // emerald — the start, echoes WhatsApp green
|
||||
send_message: { l: 0.6, c: 0.18, h: 293 }, // violet — the workhorse
|
||||
send_buttons: { l: 0.62, c: 0.16, h: 254 }, // cobalt
|
||||
send_list: { l: 0.62, c: 0.15, h: 277 }, // indigo
|
||||
send_media: { l: 0.65, c: 0.12, h: 210 }, // sky
|
||||
collect_input: { l: 0.65, c: 0.1, h: 185 }, // teal — capture
|
||||
condition: { l: 0.72, c: 0.15, h: 65 }, // amber — a fork in the road
|
||||
set_tag: { l: 0.65, c: 0.15, h: 350 }, // pink
|
||||
handoff: { l: 0.65, c: 0.17, h: 16 }, // rose — hands off
|
||||
end: { l: 0.55, c: 0.01, h: 260 }, // neutral grey — terminal
|
||||
};
|
||||
|
||||
export interface NodeColors {
|
||||
/** Full-strength hue — icon glyph, selection ring, port fill. */
|
||||
solid: string;
|
||||
/** ~14% tint — icon chip background, soft fills. */
|
||||
soft: string;
|
||||
/** ~45% tint — hover border / focus ring. */
|
||||
ring: string;
|
||||
/** Hue for the uppercase type label, kept readable in BOTH modes. */
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function nodeColors(type: NodeType): NodeColors {
|
||||
const t = NODE_HUE[type];
|
||||
const solid = `oklch(${t.l} ${t.c} ${t.h})`;
|
||||
return {
|
||||
solid,
|
||||
soft: `oklch(${t.l} ${t.c} ${t.h} / 0.14)`,
|
||||
ring: `oklch(${t.l} ${t.c} ${t.h} / 0.45)`,
|
||||
// Blend the hue toward the live --foreground token so the label
|
||||
// holds contrast in BOTH modes: in dark mode --foreground is
|
||||
// near-white (the label lightens to read on the dark card), in
|
||||
// light mode it's near-black (the label darkens to read on the
|
||||
// white card). The old fixed-light value only worked on dark.
|
||||
text: `color-mix(in oklch, ${solid}, var(--foreground) 38%)`,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Shared node icon chip — the per-type colored glyph badge used in
|
||||
// the canvas node card, list-view card, inspector header, and the
|
||||
// add-step menu. One component so a styling change (radius, contrast,
|
||||
// hover) lands in every place at once and the `nodeColors()` lookup
|
||||
// lives in exactly one spot.
|
||||
// ============================================================
|
||||
|
||||
export function NodeIconChip({
|
||||
type,
|
||||
size = 24,
|
||||
iconSize = 14,
|
||||
className,
|
||||
}: {
|
||||
type: NodeType;
|
||||
/** Chip side length in px. */
|
||||
size?: number;
|
||||
/** Glyph side length in px. */
|
||||
iconSize?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const meta = NODE_META[type];
|
||||
const c = nodeColors(type);
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-lg",
|
||||
className,
|
||||
)}
|
||||
style={{ width: size, height: size, background: c.soft, color: c.solid }}
|
||||
>
|
||||
<Icon size={iconSize} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Pure editing helpers — used by forms in both views.
|
||||
// ============================================================
|
||||
|
||||
Loading…
Reference in New Issue
Block a user