Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <PrototypeBuilder />;
}

if (activeComponent.startsWith("block-preview-")) {
const id = activeComponent.slice("block-preview-".length);
return <BlockPreviewPage id={id} />;
Expand Down
1 change: 1 addition & 0 deletions src/components/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
255 changes: 255 additions & 0 deletions src/components/prototype-builder/Canvas.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className="flex h-full flex-col items-center overflow-auto bg-[radial-gradient(var(--color-border)_1px,transparent_1px)] [background-size:16px_16px] p-8"
onClick={() => dispatch({ t: "select", id: null })}
>
<div
className="bg-background ring-border relative rounded-xl shadow-sm ring-1 transition-[width]"
style={{ width: Math.min(width, 1024), maxWidth: "100%" }}
onClick={(e) => e.stopPropagation()}
>
{/* Device chrome */}
<div className="text-muted-foreground flex items-center gap-1.5 border-b px-4 py-2 text-xs">
<span className="bg-destructive/60 size-2.5 rounded-full" />
<span className="size-2.5 rounded-full bg-amber-400/70" />
<span className="size-2.5 rounded-full bg-emerald-400/70" />
<span className="ml-2 truncate font-medium">{page?.name}</span>
<span className="ml-auto tabular-nums">{width}px</span>
</div>

<div
ref={setNodeRef}
className={cn("min-h-[420px] p-5", isOver && "bg-primary/5")}
>
{page && page.elements.length > 0 ? (
<SortableContext
items={page.elements.map((e) => e.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-4">
{page.elements.map((el, i) => (
<CanvasElement
key={el.id}
element={el}
index={i}
count={page.elements.length}
selected={state.selectedElementId === el.id}
getVar={getVar}
setVar={setVar}
/>
))}
</div>
</SortableContext>
) : (
<EmptyCanvas />
)}
</div>
</div>
</div>
);
}

function EmptyCanvas() {
return (
<div className="text-muted-foreground grid min-h-[360px] place-items-center rounded-lg border border-dashed text-center">
<div className="max-w-xs px-6">
<p className="text-foreground text-sm font-medium">Empty screen</p>
<p className="mt-1 text-sm">
Click a component in the library on the left to add it here, or drag
it onto the canvas.
</p>
</div>
</div>
);
}

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 (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn(
"group relative rounded-lg outline-offset-2 transition-shadow",
selected
? "outline-primary outline outline-2"
: "hover:outline-border outline outline-1 outline-transparent",
element.hidden && "opacity-40",
isDragging && "z-10 opacity-70"
)}
onClick={(e) => {
e.stopPropagation();
dispatch({ t: "select", id: element.id });
}}
>
{/* Interaction indicator */}
{hasActions && (
<span
className="bg-primary text-primary-foreground absolute -top-2 -right-2 z-10 grid size-5 place-items-center rounded-full shadow"
title={`${element.actions.length} interaction(s)`}
>
<Zap className="size-3" />
</span>
)}

{/* Hover / selected toolbar */}
<div
className={cn(
"bg-background absolute -top-3.5 left-2 z-10 flex items-center gap-0.5 rounded-md border p-0.5 shadow-sm transition-opacity",
selected
? "opacity-100"
: "pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100"
)}
onClick={(e) => e.stopPropagation()}
>
<button
className="text-muted-foreground hover:bg-accent flex size-6 cursor-grab items-center justify-center rounded active:cursor-grabbing"
{...attributes}
{...listeners}
title="Drag to reorder"
>
<GripVertical className="size-3.5" />
</button>
<span className="text-muted-foreground max-w-28 truncate px-1 text-xs font-medium">
{element.name}
</span>
<ToolbarButton
title="Move up"
disabled={index === 0}
onClick={() =>
dispatch({ t: "moveElement", id: element.id, dir: -1 })
}
>
<ArrowUp className="size-3.5" />
</ToolbarButton>
<ToolbarButton
title="Move down"
disabled={index === count - 1}
onClick={() => dispatch({ t: "moveElement", id: element.id, dir: 1 })}
>
<ArrowDown className="size-3.5" />
</ToolbarButton>
<ToolbarButton
title="Duplicate"
onClick={() => dispatch({ t: "duplicateElement", id: element.id })}
>
<Copy className="size-3.5" />
</ToolbarButton>
<ToolbarButton
title="Delete"
onClick={() => dispatch({ t: "deleteElement", id: element.id })}
>
<Trash2 className="size-3.5" />
</ToolbarButton>
</div>

<div className="pointer-events-none select-none">
<ElementView
element={element}
mode="editor"
getVar={getVar}
setVar={setVar}
/>
</div>
</div>
);
}

function ToolbarButton({
children,
onClick,
disabled,
title,
}: {
children: React.ReactNode;
onClick: () => void;
disabled?: boolean;
title: string;
}) {
return (
<Button
variant="ghost"
size="icon-sm"
className="size-6"
disabled={disabled}
title={title}
onClick={onClick}
>
{children}
</Button>
);
}
112 changes: 112 additions & 0 deletions src/components/prototype-builder/ComponentLibrary.tsx
Original file line number Diff line number Diff line change
@@ -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<string, CatalogItem[]>();
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 (
<div className="flex h-full flex-col">
<div className="border-b p-3">
<div className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
Components
</div>
<div className="relative mt-2">
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2" />
<Input
className="h-8 pl-8 text-sm"
placeholder="Search components…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
</div>

<div className="min-h-0 flex-1 overflow-y-auto p-3">
{grouped.map(([category, items]) => (
<div key={category} className="mb-4">
<div className="text-muted-foreground mb-1.5 px-1 text-[11px] font-semibold tracking-wide uppercase">
{category}
</div>
<div className="grid grid-cols-2 gap-1.5">
{items.map((item) => (
<PaletteItem
key={item.id}
item={item}
onAdd={() =>
dispatch({
t: "addElement",
pageId: page.id,
catalogId: item.id,
})
}
/>
))}
</div>
</div>
))}
{grouped.length === 0 && (
<p className="text-muted-foreground p-3 text-center text-sm">
No components match “{query}”.
</p>
)}
</div>
</div>
);
}

function PaletteItem({
item,
onAdd,
}: {
item: CatalogItem;
onAdd: () => void;
}) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `palette:${item.id}`,
});
const Icon = item.icon;
return (
<button
ref={setNodeRef}
{...attributes}
{...listeners}
onClick={onAdd}
title={`Add ${item.name}`}
className={cn(
"hover:border-primary/50 hover:bg-accent bg-card flex cursor-grab flex-col items-center gap-1.5 rounded-md border p-2.5 text-center transition-colors active:cursor-grabbing",
isDragging && "opacity-40"
)}
>
<Icon className="text-muted-foreground size-4" />
<span className="text-[11px] leading-tight font-medium">{item.name}</span>
</button>
);
}
Loading
Loading