diff --git a/src/App.tsx b/src/App.tsx index b936a05..f197e85 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,10 +19,15 @@ import { } from "@/components/ui/studio-sidebar"; import { BlockPreviewPage, BlockCodePage } from "@/components/blocks"; import { ErrorPagePreview } from "@/components/error-pages"; +import { PrototypeBuilder } from "@/components/prototype-builder"; function AppShell() { const { activeComponent } = useNavigation(); + if (activeComponent === "prototype-builder") { + return ; + } + if (activeComponent.startsWith("block-preview-")) { const id = activeComponent.slice("block-preview-".length); return ; diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 900c2e3..6be23f7 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -27,6 +27,7 @@ const data = { { title: "Tools", items: [ + { id: "prototype-builder", title: "Prototype Builder" }, { id: "playground", title: "Playground" }, { id: "blocks", title: "Blocks" }, { id: "settings", title: "Settings" }, diff --git a/src/components/prototype-builder/Canvas.tsx b/src/components/prototype-builder/Canvas.tsx new file mode 100644 index 0000000..94420e7 --- /dev/null +++ b/src/components/prototype-builder/Canvas.tsx @@ -0,0 +1,255 @@ +import * as React from "react"; +import { useDroppable } from "@dnd-kit/core"; +import { + SortableContext, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { + ArrowDown, + ArrowUp, + Copy, + GripVertical, + Trash2, + Zap, +} from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; + +import { ElementView } from "./element-renderer"; +import { DEVICE_WIDTH } from "./constants"; +import { usePrototypeStore, useCurrentPage } from "./store"; +import type { PrototypeElement } from "./types"; + +export function Canvas() { + const { state, dispatch } = usePrototypeStore(); + const page = useCurrentPage(); + const width = DEVICE_WIDTH[state.device]; + + const getVar = React.useCallback( + (name: string) => state.variables.find((v) => v.name === name)?.value ?? "", + [state.variables] + ); + const setVar = React.useCallback( + (name: string, value: string) => { + const idx = state.variables.findIndex((v) => v.name === name); + if (idx >= 0) dispatch({ t: "updateVariable", index: idx, value }); + else dispatch({ t: "addVariable", name }); + }, + [dispatch, state.variables] + ); + + const { setNodeRef, isOver } = useDroppable({ id: "canvas-dropzone" }); + + return ( +
dispatch({ t: "select", id: null })} + > +
e.stopPropagation()} + > + {/* Device chrome */} +
+ + + + {page?.name} + {width}px +
+ +
+ {page && page.elements.length > 0 ? ( + e.id)} + strategy={verticalListSortingStrategy} + > +
+ {page.elements.map((el, i) => ( + + ))} +
+
+ ) : ( + + )} +
+
+
+ ); +} + +function EmptyCanvas() { + return ( +
+
+

Empty screen

+

+ Click a component in the library on the left to add it here, or drag + it onto the canvas. +

+
+
+ ); +} + +interface CanvasElementProps { + element: PrototypeElement; + index: number; + count: number; + selected: boolean; + getVar: (name: string) => string; + setVar: (name: string, value: string) => void; +} + +function CanvasElement({ + element, + index, + count, + selected, + getVar, + setVar, +}: CanvasElementProps) { + const { dispatch } = usePrototypeStore(); + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: element.id, + }); + + const hasActions = element.actions.length > 0; + + return ( +
{ + e.stopPropagation(); + dispatch({ t: "select", id: element.id }); + }} + > + {/* Interaction indicator */} + {hasActions && ( + + + + )} + + {/* Hover / selected toolbar */} +
e.stopPropagation()} + > + + + {element.name} + + + dispatch({ t: "moveElement", id: element.id, dir: -1 }) + } + > + + + dispatch({ t: "moveElement", id: element.id, dir: 1 })} + > + + + dispatch({ t: "duplicateElement", id: element.id })} + > + + + dispatch({ t: "deleteElement", id: element.id })} + > + + +
+ +
+ +
+
+ ); +} + +function ToolbarButton({ + children, + onClick, + disabled, + title, +}: { + children: React.ReactNode; + onClick: () => void; + disabled?: boolean; + title: string; +}) { + return ( + + ); +} diff --git a/src/components/prototype-builder/ComponentLibrary.tsx b/src/components/prototype-builder/ComponentLibrary.tsx new file mode 100644 index 0000000..059093e --- /dev/null +++ b/src/components/prototype-builder/ComponentLibrary.tsx @@ -0,0 +1,112 @@ +import * as React from "react"; +import { useDraggable } from "@dnd-kit/core"; +import { Search } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Input } from "@/components/ui/input"; + +import { catalogItems, type CatalogItem } from "./catalog"; +import { usePrototypeStore, useCurrentPage } from "./store"; + +export function ComponentLibrary() { + const [query, setQuery] = React.useState(""); + const page = useCurrentPage(); + const { dispatch } = usePrototypeStore(); + + const filtered = React.useMemo(() => { + const q = query.trim().toLowerCase(); + return catalogItems.filter( + (i) => + !q || + i.name.toLowerCase().includes(q) || + i.category.toLowerCase().includes(q) + ); + }, [query]); + + const grouped = React.useMemo(() => { + const map = new Map(); + for (const item of filtered) { + if (!map.has(item.category)) map.set(item.category, []); + map.get(item.category)!.push(item); + } + return Array.from(map.entries()); + }, [filtered]); + + return ( +
+
+
+ Components +
+
+ + setQuery(e.target.value)} + /> +
+
+ +
+ {grouped.map(([category, items]) => ( +
+
+ {category} +
+
+ {items.map((item) => ( + + dispatch({ + t: "addElement", + pageId: page.id, + catalogId: item.id, + }) + } + /> + ))} +
+
+ ))} + {grouped.length === 0 && ( +

+ No components match “{query}”. +

+ )} +
+
+ ); +} + +function PaletteItem({ + item, + onAdd, +}: { + item: CatalogItem; + onAdd: () => void; +}) { + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id: `palette:${item.id}`, + }); + const Icon = item.icon; + return ( + + ); +} diff --git a/src/components/prototype-builder/FlowView.tsx b/src/components/prototype-builder/FlowView.tsx new file mode 100644 index 0000000..0d49be2 --- /dev/null +++ b/src/components/prototype-builder/FlowView.tsx @@ -0,0 +1,318 @@ +import * as React from "react"; +import { Flag, LayoutGrid, Maximize2, Minus, Pencil, Plus } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; + +import { catalogById } from "./catalog"; +import { usePrototypeStore } from "./store"; +import type { PrototypePage } from "./types"; + +const NODE_W = 224; +const NODE_H = 148; + +interface Edge { + from: string; + to: string; + count: number; +} + +export function FlowView() { + const { state, dispatch } = usePrototypeStore(); + const [zoom, setZoom] = React.useState(0.9); + const [pan, setPan] = React.useState({ x: 40, y: 20 }); + const dragRef = React.useRef<{ + mode: "pan" | "node"; + id?: string; + sx: number; + sy: number; + ox: number; + oy: number; + } | null>(null); + + // Derive edges from navigate actions. + const edges = React.useMemo(() => { + const map = new Map(); + for (const page of state.pages) { + for (const el of page.elements) { + for (const a of el.actions) { + if ( + a.type === "navigate" && + a.targetPageId && + a.targetPageId !== page.id + ) { + const key = `${page.id}->${a.targetPageId}`; + const existing = map.get(key); + if (existing) existing.count += 1; + else map.set(key, { from: page.id, to: a.targetPageId, count: 1 }); + } + } + } + } + return Array.from(map.values()); + }, [state.pages]); + + const pageById = React.useMemo( + () => + Object.fromEntries(state.pages.map((p) => [p.id, p])) as Record< + string, + PrototypePage + >, + [state.pages] + ); + + const onPointerDown = ( + e: React.PointerEvent, + mode: "pan" | "node", + id?: string + ) => { + (e.target as HTMLElement).setPointerCapture?.(e.pointerId); + const page = id ? pageById[id] : null; + dragRef.current = { + mode, + id, + sx: e.clientX, + sy: e.clientY, + ox: page ? page.flowX : pan.x, + oy: page ? page.flowY : pan.y, + }; + }; + + const onPointerMove = (e: React.PointerEvent) => { + const d = dragRef.current; + if (!d) return; + const dx = (e.clientX - d.sx) / zoom; + const dy = (e.clientY - d.sy) / zoom; + if (d.mode === "node" && d.id) { + dispatch({ + t: "pageFlowPos", + id: d.id, + x: Math.round(d.ox + dx), + y: Math.round(d.oy + dy), + }); + } else { + setPan({ x: d.ox + (e.clientX - d.sx), y: d.oy + (e.clientY - d.sy) }); + } + }; + + const endDrag = () => { + dragRef.current = null; + }; + + const autoArrange = () => { + state.pages.forEach((p, i) => { + dispatch({ + t: "pageFlowPos", + id: p.id, + x: 40 + i * 300, + y: 160 + (i % 2) * 60, + }); + }); + setPan({ x: 40, y: 20 }); + setZoom(0.8); + }; + + return ( +
+ {/* Controls */} +
+ + +
+
+ + + {Math.round(zoom * 100)}% + + + +
+ + {/* Pannable stage */} +
onPointerDown(e, "pan")} + onPointerMove={onPointerMove} + onPointerUp={endDrag} + onPointerLeave={endDrag} + > +
+ {/* Edges */} + + + + + + + {edges.map((edge) => { + const from = pageById[edge.from]; + const to = pageById[edge.to]; + if (!from || !to) return null; + const x1 = from.flowX + NODE_W; + const y1 = from.flowY + NODE_H / 2; + const x2 = to.flowX; + const y2 = to.flowY + NODE_H / 2; + const midX = (x1 + x2) / 2; + return ( + + ); + })} + + + {/* Nodes */} + {state.pages.map((page) => ( + onPointerDown(e, "node", page.id)} + onOpen={() => { + dispatch({ t: "currentPage", id: page.id }); + dispatch({ t: "view", view: "design" }); + }} + /> + ))} +
+
+ +
+ Drag nodes to arrange · drag background to pan · connections follow + “Navigate” interactions +
+
+ ); +} + +function FlowNode({ + page, + active, + isStart, + onPointerDownHeader, + onOpen, +}: { + page: PrototypePage; + active: boolean; + isStart: boolean; + onPointerDownHeader: (e: React.PointerEvent) => void; + onOpen: () => void; +}) { + return ( +
+
+ {isStart && } + {page.name} + +
+
+ {page.group && ( + + {page.group} + + )} +
+ {page.elements.slice(0, 3).map((el) => { + const Icon = catalogById[el.type]?.icon; + return ( +
+ {Icon && } + {el.name} +
+ ); + })} + {page.elements.length === 0 && ( + + Empty + + )} + {page.elements.length > 3 && ( + + +{page.elements.length - 3} more + + )} +
+
+
+ ); +} diff --git a/src/components/prototype-builder/InteractionPanel.tsx b/src/components/prototype-builder/InteractionPanel.tsx new file mode 100644 index 0000000..e44e5a8 --- /dev/null +++ b/src/components/prototype-builder/InteractionPanel.tsx @@ -0,0 +1,749 @@ +import * as React from "react"; +import { + Lightbulb, + Plus, + Settings2, + Trash2, + Variable, + Zap, +} from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; +import { Badge } from "@/components/ui/badge"; + +import { catalogById, type FieldSchema } from "./catalog"; +import { usePrototypeStore, useSelectedElement, useCurrentPage } from "./store"; +import type { + ActionType, + ConditionOp, + PrototypeAction, + PrototypeElement, + TransitionType, + TriggerType, +} from "./types"; + +const ACTION_LABELS: Record = { + navigate: "Navigate to page", + back: "Go back", + openModal: "Open modal", + closeModal: "Close modal", + showComponent: "Show component", + hideComponent: "Hide component", + toggleComponent: "Toggle component", + showToast: "Show toast", + setVariable: "Set variable", + delay: "Delay / wait", +}; + +const TRIGGERS: TriggerType[] = ["click", "change", "hover", "submit"]; +const TRANSITIONS: TransitionType[] = [ + "none", + "slide-left", + "slide-right", + "slide-up", + "fade", +]; + +// A native onChange(e.target.value)} + className={cn( + "border-input bg-background focus-visible:ring-ring/50 h-8 w-full rounded-md border px-2 text-sm shadow-xs outline-none focus-visible:ring-[3px]", + className + )} + > + {children} + + ); +} + +export function InteractionPanel() { + const selected = useSelectedElement(); + + return ( +
+ {selected ? ( + + ) : ( + + )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Element inspector: properties + interactions + smart suggestions +// --------------------------------------------------------------------------- + +function ElementInspector({ element }: { element: PrototypeElement }) { + const { dispatch } = usePrototypeStore(); + const item = catalogById[element.type]; + + return ( +
+
+ + + dispatch({ + t: "updateElement", + id: element.id, + patch: { name: e.target.value }, + }) + } + className="h-8" + /> +
+ + {/* Properties */} + {item?.fields.length ? ( +
+
+ {item.fields.map((field) => ( + + ))} +
+
+ ) : null} + + {/* Behaviour toggles */} +
+
+ + dispatch({ + t: "updateElement", + id: element.id, + patch: { hidden: v }, + }) + } + /> + + dispatch({ + t: "updateElement", + id: element.id, + patch: { isModal: v }, + }) + } + /> +
+
+ + {/* Smart suggestions */} + {item?.suggestions?.length ? ( +
} + > +
+ {item.suggestions.map((s, i) => ( + + ))} +
+
+ ) : null} + + {/* Interactions */} +
} + action={ + + } + > + {element.actions.length === 0 ? ( +

+ No interactions yet. Add one to make this component respond to + clicks, changes or hovers. +

+ ) : ( +
+ {element.actions.map((action, i) => ( + + ))} +
+ )} +
+
+ ); +} + +function FieldEditor({ + field, + element, +}: { + field: FieldSchema; + element: PrototypeElement; +}) { + const { state, dispatch } = usePrototypeStore(); + const value = (element.props[field.key] as string) ?? ""; + const set = (v: string | boolean) => + dispatch({ t: "updateProps", id: element.id, props: { [field.key]: v } }); + + if (field.type === "boolean") { + return ( + set(v ? "true" : "false")} + /> + ); + } + if (field.type === "textarea") { + return ( +
+ +