frontend ai mockup changes
This commit is contained in:
parent
c007095a05
commit
a21785972d
83
package-lock.json
generated
83
package-lock.json
generated
@ -9,6 +9,9 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.57.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@next/font": "^14.2.15",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
@ -17,7 +20,9 @@
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
@ -29,6 +34,7 @@
|
||||
"next": "15.4.6",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-resizable-panels": "^3.0.5",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"svg-path-parser": "^1.1.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
@ -84,6 +90,73 @@
|
||||
"anthropic-ai-sdk": "bin/cli"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/modifiers": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz",
|
||||
"integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz",
|
||||
@ -8001,6 +8074,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-resizable-panels": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-3.0.5.tgz",
|
||||
"integrity": "sha512-3z1yN25DMTXLg2wfyFrW32r5k4WEcUa3F7cJ2EgtNK07lnOs4mpM8yWLGunCpkhcQRwJX4fqoLcIh/pHPxzlmQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/react-style-singleton": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||
|
||||
@ -10,6 +10,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.57.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@next/font": "^14.2.15",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
@ -18,7 +21,9 @@
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
@ -30,6 +35,7 @@
|
||||
"next": "15.4.6",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-resizable-panels": "^3.0.5",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"svg-path-parser": "^1.1.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
|
||||
143
src/components/canvas.tsx
Normal file
143
src/components/canvas.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
"use client"
|
||||
|
||||
import { useRef, useState } from "react"
|
||||
import { useEditorStore } from "@/lib/store"
|
||||
import { ComponentRenderer } from "./component-renderer"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function Canvas() {
|
||||
const canvasRef = useRef<HTMLDivElement>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null)
|
||||
const [selectedComponents, setSelectedComponents] = useState<Set<string>>(new Set())
|
||||
|
||||
const {
|
||||
components,
|
||||
selectedComponent,
|
||||
selectComponent,
|
||||
moveComponent,
|
||||
updateComponent
|
||||
} = useEditorStore()
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (e.target === canvasRef.current) {
|
||||
// Clicked on empty canvas, deselect all
|
||||
selectComponent(null)
|
||||
setSelectedComponents(new Set())
|
||||
}
|
||||
}
|
||||
|
||||
const handleComponentClick = (componentId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
|
||||
const component = components.find(c => c.id === componentId)
|
||||
if (component) {
|
||||
selectComponent(component)
|
||||
setSelectedComponents(new Set([componentId]))
|
||||
}
|
||||
}
|
||||
|
||||
const handleComponentMouseDown = (componentId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
|
||||
const component = components.find(c => c.id === componentId)
|
||||
if (component) {
|
||||
selectComponent(component)
|
||||
setSelectedComponents(new Set([componentId]))
|
||||
|
||||
// Start dragging
|
||||
setIsDragging(true)
|
||||
setDragStart({ x: e.clientX, y: e.clientY })
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (isDragging && dragStart && selectedComponent) {
|
||||
const deltaX = e.clientX - dragStart.x
|
||||
const deltaY = e.clientY - dragStart.y
|
||||
|
||||
const newPosition = {
|
||||
x: Math.max(0, selectedComponent.position.x + deltaX),
|
||||
y: Math.max(0, selectedComponent.position.y + deltaY)
|
||||
}
|
||||
|
||||
moveComponent(selectedComponent.id, newPosition)
|
||||
setDragStart({ x: e.clientX, y: e.clientY })
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false)
|
||||
setDragStart(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className={cn(
|
||||
"relative w-full h-full bg-white overflow-hidden",
|
||||
"canvas-grid"
|
||||
)}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
{/* Canvas Grid Background */}
|
||||
<div className="absolute inset-0 opacity-20">
|
||||
<div
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(99, 102, 241, 0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(99, 102, 241, 0.1) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: '20px 20px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Components */}
|
||||
{components.map((component) => (
|
||||
<div
|
||||
key={component.id}
|
||||
className={cn(
|
||||
"absolute cursor-move select-none",
|
||||
selectedComponents.has(component.id) && "ring-2 ring-blue-500 ring-opacity-50"
|
||||
)}
|
||||
style={{
|
||||
left: component.position.x,
|
||||
top: component.position.y,
|
||||
width: component.size?.width || 100,
|
||||
height: component.size?.height || 100,
|
||||
}}
|
||||
onClick={(e) => handleComponentClick(component.id, e)}
|
||||
onMouseDown={(e) => handleComponentMouseDown(component.id, e)}
|
||||
>
|
||||
<ComponentRenderer
|
||||
component={component}
|
||||
isSelected={selectedComponents.has(component.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Drop Zone Indicator */}
|
||||
{isDragging && (
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div className="w-full h-full border-2 border-dashed border-blue-400 bg-blue-50 bg-opacity-20" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{components.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<div className="text-6xl mb-4">🎨</div>
|
||||
<h3 className="text-lg font-medium mb-2">No components yet</h3>
|
||||
<p className="text-sm">Drag components from the palette to get started</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
213
src/components/component-palette.tsx
Normal file
213
src/components/component-palette.tsx
Normal file
@ -0,0 +1,213 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "./ui/button"
|
||||
import { Input } from "./ui/input"
|
||||
import { Textarea } from "./ui/textarea"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
import { Switch } from "./ui/switch"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { RadioGroup, RadioGroupItem } from "./ui/radio-group"
|
||||
import { Progress } from "./ui/progress"
|
||||
import { Avatar, AvatarFallback } from "./ui/avatar"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
MousePointer,
|
||||
Square,
|
||||
Circle,
|
||||
Type,
|
||||
Image,
|
||||
Layout,
|
||||
BarChart3,
|
||||
Settings,
|
||||
User,
|
||||
Calendar,
|
||||
Mail,
|
||||
Phone,
|
||||
Globe,
|
||||
Search,
|
||||
Filter,
|
||||
Download,
|
||||
Upload,
|
||||
Edit,
|
||||
Trash2,
|
||||
Plus,
|
||||
Minus,
|
||||
Check,
|
||||
X,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Menu,
|
||||
MoreHorizontal,
|
||||
Star,
|
||||
Heart,
|
||||
Share,
|
||||
Bookmark,
|
||||
Flag,
|
||||
AlertCircle,
|
||||
Info,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
HelpCircle
|
||||
} from "lucide-react"
|
||||
|
||||
const componentCategories = [
|
||||
{
|
||||
name: "Basic",
|
||||
icon: Square,
|
||||
components: [
|
||||
{ type: "button", name: "Button", icon: MousePointer },
|
||||
{ type: "input", name: "Input", icon: Type },
|
||||
{ type: "textarea", name: "Textarea", icon: Type },
|
||||
{ type: "card", name: "Card", icon: Square },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Form",
|
||||
icon: Settings,
|
||||
components: [
|
||||
{ type: "checkbox", name: "Checkbox", icon: Check },
|
||||
{ type: "switch", name: "Switch", icon: Settings },
|
||||
{ type: "select", name: "Select", icon: ChevronDown },
|
||||
{ type: "radiogroup", name: "Radio Group", icon: Circle },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Data",
|
||||
icon: BarChart3,
|
||||
components: [
|
||||
{ type: "table", name: "Table", icon: Layout },
|
||||
{ type: "progress", name: "Progress", icon: BarChart3 },
|
||||
{ type: "tabs", name: "Tabs", icon: Layout },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Media",
|
||||
icon: Image,
|
||||
components: [
|
||||
{ type: "avatar", name: "Avatar", icon: User },
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
export function ComponentPalette() {
|
||||
const [selectedCategory, setSelectedCategory] = useState("Basic")
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
const filteredComponents = componentCategories
|
||||
.find(cat => cat.name === selectedCategory)
|
||||
?.components.filter(comp =>
|
||||
comp.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
) || []
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-white">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Components</h2>
|
||||
<p className="text-sm text-gray-500">Drag components to canvas</p>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="p-4 border-b">
|
||||
<Input
|
||||
placeholder="Search components..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
<div className="p-4 border-b">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{componentCategories.map((category) => (
|
||||
<Button
|
||||
key={category.name}
|
||||
variant={selectedCategory === category.name ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory(category.name)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<category.icon className="h-4 w-4" />
|
||||
{category.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Components List */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{filteredComponents.map((component) => (
|
||||
<ComponentPreview
|
||||
key={component.type}
|
||||
type={component.type}
|
||||
name={component.name}
|
||||
icon={component.icon}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredComponents.length === 0 && (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
<Search className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No components found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ComponentPreviewProps {
|
||||
type: string
|
||||
name: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
function ComponentPreview({ type, name, icon: Icon }: ComponentPreviewProps) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
const handleDragStart = (e: React.DragEvent) => {
|
||||
setIsDragging(true)
|
||||
e.dataTransfer.setData('application/json', JSON.stringify({ type }))
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={cn(
|
||||
"p-3 border border-gray-200 rounded-lg cursor-grab hover:border-blue-300 hover:shadow-sm transition-all",
|
||||
isDragging && "opacity-50 scale-95"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<div className="p-2 bg-gray-100 rounded-lg">
|
||||
<Icon className="h-4 w-4 text-gray-600" />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-gray-700 text-center">
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
253
src/components/component-renderer.tsx
Normal file
253
src/components/component-renderer.tsx
Normal file
@ -0,0 +1,253 @@
|
||||
"use client"
|
||||
|
||||
import { ComponentInstance } from "@/lib/store"
|
||||
import { WireframeRenderer } from "./wireframe-renderer"
|
||||
import { Button } from "./ui/button"
|
||||
import { Input } from "./ui/input"
|
||||
import { Textarea } from "./ui/textarea"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
import { Switch } from "./ui/switch"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { RadioGroup, RadioGroupItem } from "./ui/radio-group"
|
||||
import { Progress } from "./ui/progress"
|
||||
import { Avatar, AvatarFallback } from "./ui/avatar"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ComponentRendererProps {
|
||||
component: ComponentInstance
|
||||
isSelected?: boolean
|
||||
onClick?: () => void
|
||||
onMouseDown?: (e: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
export function ComponentRenderer({
|
||||
component,
|
||||
isSelected = false,
|
||||
onClick,
|
||||
onMouseDown
|
||||
}: ComponentRendererProps) {
|
||||
const { type, props } = component
|
||||
|
||||
// Handle wireframe components
|
||||
if (type.startsWith('wireframe-')) {
|
||||
return (
|
||||
<WireframeRenderer
|
||||
component={component}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle regular UI components
|
||||
const renderComponent = () => {
|
||||
switch (type) {
|
||||
case 'button':
|
||||
return (
|
||||
<Button
|
||||
variant={props.variant || 'default'}
|
||||
size={props.size || 'default'}
|
||||
className={cn("w-full h-full", isSelected && "ring-2 ring-blue-500")}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
>
|
||||
{props.children || 'Button'}
|
||||
</Button>
|
||||
)
|
||||
|
||||
case 'input':
|
||||
return (
|
||||
<Input
|
||||
placeholder={props.placeholder || 'Enter text...'}
|
||||
className={cn("w-full h-full", isSelected && "ring-2 ring-blue-500")}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
readOnly
|
||||
/>
|
||||
)
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<Textarea
|
||||
placeholder={props.placeholder || 'Enter text...'}
|
||||
className={cn("w-full h-full resize-none", isSelected && "ring-2 ring-blue-500")}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
readOnly
|
||||
/>
|
||||
)
|
||||
|
||||
case 'card':
|
||||
return (
|
||||
<Card className={cn("w-full h-full", isSelected && "ring-2 ring-blue-500")}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">{props.title || 'Card Title'}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-gray-600">{props.description || 'Card description'}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
case 'checkbox':
|
||||
return (
|
||||
<div className={cn("flex items-center space-x-2 w-full h-full", isSelected && "ring-2 ring-blue-500")}>
|
||||
<Checkbox
|
||||
id={component.id}
|
||||
checked={props.checked || false}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
/>
|
||||
<label htmlFor={component.id} className="text-sm">
|
||||
{props.label || 'Checkbox'}
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'switch':
|
||||
return (
|
||||
<div className={cn("flex items-center space-x-2 w-full h-full", isSelected && "ring-2 ring-blue-500")}>
|
||||
<Switch
|
||||
checked={props.checked || false}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
/>
|
||||
<label className="text-sm">
|
||||
{props.label || 'Switch'}
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<Select>
|
||||
<SelectTrigger className={cn("w-full h-full", isSelected && "ring-2 ring-blue-500")}>
|
||||
<SelectValue placeholder={props.placeholder || 'Select option...'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(props.options || ['Option 1', 'Option 2']).map((option: string, index: number) => (
|
||||
<SelectItem key={index} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
|
||||
case 'radiogroup':
|
||||
return (
|
||||
<RadioGroup
|
||||
value={props.value || 'Option 1'}
|
||||
className={cn("w-full h-full", isSelected && "ring-2 ring-blue-500")}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
>
|
||||
{(props.options || ['Option 1', 'Option 2']).map((option: string, index: number) => (
|
||||
<div key={index} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={option} id={`${component.id}-${index}`} />
|
||||
<label htmlFor={`${component.id}-${index}`} className="text-sm">
|
||||
{option}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)
|
||||
|
||||
case 'progress':
|
||||
return (
|
||||
<div className={cn("w-full h-full flex items-center", isSelected && "ring-2 ring-blue-500")}>
|
||||
<Progress
|
||||
value={props.value || 50}
|
||||
max={props.max || 100}
|
||||
className="w-full"
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'avatar':
|
||||
return (
|
||||
<div className={cn("w-full h-full flex items-center justify-center", isSelected && "ring-2 ring-blue-500")}>
|
||||
<Avatar onClick={onClick} onMouseDown={onMouseDown}>
|
||||
<AvatarFallback>{props.fallback || 'AB'}</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'table':
|
||||
return (
|
||||
<div className={cn("w-full h-full overflow-auto", isSelected && "ring-2 ring-blue-500")}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{(props.headers || ['Name', 'Email', 'Role']).map((header: string, index: number) => (
|
||||
<TableHead key={index} className="text-xs">{header}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(props.rows || [
|
||||
['John Doe', 'john@example.com', 'Admin'],
|
||||
['Jane Smith', 'jane@example.com', 'User']
|
||||
]).map((row: string[], rowIndex: number) => (
|
||||
<TableRow key={rowIndex}>
|
||||
{row.map((cell: string, cellIndex: number) => (
|
||||
<TableCell key={cellIndex} className="text-xs">{cell}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'tabs':
|
||||
return (
|
||||
<Tabs defaultValue="0" className={cn("w-full h-full", isSelected && "ring-2 ring-blue-500")}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
{(props.tabs || [
|
||||
{ label: 'Tab 1', content: 'Content 1' },
|
||||
{ label: 'Tab 2', content: 'Content 2' }
|
||||
]).map((tab: any, index: number) => (
|
||||
<TabsTrigger key={index} value={index.toString()}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{(props.tabs || [
|
||||
{ label: 'Tab 1', content: 'Content 1' },
|
||||
{ label: 'Tab 2', content: 'Content 2' }
|
||||
]).map((tab: any, index: number) => (
|
||||
<TabsContent key={index} value={index.toString()} className="mt-2">
|
||||
<div className="text-sm">{tab.content}</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)
|
||||
|
||||
default:
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full h-full border-2 border-dashed border-gray-300 rounded-lg flex items-center justify-center text-gray-500 text-sm",
|
||||
isSelected && "ring-2 ring-blue-500"
|
||||
)}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
>
|
||||
{type}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
{renderComponent()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
234
src/components/dual-canvas-editor.tsx
Normal file
234
src/components/dual-canvas-editor.tsx
Normal file
@ -0,0 +1,234 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense, useState, useEffect } from "react"
|
||||
import { DndContext, type DragEndEvent, DragOverlay, type DragStartEvent } from "@dnd-kit/core"
|
||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Trash2, Palette, PenTool, Eye, EyeOff, SquarePen } from "lucide-react"
|
||||
|
||||
import WireframeCanvas from "@/components/wireframe-canvas"
|
||||
import PromptSidePanel from "@/components/prompt-side-panel"
|
||||
import { Canvas } from "@/components/canvas"
|
||||
import { ComponentPalette } from "@/components/component-palette"
|
||||
import { PropertiesPanel } from "@/components/properties-panel"
|
||||
import { useEditorStore } from "@/lib/store"
|
||||
import { useWireframeIntegration } from "@/lib/wireframe-integration"
|
||||
|
||||
interface DualCanvasEditorProps {
|
||||
className?: string
|
||||
onWireframeGenerated?: (data: any) => void
|
||||
onGenerationStart?: () => void
|
||||
selectedDevice?: 'desktop' | 'tablet' | 'mobile'
|
||||
onDeviceChange?: (device: 'desktop' | 'tablet' | 'mobile') => void
|
||||
}
|
||||
|
||||
export function DualCanvasEditor({
|
||||
className,
|
||||
onWireframeGenerated,
|
||||
onGenerationStart,
|
||||
selectedDevice = 'desktop',
|
||||
onDeviceChange
|
||||
}: DualCanvasEditorProps) {
|
||||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
const [canvasMode, setCanvasMode] = useState<'wireframe' | 'components'>('wireframe')
|
||||
const {
|
||||
selectedComponent,
|
||||
addComponent,
|
||||
clearAll,
|
||||
components,
|
||||
showWireframes,
|
||||
setShowWireframes,
|
||||
wireframeRenderMode,
|
||||
setWireframeRenderMode
|
||||
} = useEditorStore()
|
||||
|
||||
// Initialize wireframe integration
|
||||
useWireframeIntegration()
|
||||
|
||||
const handleDeviceChange = (device: 'desktop' | 'tablet' | 'mobile') => {
|
||||
if (onDeviceChange) {
|
||||
onDeviceChange(device)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string)
|
||||
}
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
setActiveId(null)
|
||||
|
||||
if (event.over?.id === "canvas" && event.active) {
|
||||
const componentType = event.active.id as string
|
||||
|
||||
// Get the drop position relative to the canvas
|
||||
const canvasRect = event.over.rect
|
||||
const dragRect = event.active.rect.current.translated
|
||||
|
||||
if (dragRect && canvasRect) {
|
||||
const newComponent = {
|
||||
id: `${componentType}-${Date.now()}`,
|
||||
type: componentType,
|
||||
props: getDefaultProps(componentType),
|
||||
position: {
|
||||
x: Math.max(0, dragRect.left - canvasRect.left),
|
||||
y: Math.max(0, dragRect.top - canvasRect.top),
|
||||
},
|
||||
}
|
||||
|
||||
addComponent(newComponent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getDefaultProps = (componentType: string): Record<string, any> => {
|
||||
const defaults: Record<string, any> = {
|
||||
button: { children: "Button", variant: "default" },
|
||||
input: { placeholder: "Enter text..." },
|
||||
textarea: { placeholder: "Enter text..." },
|
||||
card: { title: "Card Title", description: "Card description" },
|
||||
checkbox: { label: "Checkbox", checked: false },
|
||||
switch: { label: "Switch", checked: false },
|
||||
select: { placeholder: "Select option...", options: ["Option 1", "Option 2"] },
|
||||
radiogroup: { options: ["Option 1", "Option 2"], value: "Option 1" },
|
||||
progress: { value: 50, max: 100 },
|
||||
avatar: { fallback: "AB" },
|
||||
table: {
|
||||
headers: ["Name", "Email", "Role"],
|
||||
rows: [
|
||||
["John Doe", "john@example.com", "Admin"],
|
||||
["Jane Smith", "jane@example.com", "User"],
|
||||
["kenil", "kenil@example.com", "User"],
|
||||
["kavya", "kavya@example.com", "User"],
|
||||
["raj", "raj@example.com", "User"]
|
||||
],
|
||||
},
|
||||
tabs: {
|
||||
tabs: [
|
||||
{ label: "Tab 1", content: "Content 1" },
|
||||
{ label: "Tab 2", content: "Content 2" },
|
||||
],
|
||||
},
|
||||
datepicker: { placeholder: "Pick a date", selectedDate: null },
|
||||
dialog: { title: "Dialog Title", description: "Dialog description" },
|
||||
}
|
||||
|
||||
return defaults[componentType] || {}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`h-full w-full bg-background text-foreground ${className || ''}`}>
|
||||
<header className="border-b border-border">
|
||||
<div className="mx-auto max-w-6xl px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-pretty text-lg font-semibold tracking-tight">Dual Canvas Editor</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Switch between AI wireframe generation and drag-and-drop component editor.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tabs value={canvasMode} onValueChange={(value) => setCanvasMode(value as 'wireframe' | 'components')}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="wireframe" className="flex items-center gap-2">
|
||||
<PenTool className="h-4 w-4" />
|
||||
AI Wireframe
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="components" className="flex items-center gap-2">
|
||||
<Palette className="h-4 w-4" />
|
||||
Components
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
{canvasMode === 'components' && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowWireframes(!showWireframes)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{showWireframes ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
{showWireframes ? 'Hide Wireframes' : 'Show Wireframes'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearAll}
|
||||
disabled={components.length === 0}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Clear All
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="h-[calc(100vh-100px)] w-full">
|
||||
{canvasMode === 'wireframe' ? (
|
||||
<div className="h-full w-full flex">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Suspense fallback={<div className="p-4">Loading canvas…</div>}>
|
||||
<WireframeCanvas
|
||||
className="h-full w-full"
|
||||
selectedDevice={selectedDevice}
|
||||
onWireframeGenerated={onWireframeGenerated}
|
||||
onGenerationStart={onGenerationStart}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
<PromptSidePanel
|
||||
className="shrink-0"
|
||||
selectedDevice={selectedDevice}
|
||||
onDeviceChange={handleDeviceChange}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full">
|
||||
{/* Component Palette Sidebar */}
|
||||
<ResizablePanel defaultSize={20} minSize={15} maxSize={30}>
|
||||
<div className="h-full bg-sidebar border-r border-sidebar-border">
|
||||
<ComponentPalette />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle className="w-1 bg-border hover:bg-accent transition-colors" />
|
||||
|
||||
{/* Canvas Area */}
|
||||
<ResizablePanel defaultSize={selectedComponent ? 60 : 80} minSize={40}>
|
||||
<div className="h-full bg-background">
|
||||
<Canvas />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
{/* Properties Panel - Only show when component is selected */}
|
||||
{selectedComponent && (
|
||||
<>
|
||||
<ResizableHandle className="w-1 bg-border hover:bg-accent transition-colors" />
|
||||
<ResizablePanel defaultSize={20} minSize={15} maxSize={35}>
|
||||
<div className="h-full bg-sidebar border-l border-sidebar-border">
|
||||
<PropertiesPanel />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
|
||||
{/* Drag Overlay */}
|
||||
<DragOverlay>
|
||||
{activeId ? (
|
||||
<div className="drag-overlay p-2 bg-card border border-border rounded-md shadow-lg">{activeId}</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -19,6 +19,7 @@ import { BACKEND_URL } from "@/config/backend"
|
||||
import { Tooltip } from "@/components/ui/tooltip"
|
||||
import WireframeCanvas from "@/components/wireframe-canvas"
|
||||
import PromptSidePanel from "@/components/prompt-side-panel"
|
||||
import { DualCanvasEditor } from "@/components/dual-canvas-editor"
|
||||
|
||||
interface Template {
|
||||
id: string
|
||||
@ -1403,27 +1404,16 @@ function AIMockupStep({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wireframe Canvas and Panel */}
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl overflow-hidden" style={{ height: '600px' }}>
|
||||
<div className="flex h-full">
|
||||
{/* Main Canvas Area */}
|
||||
<div className="flex-1 relative">
|
||||
<WireframeCanvas
|
||||
{/* Dual Canvas Editor */}
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl overflow-hidden" style={{ height: '700px' }}>
|
||||
<DualCanvasEditor
|
||||
className="h-full w-full"
|
||||
onWireframeGenerated={handleWireframeGenerated}
|
||||
onGenerationStart={handleWireframeGenerationStart}
|
||||
selectedDevice={selectedDevice}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* AI Prompt Panel */}
|
||||
<PromptSidePanel
|
||||
className="border-l border-white/10"
|
||||
selectedDevice={selectedDevice}
|
||||
onDeviceChange={handleDeviceChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wireframe Status and Controls */}
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-6">
|
||||
|
||||
@ -202,7 +202,7 @@ export function PromptSidePanel({
|
||||
{backendStatus === 'disconnected' && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<p className="text-xs text-amber-700">
|
||||
<strong>Backend not connected.</strong> Make sure your Flask backend is running on port 5000.
|
||||
<strong>Backend not connected.</strong> Make sure your backend is running on port 8000.
|
||||
The system will use fallback generation instead.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
323
src/components/properties-panel.tsx
Normal file
323
src/components/properties-panel.tsx
Normal file
@ -0,0 +1,323 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useEditorStore } from "@/lib/store"
|
||||
import { Button } from "./ui/button"
|
||||
import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { Textarea } from "./ui/textarea"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { Switch } from "./ui/switch"
|
||||
import { Slider } from "./ui/slider"
|
||||
import { ColorPicker } from "./ui/color-picker"
|
||||
import { Separator } from "./ui/separator"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Trash2, Copy, Move, RotateCcw } from "lucide-react"
|
||||
|
||||
export function PropertiesPanel() {
|
||||
const { selectedComponent, updateComponent, removeComponent } = useEditorStore()
|
||||
const [localProps, setLocalProps] = useState<Record<string, any>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedComponent) {
|
||||
setLocalProps(selectedComponent.props)
|
||||
}
|
||||
}, [selectedComponent])
|
||||
|
||||
const handlePropChange = (key: string, value: any) => {
|
||||
setLocalProps(prev => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (selectedComponent) {
|
||||
updateComponent(selectedComponent.id, { props: localProps })
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
if (selectedComponent) {
|
||||
setLocalProps(selectedComponent.props)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (selectedComponent) {
|
||||
removeComponent(selectedComponent.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedComponent) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-gray-500">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-2">🎯</div>
|
||||
<p className="text-sm">Select a component to edit properties</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderPropertyEditor = (key: string, value: any) => {
|
||||
const type = typeof value
|
||||
|
||||
switch (type) {
|
||||
case 'string':
|
||||
if (key.includes('color') || key.includes('Color')) {
|
||||
return (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label htmlFor={key} className="text-xs font-medium">
|
||||
{key.charAt(0).toUpperCase() + key.slice(1)}
|
||||
</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input
|
||||
id={key}
|
||||
value={value}
|
||||
onChange={(e) => handlePropChange(key, e.target.value)}
|
||||
className="text-xs"
|
||||
/>
|
||||
<div
|
||||
className="w-6 h-6 rounded border"
|
||||
style={{ backgroundColor: value }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (key.includes('text') || key.includes('placeholder') || key.includes('label')) {
|
||||
return (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label htmlFor={key} className="text-xs font-medium">
|
||||
{key.charAt(0).toUpperCase() + key.slice(1)}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={key}
|
||||
value={value}
|
||||
onChange={(e) => handlePropChange(key, e.target.value)}
|
||||
className="text-xs min-h-[60px]"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label htmlFor={key} className="text-xs font-medium">
|
||||
{key.charAt(0).toUpperCase() + key.slice(1)}
|
||||
</Label>
|
||||
<Input
|
||||
id={key}
|
||||
value={value}
|
||||
onChange={(e) => handlePropChange(key, e.target.value)}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'number':
|
||||
if (key.includes('size') || key.includes('width') || key.includes('height')) {
|
||||
return (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label htmlFor={key} className="text-xs font-medium">
|
||||
{key.charAt(0).toUpperCase() + key.slice(1)}: {value}
|
||||
</Label>
|
||||
<Slider
|
||||
value={[value]}
|
||||
onValueChange={([newValue]) => handlePropChange(key, newValue)}
|
||||
min={0}
|
||||
max={key.includes('size') ? 100 : 500}
|
||||
step={1}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label htmlFor={key} className="text-xs font-medium">
|
||||
{key.charAt(0).toUpperCase() + key.slice(1)}
|
||||
</Label>
|
||||
<Input
|
||||
id={key}
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => handlePropChange(key, Number(e.target.value))}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'boolean':
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between">
|
||||
<Label htmlFor={key} className="text-xs font-medium">
|
||||
{key.charAt(0).toUpperCase() + key.slice(1)}
|
||||
</Label>
|
||||
<Switch
|
||||
id={key}
|
||||
checked={value}
|
||||
onCheckedChange={(checked) => handlePropChange(key, checked)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'object':
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label className="text-xs font-medium">
|
||||
{key.charAt(0).toUpperCase() + key.slice(1)} ({value.length} items)
|
||||
</Label>
|
||||
<div className="space-y-1">
|
||||
{value.map((item, index) => (
|
||||
<div key={index} className="flex items-center space-x-2">
|
||||
<Input
|
||||
value={item}
|
||||
onChange={(e) => {
|
||||
const newArray = [...value]
|
||||
newArray[index] = e.target.value
|
||||
handlePropChange(key, newArray)
|
||||
}}
|
||||
className="text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const newArray = value.filter((_, i) => i !== index)
|
||||
handlePropChange(key, newArray)
|
||||
}}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const newArray = [...value, '']
|
||||
handlePropChange(key, newArray)
|
||||
}}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
Add Item
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label className="text-xs font-medium">
|
||||
{key.charAt(0).toUpperCase() + key.slice(1)} (Object)
|
||||
</Label>
|
||||
<div className="text-xs text-gray-500 p-2 bg-gray-100 rounded">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label className="text-xs font-medium">
|
||||
{key.charAt(0).toUpperCase() + key.slice(1)}
|
||||
</Label>
|
||||
<div className="text-xs text-gray-500 p-2 bg-gray-100 rounded">
|
||||
{String(value)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-white">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Properties</h3>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{selectedComponent.type}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Component Info */}
|
||||
<div className="p-4 border-b bg-gray-50">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-gray-500">ID:</span>
|
||||
<span className="font-mono">{selectedComponent.id}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-gray-500">Position:</span>
|
||||
<span className="font-mono">
|
||||
{Math.round(selectedComponent.position.x)}, {Math.round(selectedComponent.position.y)}
|
||||
</span>
|
||||
</div>
|
||||
{selectedComponent.size && (
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-gray-500">Size:</span>
|
||||
<span className="font-mono">
|
||||
{Math.round(selectedComponent.size.width)} × {Math.round(selectedComponent.size.height)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Properties */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-4">
|
||||
{Object.entries(localProps).map(([key, value]) =>
|
||||
renderPropertyEditor(key, value)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{Object.keys(localProps).length === 0 && (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
<div className="text-4xl mb-2">⚙️</div>
|
||||
<p className="text-sm">No properties available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="p-4 border-t space-y-2">
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleReset}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Component
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
30
src/components/ui/color-picker.tsx
Normal file
30
src/components/ui/color-picker.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Input } from "./input"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ColorPickerProps {
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ColorPicker({ value = "#000000", onChange, className }: ColorPickerProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center space-x-2", className)}>
|
||||
<Input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
className="w-12 h-8 p-1 border rounded"
|
||||
/>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
className="flex-1 text-xs font-mono"
|
||||
placeholder="#000000"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
59
src/components/ui/resizable.tsx
Normal file
59
src/components/ui/resizable.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ResizablePanelGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
const ResizablePanel = ResizablePrimitive.Panel
|
||||
|
||||
const ResizableHandle = ({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean
|
||||
}) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
className={cn(
|
||||
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-2.5 w-2.5"
|
||||
>
|
||||
<path
|
||||
d="M5.5 4.625C6.12132 4.625 6.625 4.12132 6.625 3.5C6.625 2.87868 6.12132 2.375 5.5 2.375C4.87868 2.375 4.375 2.87868 4.375 3.5C4.375 4.12132 4.87868 4.625 5.5 4.625ZM9.5 4.625C10.1213 4.625 10.625 4.12132 10.625 3.5C10.625 2.87868 10.1213 2.375 9.5 2.375C8.87868 2.375 8.375 2.87868 8.375 3.5C8.375 4.12132 8.87868 4.625 9.5 4.625ZM5.5 8.125C6.12132 8.125 6.625 7.62132 6.625 7C6.625 6.37868 6.12132 5.875 5.5 5.875C4.87868 5.875 4.375 6.37868 4.375 7C4.375 7.62132 4.87868 8.125 5.5 8.125ZM9.5 8.125C10.1213 8.125 10.625 7.62132 10.625 7C10.625 6.37868 10.1213 5.875 9.5 5.875C8.87868 5.875 8.375 6.37868 8.375 7C8.375 7.62132 8.87868 8.125 9.5 8.125ZM5.5 11.625C6.12132 11.625 6.625 11.1213 6.625 10.5C6.625 9.87868 6.12132 9.375 5.5 9.375C4.87868 9.375 4.375 9.87868 4.375 10.5C4.375 11.1213 4.87868 11.625 5.5 11.625ZM9.5 11.625C10.1213 11.625 10.625 11.1213 10.625 10.5C10.625 9.87868 10.1213 9.375 9.5 9.375C8.87868 9.375 8.375 9.87868 8.375 10.5C8.375 11.1213 8.87868 11.625 9.5 11.625Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
)
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||
31
src/components/ui/separator.tsx
Normal file
31
src/components/ui/separator.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
28
src/components/ui/slider.tsx
Normal file
28
src/components/ui/slider.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
))
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
|
||||
export { Slider }
|
||||
@ -12,6 +12,8 @@ import { getAccessToken } from "@/components/apis/authApiClients"
|
||||
import { authApiClient, getRefreshToken, setTokens, clearTokens } from "@/components/apis/authApiClients"
|
||||
|
||||
import { parseSVG, makeAbsolute } from 'svg-path-parser'
|
||||
import { wireframeConverter } from "@/lib/wireframe-converter"
|
||||
import { useEditorStore } from "@/lib/store"
|
||||
|
||||
export default function WireframeCanvas({
|
||||
className,
|
||||
@ -1154,6 +1156,17 @@ export default function WireframeCanvas({
|
||||
|
||||
if (containsSVG) {
|
||||
await parseSVGAndRender(editor, responseText)
|
||||
|
||||
// NEW: also convert SVG to component instances for the Components tab
|
||||
try {
|
||||
const parsed = wireframeConverter.parseSVGToWireframe(responseText, targetDevice as 'desktop' | 'tablet' | 'mobile')
|
||||
const componentInstances = wireframeConverter.convertToComponents(parsed)
|
||||
// Replace existing components with generated ones
|
||||
useEditorStore.getState().setComponents(componentInstances)
|
||||
} catch (convErr) {
|
||||
console.warn('SVG to components conversion failed (non-blocking):', convErr)
|
||||
}
|
||||
|
||||
// Save the generated wireframe
|
||||
await saveWireframe(editor, false)
|
||||
// Notify parent component that wireframe was generated
|
||||
@ -1170,9 +1183,12 @@ export default function WireframeCanvas({
|
||||
const jsonResponse = JSON.parse(responseText)
|
||||
if (jsonResponse.svg) {
|
||||
await parseSVGAndRender(editor, jsonResponse.svg)
|
||||
// Save the generated wireframe
|
||||
try {
|
||||
const parsed = wireframeConverter.parseSVGToWireframe(jsonResponse.svg, targetDevice as 'desktop' | 'tablet' | 'mobile')
|
||||
const componentInstances = wireframeConverter.convertToComponents(parsed)
|
||||
useEditorStore.getState().setComponents(componentInstances)
|
||||
} catch {}
|
||||
await saveWireframe(editor, false)
|
||||
// Notify parent component that wireframe was generated
|
||||
onWireframeGenerated?.({
|
||||
success: true,
|
||||
wireframeId: currentWireframeId,
|
||||
@ -1182,9 +1198,12 @@ export default function WireframeCanvas({
|
||||
})
|
||||
} else if (jsonResponse.data && jsonResponse.data.svg) {
|
||||
await parseSVGAndRender(editor, jsonResponse.data.svg)
|
||||
// Save the generated wireframe
|
||||
try {
|
||||
const parsed = wireframeConverter.parseSVGToWireframe(jsonResponse.data.svg, targetDevice as 'desktop' | 'tablet' | 'mobile')
|
||||
const componentInstances = wireframeConverter.convertToComponents(parsed)
|
||||
useEditorStore.getState().setComponents(componentInstances)
|
||||
} catch {}
|
||||
await saveWireframe(editor, false)
|
||||
// Notify parent component that wireframe was generated
|
||||
onWireframeGenerated?.({
|
||||
success: true,
|
||||
wireframeId: currentWireframeId,
|
||||
@ -1203,7 +1222,6 @@ export default function WireframeCanvas({
|
||||
} catch (err) {
|
||||
console.error('Error generating wireframe:', err)
|
||||
setError(err instanceof Error ? err.message : 'Failed to generate wireframe')
|
||||
// Notify parent component of failure
|
||||
onWireframeGenerated?.({
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
|
||||
180
src/components/wireframe-renderer.tsx
Normal file
180
src/components/wireframe-renderer.tsx
Normal file
@ -0,0 +1,180 @@
|
||||
"use client"
|
||||
|
||||
import { ComponentInstance } from "@/lib/store"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface WireframeRendererProps {
|
||||
component: ComponentInstance
|
||||
onClick?: () => void
|
||||
onMouseDown?: (e: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
export function WireframeRenderer({
|
||||
component,
|
||||
onClick,
|
||||
onMouseDown
|
||||
}: WireframeRendererProps) {
|
||||
const { type, props } = component
|
||||
|
||||
const renderWireframeElement = (element: any, index: number) => {
|
||||
const elementProps = {
|
||||
x: element.x || 0,
|
||||
y: element.y || 0,
|
||||
width: element.width || 100,
|
||||
height: element.height || 50,
|
||||
fill: element.fill || 'none',
|
||||
stroke: element.stroke || '#000000',
|
||||
strokeWidth: element.strokeWidth || 1
|
||||
}
|
||||
|
||||
switch (element.type) {
|
||||
case 'rect':
|
||||
return (
|
||||
<rect
|
||||
key={index}
|
||||
{...elementProps}
|
||||
rx={element.rx || 0}
|
||||
ry={element.ry || 0}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'circle':
|
||||
return (
|
||||
<circle
|
||||
key={index}
|
||||
cx={element.cx || element.x + element.width / 2}
|
||||
cy={element.cy || element.y + element.height / 2}
|
||||
r={element.r || Math.min(element.width, element.height) / 2}
|
||||
fill={element.fill || 'none'}
|
||||
stroke={element.stroke || '#000000'}
|
||||
strokeWidth={element.strokeWidth || 1}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'text':
|
||||
return (
|
||||
<text
|
||||
key={index}
|
||||
x={element.x || 0}
|
||||
y={element.y || 0}
|
||||
fill={element.fill || '#000000'}
|
||||
fontSize={element.fontSize || 16}
|
||||
fontFamily={element.fontFamily || 'sans-serif'}
|
||||
fontWeight={element.fontWeight || 'normal'}
|
||||
>
|
||||
{element.text || ''}
|
||||
</text>
|
||||
)
|
||||
|
||||
case 'line':
|
||||
return (
|
||||
<line
|
||||
key={index}
|
||||
x1={element.x || 0}
|
||||
y1={element.y || 0}
|
||||
x2={(element.x || 0) + element.width}
|
||||
y2={(element.y || 0) + element.height}
|
||||
stroke={element.stroke || '#000000'}
|
||||
strokeWidth={element.strokeWidth || 1}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'path':
|
||||
return (
|
||||
<rect
|
||||
key={index}
|
||||
{...elementProps}
|
||||
fill="none"
|
||||
strokeDasharray="5,5"
|
||||
/>
|
||||
)
|
||||
|
||||
case 'group':
|
||||
return (
|
||||
<g key={index}>
|
||||
{element.children?.map((child: any, childIndex: number) =>
|
||||
renderWireframeElement(child, childIndex)
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
|
||||
default:
|
||||
return (
|
||||
<rect
|
||||
key={index}
|
||||
{...elementProps}
|
||||
fill="none"
|
||||
strokeDasharray="3,3"
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'wireframe-svg') {
|
||||
const { elements, width, height, deviceType } = props
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full h-full border border-gray-300 rounded-lg overflow-hidden bg-white",
|
||||
"hover:shadow-lg transition-shadow"
|
||||
)}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
>
|
||||
<div className="p-2 bg-gray-100 border-b text-xs font-medium text-gray-600">
|
||||
Wireframe ({deviceType}) - {elements?.length || 0} elements
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="max-w-full max-h-full"
|
||||
style={{ border: '1px solid #e5e7eb' }}
|
||||
>
|
||||
{elements?.map((element: any, index: number) =>
|
||||
renderWireframeElement(element, index)
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle individual wireframe elements
|
||||
const elementProps = {
|
||||
x: props.x || 0,
|
||||
y: props.y || 0,
|
||||
width: props.width || 100,
|
||||
height: props.height || 50,
|
||||
fill: props.fill || 'none',
|
||||
stroke: props.stroke || '#000000',
|
||||
strokeWidth: props.strokeWidth || 1
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full h-full border border-gray-300 rounded-lg overflow-hidden bg-white",
|
||||
"hover:shadow-lg transition-shadow"
|
||||
)}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
>
|
||||
<div className="p-1 bg-gray-100 border-b text-xs font-medium text-gray-600">
|
||||
{type.replace('wireframe-', '')}
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<svg
|
||||
width={elementProps.width}
|
||||
height={elementProps.height}
|
||||
viewBox={`0 0 ${elementProps.width} ${elementProps.height}`}
|
||||
className="max-w-full max-h-full"
|
||||
>
|
||||
{renderWireframeElement({ type: type.replace('wireframe-', ''), ...elementProps }, 0)}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -4,8 +4,8 @@
|
||||
*/
|
||||
|
||||
// Main backend URL - change this to update all API calls
|
||||
export const BACKEND_URL = 'http://localhost:8000';
|
||||
// export const BACKEND_URL = 'https://backend.codenuk.com';
|
||||
// export const BACKEND_URL = 'http://localhost:8000';
|
||||
export const BACKEND_URL = 'https://backend.codenuk.com';
|
||||
|
||||
|
||||
// Realtime notifications socket URL (Template Manager emits notifications)
|
||||
|
||||
156
src/lib/store.ts
Normal file
156
src/lib/store.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import { create } from "zustand"
|
||||
import { persist } from "zustand/middleware"
|
||||
import { ParsedWireframe } from "./wireframe-converter"
|
||||
|
||||
export interface ComponentInstance {
|
||||
id: string
|
||||
type: string
|
||||
props: Record<string, any>
|
||||
position: { x: number; y: number }
|
||||
size?: { width: number; height: number }
|
||||
}
|
||||
|
||||
export interface WireframeData {
|
||||
id: string
|
||||
name: string
|
||||
deviceType: 'desktop' | 'tablet' | 'mobile'
|
||||
data: ParsedWireframe
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
interface EditorState {
|
||||
components: ComponentInstance[]
|
||||
selectedComponent: ComponentInstance | null
|
||||
wireframes: WireframeData[]
|
||||
currentWireframe: WireframeData | null
|
||||
showWireframes: boolean
|
||||
wireframeRenderMode: 'svg' | 'editable'
|
||||
addComponent: (component: ComponentInstance) => void
|
||||
addComponents: (components: ComponentInstance[]) => void
|
||||
setComponents: (components: ComponentInstance[]) => void
|
||||
updateComponent: (id: string, updates: Partial<ComponentInstance>) => void
|
||||
removeComponent: (id: string) => void
|
||||
selectComponent: (component: ComponentInstance | null) => void
|
||||
moveComponent: (id: string, position: { x: number; y: number }) => void
|
||||
clearAll: () => void
|
||||
addWireframe: (wireframe: WireframeData) => void
|
||||
updateWireframe: (id: string, updates: Partial<WireframeData>) => void
|
||||
removeWireframe: (id: string) => void
|
||||
setCurrentWireframe: (wireframe: WireframeData | null) => void
|
||||
setShowWireframes: (show: boolean) => void
|
||||
setWireframeRenderMode: (mode: 'svg' | 'editable') => void
|
||||
clearWireframes: () => void
|
||||
clearWireframeComponents: () => void
|
||||
}
|
||||
|
||||
export const useEditorStore = create<EditorState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
components: [],
|
||||
selectedComponent: null,
|
||||
wireframes: [],
|
||||
currentWireframe: null,
|
||||
showWireframes: false,
|
||||
wireframeRenderMode: 'svg',
|
||||
|
||||
addComponent: (component) =>
|
||||
set((state) => ({
|
||||
components: [...state.components, component],
|
||||
})),
|
||||
|
||||
addComponents: (components) =>
|
||||
set((state) => ({
|
||||
components: [...state.components, ...components],
|
||||
})),
|
||||
|
||||
setComponents: (components) => set({ components }),
|
||||
|
||||
updateComponent: (id, updates) =>
|
||||
set((state) => ({
|
||||
components: state.components.map((comp) =>
|
||||
comp.id === id
|
||||
? {
|
||||
...comp,
|
||||
...updates,
|
||||
props: updates.props ? { ...comp.props, ...updates.props } : comp.props,
|
||||
}
|
||||
: comp,
|
||||
),
|
||||
selectedComponent:
|
||||
state.selectedComponent?.id === id
|
||||
? {
|
||||
...state.selectedComponent,
|
||||
...updates,
|
||||
props: updates.props
|
||||
? { ...state.selectedComponent.props, ...updates.props }
|
||||
: state.selectedComponent.props,
|
||||
}
|
||||
: state.selectedComponent,
|
||||
})),
|
||||
|
||||
removeComponent: (id) =>
|
||||
set((state) => ({
|
||||
components: state.components.filter((comp) => comp.id !== id),
|
||||
selectedComponent: state.selectedComponent?.id === id ? null : state.selectedComponent,
|
||||
})),
|
||||
|
||||
selectComponent: (component) => set({ selectedComponent: component }),
|
||||
|
||||
moveComponent: (id, position) =>
|
||||
set((state) => ({
|
||||
components: state.components.map((comp) => (comp.id === id ? { ...comp, position } : comp)),
|
||||
})),
|
||||
|
||||
clearAll: () =>
|
||||
set({
|
||||
components: [],
|
||||
selectedComponent: null,
|
||||
}),
|
||||
|
||||
addWireframe: (wireframe) =>
|
||||
set((state) => ({
|
||||
wireframes: [...state.wireframes, wireframe],
|
||||
})),
|
||||
|
||||
updateWireframe: (id, updates) =>
|
||||
set((state) => ({
|
||||
wireframes: state.wireframes.map((wireframe) =>
|
||||
wireframe.id === id
|
||||
? {
|
||||
...wireframe,
|
||||
...updates,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
: wireframe,
|
||||
),
|
||||
})),
|
||||
|
||||
removeWireframe: (id) =>
|
||||
set((state) => ({
|
||||
wireframes: state.wireframes.filter((wireframe) => wireframe.id !== id),
|
||||
currentWireframe: state.currentWireframe?.id === id ? null : state.currentWireframe,
|
||||
})),
|
||||
|
||||
setCurrentWireframe: (wireframe) => set({ currentWireframe: wireframe }),
|
||||
|
||||
setShowWireframes: (show) => set({ showWireframes: show }),
|
||||
|
||||
setWireframeRenderMode: (mode) => set({ wireframeRenderMode: mode }),
|
||||
|
||||
clearWireframes: () =>
|
||||
set({
|
||||
wireframes: [],
|
||||
currentWireframe: null,
|
||||
}),
|
||||
|
||||
clearWireframeComponents: () =>
|
||||
set((state) => ({
|
||||
components: state.components.filter(comp => !comp.type.startsWith('wireframe-')),
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: "editor-storage",
|
||||
},
|
||||
),
|
||||
)
|
||||
474
src/lib/wireframe-converter.tsx
Normal file
474
src/lib/wireframe-converter.tsx
Normal file
@ -0,0 +1,474 @@
|
||||
"use client"
|
||||
|
||||
import React from 'react'
|
||||
import { ComponentInstance } from './store'
|
||||
|
||||
export interface WireframeElement {
|
||||
id: string
|
||||
type: 'rect' | 'circle' | 'text' | 'path' | 'line' | 'group'
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
fill?: string
|
||||
stroke?: string
|
||||
strokeWidth?: number
|
||||
text?: string
|
||||
fontSize?: number
|
||||
fontFamily?: string
|
||||
fontWeight?: string
|
||||
rx?: number
|
||||
ry?: number
|
||||
cx?: number
|
||||
cy?: number
|
||||
r?: number
|
||||
children?: WireframeElement[]
|
||||
}
|
||||
|
||||
export interface ParsedWireframe {
|
||||
elements: WireframeElement[]
|
||||
width: number
|
||||
height: number
|
||||
deviceType: 'desktop' | 'tablet' | 'mobile'
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts SVG wireframe data to React components for the component canvas
|
||||
*/
|
||||
export class WireframeToComponentsConverter {
|
||||
private static instance: WireframeToComponentsConverter
|
||||
private componentCounter = 0
|
||||
|
||||
static getInstance(): WireframeToComponentsConverter {
|
||||
if (!WireframeToComponentsConverter.instance) {
|
||||
WireframeToComponentsConverter.instance = new WireframeToComponentsConverter()
|
||||
}
|
||||
return WireframeToComponentsConverter.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse SVG string and extract wireframe elements
|
||||
*/
|
||||
parseSVGToWireframe(svgString: string, deviceType: 'desktop' | 'tablet' | 'mobile' = 'desktop'): ParsedWireframe {
|
||||
try {
|
||||
const parser = new DOMParser()
|
||||
const svgDoc = parser.parseFromString(svgString, 'image/svg+xml')
|
||||
const svgElement = svgDoc.querySelector('svg')
|
||||
|
||||
if (!svgElement) {
|
||||
throw new Error('No SVG element found')
|
||||
}
|
||||
|
||||
// Get SVG dimensions
|
||||
const viewBox = svgElement.getAttribute('viewBox')
|
||||
let width = 800
|
||||
let height = 600
|
||||
|
||||
if (viewBox) {
|
||||
const values = viewBox.split(/\s+|,/).map(Number).filter(n => !isNaN(n))
|
||||
if (values.length >= 4) {
|
||||
width = values[2]
|
||||
height = values[3]
|
||||
}
|
||||
} else {
|
||||
const widthAttr = svgElement.getAttribute('width')
|
||||
const heightAttr = svgElement.getAttribute('height')
|
||||
if (widthAttr && heightAttr) {
|
||||
width = parseFloat(widthAttr.replace(/px|pt|em|rem|%/g, '')) || 800
|
||||
height = parseFloat(heightAttr.replace(/px|pt|em|rem|%/g, '')) || 600
|
||||
}
|
||||
}
|
||||
|
||||
const elements = this.parseSVGElements(svgElement)
|
||||
|
||||
return {
|
||||
elements,
|
||||
width,
|
||||
height,
|
||||
deviceType
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing SVG:', error)
|
||||
return {
|
||||
elements: [],
|
||||
width: 800,
|
||||
height: 600,
|
||||
deviceType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse SVG elements recursively
|
||||
*/
|
||||
private parseSVGElements(svgElement: Element): WireframeElement[] {
|
||||
const elements: WireframeElement[] = []
|
||||
|
||||
Array.from(svgElement.children).forEach((element) => {
|
||||
const tagName = element.tagName.toLowerCase()
|
||||
|
||||
// Skip non-visual elements
|
||||
if (['defs', 'style', 'title', 'desc', 'metadata', 'clippath', 'mask'].includes(tagName)) {
|
||||
return
|
||||
}
|
||||
|
||||
const wireframeElement = this.parseElement(element)
|
||||
if (wireframeElement) {
|
||||
elements.push(wireframeElement)
|
||||
}
|
||||
})
|
||||
|
||||
return elements
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse individual SVG element
|
||||
*/
|
||||
private parseElement(element: Element): WireframeElement | null {
|
||||
const tagName = element.tagName.toLowerCase()
|
||||
const id = element.getAttribute('id') || `element-${++this.componentCounter}`
|
||||
|
||||
// Check visibility
|
||||
const visibility = element.getAttribute('visibility')
|
||||
const display = element.getAttribute('display')
|
||||
const opacity = element.getAttribute('opacity')
|
||||
|
||||
if (visibility === 'hidden' || display === 'none' || opacity === '0') {
|
||||
return null
|
||||
}
|
||||
|
||||
switch (tagName) {
|
||||
case 'rect':
|
||||
return this.parseRect(element as SVGRectElement, id)
|
||||
case 'circle':
|
||||
return this.parseCircle(element as SVGCircleElement, id)
|
||||
case 'ellipse':
|
||||
return this.parseEllipse(element as SVGEllipseElement, id)
|
||||
case 'text':
|
||||
return this.parseText(element as SVGTextElement, id)
|
||||
case 'path':
|
||||
return this.parsePath(element as SVGPathElement, id)
|
||||
case 'line':
|
||||
return this.parseLine(element as SVGLineElement, id)
|
||||
case 'g':
|
||||
return this.parseGroup(element, id)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private parseRect(element: SVGRectElement, id: string): WireframeElement {
|
||||
const x = parseFloat(element.getAttribute('x') || '0')
|
||||
const y = parseFloat(element.getAttribute('y') || '0')
|
||||
const width = parseFloat(element.getAttribute('width') || '100')
|
||||
const height = parseFloat(element.getAttribute('height') || '100')
|
||||
const fill = element.getAttribute('fill') || 'none'
|
||||
const stroke = element.getAttribute('stroke') || '#000000'
|
||||
const strokeWidth = parseFloat(element.getAttribute('stroke-width') || '1')
|
||||
const rx = parseFloat(element.getAttribute('rx') || '0')
|
||||
const ry = parseFloat(element.getAttribute('ry') || '0')
|
||||
|
||||
return {
|
||||
id,
|
||||
type: 'rect',
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
fill,
|
||||
stroke,
|
||||
strokeWidth,
|
||||
rx,
|
||||
ry
|
||||
}
|
||||
}
|
||||
|
||||
private parseCircle(element: SVGCircleElement, id: string): WireframeElement {
|
||||
const cx = parseFloat(element.getAttribute('cx') || '0')
|
||||
const cy = parseFloat(element.getAttribute('cy') || '0')
|
||||
const r = parseFloat(element.getAttribute('r') || '50')
|
||||
const fill = element.getAttribute('fill') || 'none'
|
||||
const stroke = element.getAttribute('stroke') || '#000000'
|
||||
const strokeWidth = parseFloat(element.getAttribute('stroke-width') || '1')
|
||||
|
||||
return {
|
||||
id,
|
||||
type: 'circle',
|
||||
x: cx - r,
|
||||
y: cy - r,
|
||||
width: r * 2,
|
||||
height: r * 2,
|
||||
fill,
|
||||
stroke,
|
||||
strokeWidth,
|
||||
cx,
|
||||
cy,
|
||||
r
|
||||
}
|
||||
}
|
||||
|
||||
private parseEllipse(element: SVGEllipseElement, id: string): WireframeElement {
|
||||
const cx = parseFloat(element.getAttribute('cx') || '0')
|
||||
const cy = parseFloat(element.getAttribute('cy') || '0')
|
||||
const rx = parseFloat(element.getAttribute('rx') || '50')
|
||||
const ry = parseFloat(element.getAttribute('ry') || '30')
|
||||
const fill = element.getAttribute('fill') || 'none'
|
||||
const stroke = element.getAttribute('stroke') || '#000000'
|
||||
const strokeWidth = parseFloat(element.getAttribute('stroke-width') || '1')
|
||||
|
||||
return {
|
||||
id,
|
||||
type: 'circle',
|
||||
x: cx - rx,
|
||||
y: cy - ry,
|
||||
width: rx * 2,
|
||||
height: ry * 2,
|
||||
fill,
|
||||
stroke,
|
||||
strokeWidth,
|
||||
cx,
|
||||
cy,
|
||||
r: rx
|
||||
}
|
||||
}
|
||||
|
||||
private parseText(element: SVGTextElement, id: string): WireframeElement {
|
||||
const x = parseFloat(element.getAttribute('x') || '0')
|
||||
const y = parseFloat(element.getAttribute('y') || '0')
|
||||
const text = element.textContent || ''
|
||||
const fill = element.getAttribute('fill') || '#000000'
|
||||
const fontSize = parseFloat(element.getAttribute('font-size') || '16')
|
||||
const fontFamily = element.getAttribute('font-family') || 'sans-serif'
|
||||
const fontWeight = element.getAttribute('font-weight') || 'normal'
|
||||
|
||||
// Estimate text dimensions
|
||||
const estimatedWidth = text.length * (fontSize * 0.6)
|
||||
const estimatedHeight = fontSize
|
||||
|
||||
return {
|
||||
id,
|
||||
type: 'text',
|
||||
x,
|
||||
y,
|
||||
width: estimatedWidth,
|
||||
height: estimatedHeight,
|
||||
fill,
|
||||
text,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
fontWeight
|
||||
}
|
||||
}
|
||||
|
||||
private parsePath(element: SVGPathElement, id: string): WireframeElement {
|
||||
const d = element.getAttribute('d') || ''
|
||||
const fill = element.getAttribute('fill') || 'none'
|
||||
const stroke = element.getAttribute('stroke') || '#000000'
|
||||
const strokeWidth = parseFloat(element.getAttribute('stroke-width') || '1')
|
||||
|
||||
// For paths, we'll create a bounding box
|
||||
// This is a simplified approach - in a real implementation, you'd parse the path data
|
||||
const bounds = this.getPathBounds(d)
|
||||
|
||||
return {
|
||||
id,
|
||||
type: 'path',
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
fill,
|
||||
stroke,
|
||||
strokeWidth
|
||||
}
|
||||
}
|
||||
|
||||
private parseLine(element: SVGLineElement, id: string): WireframeElement {
|
||||
const x1 = parseFloat(element.getAttribute('x1') || '0')
|
||||
const y1 = parseFloat(element.getAttribute('y1') || '0')
|
||||
const x2 = parseFloat(element.getAttribute('x2') || '0')
|
||||
const y2 = parseFloat(element.getAttribute('y2') || '0')
|
||||
const stroke = element.getAttribute('stroke') || '#000000'
|
||||
const strokeWidth = parseFloat(element.getAttribute('stroke-width') || '1')
|
||||
|
||||
const x = Math.min(x1, x2)
|
||||
const y = Math.min(y1, y2)
|
||||
const width = Math.abs(x2 - x1)
|
||||
const height = Math.abs(y2 - y1)
|
||||
|
||||
return {
|
||||
id,
|
||||
type: 'line',
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
stroke,
|
||||
strokeWidth
|
||||
}
|
||||
}
|
||||
|
||||
private parseGroup(element: Element, id: string): WireframeElement {
|
||||
const children = this.parseSVGElements(element)
|
||||
|
||||
// Calculate group bounds
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
|
||||
children.forEach(child => {
|
||||
minX = Math.min(minX, child.x)
|
||||
minY = Math.min(minY, child.y)
|
||||
maxX = Math.max(maxX, child.x + child.width)
|
||||
maxY = Math.max(maxY, child.y + child.height)
|
||||
})
|
||||
|
||||
return {
|
||||
id,
|
||||
type: 'group',
|
||||
x: minX === Infinity ? 0 : minX,
|
||||
y: minY === Infinity ? 0 : minY,
|
||||
width: maxX === -Infinity ? 0 : maxX - minX,
|
||||
height: maxY === -Infinity ? 0 : maxY - minY,
|
||||
children
|
||||
}
|
||||
}
|
||||
|
||||
private getPathBounds(d: string): { x: number; y: number; width: number; height: number } {
|
||||
// Simplified path bounds calculation
|
||||
// In a real implementation, you'd parse the path data properly
|
||||
return { x: 0, y: 0, width: 100, height: 50 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert wireframe elements to React components
|
||||
*/
|
||||
convertToComponents(wireframe: ParsedWireframe): ComponentInstance[] {
|
||||
const components: ComponentInstance[] = []
|
||||
|
||||
wireframe.elements.forEach((element, index) => {
|
||||
const component = this.elementToComponent(element, index)
|
||||
if (component) {
|
||||
components.push(component)
|
||||
}
|
||||
})
|
||||
|
||||
return components
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a single wireframe element to a React component
|
||||
*/
|
||||
private elementToComponent(element: WireframeElement, index: number): ComponentInstance | null {
|
||||
const baseProps = {
|
||||
x: element.x,
|
||||
y: element.y,
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
fill: element.fill,
|
||||
stroke: element.stroke,
|
||||
strokeWidth: element.strokeWidth
|
||||
}
|
||||
|
||||
switch (element.type) {
|
||||
case 'rect':
|
||||
return {
|
||||
id: `wireframe-${element.id}-${index}`,
|
||||
type: 'wireframe-rect',
|
||||
props: {
|
||||
...baseProps,
|
||||
rx: element.rx || 0,
|
||||
ry: element.ry || 0
|
||||
},
|
||||
position: { x: element.x, y: element.y },
|
||||
size: { width: element.width, height: element.height }
|
||||
}
|
||||
|
||||
case 'circle':
|
||||
return {
|
||||
id: `wireframe-${element.id}-${index}`,
|
||||
type: 'wireframe-circle',
|
||||
props: {
|
||||
...baseProps,
|
||||
cx: element.cx || element.x + element.width / 2,
|
||||
cy: element.cy || element.y + element.height / 2,
|
||||
r: element.r || Math.min(element.width, element.height) / 2
|
||||
},
|
||||
position: { x: element.x, y: element.y },
|
||||
size: { width: element.width, height: element.height }
|
||||
}
|
||||
|
||||
case 'text':
|
||||
return {
|
||||
id: `wireframe-${element.id}-${index}`,
|
||||
type: 'wireframe-text',
|
||||
props: {
|
||||
...baseProps,
|
||||
text: element.text || '',
|
||||
fontSize: element.fontSize || 16,
|
||||
fontFamily: element.fontFamily || 'sans-serif',
|
||||
fontWeight: element.fontWeight || 'normal'
|
||||
},
|
||||
position: { x: element.x, y: element.y },
|
||||
size: { width: element.width, height: element.height }
|
||||
}
|
||||
|
||||
case 'line':
|
||||
return {
|
||||
id: `wireframe-${element.id}-${index}`,
|
||||
type: 'wireframe-line',
|
||||
props: baseProps,
|
||||
position: { x: element.x, y: element.y },
|
||||
size: { width: element.width, height: element.height }
|
||||
}
|
||||
|
||||
case 'path':
|
||||
return {
|
||||
id: `wireframe-${element.id}-${index}`,
|
||||
type: 'wireframe-path',
|
||||
props: baseProps,
|
||||
position: { x: element.x, y: element.y },
|
||||
size: { width: element.width, height: element.height }
|
||||
}
|
||||
|
||||
case 'group':
|
||||
// For groups, we'll create a container component
|
||||
return {
|
||||
id: `wireframe-${element.id}-${index}`,
|
||||
type: 'wireframe-group',
|
||||
props: {
|
||||
...baseProps,
|
||||
children: element.children || []
|
||||
},
|
||||
position: { x: element.x, y: element.y },
|
||||
size: { width: element.width, height: element.height }
|
||||
}
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert wireframe to a single SVG component for display
|
||||
*/
|
||||
convertToSVGComponent(wireframe: ParsedWireframe): ComponentInstance {
|
||||
const timestamp = Date.now()
|
||||
const randomId = Math.random().toString(36).substr(2, 9)
|
||||
return {
|
||||
id: `wireframe-svg-${timestamp}-${randomId}`,
|
||||
type: 'wireframe-svg',
|
||||
props: {
|
||||
elements: wireframe.elements,
|
||||
width: wireframe.width,
|
||||
height: wireframe.height,
|
||||
deviceType: wireframe.deviceType
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: wireframe.width, height: wireframe.height }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const wireframeConverter = WireframeToComponentsConverter.getInstance()
|
||||
154
src/lib/wireframe-integration.tsx
Normal file
154
src/lib/wireframe-integration.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useEditorStore } from './store'
|
||||
import { wireframeConverter, ParsedWireframe } from './wireframe-converter.tsx'
|
||||
|
||||
/**
|
||||
* Hook to integrate wireframe data from the backend with the component canvas
|
||||
*/
|
||||
export function useWireframeIntegration() {
|
||||
const {
|
||||
addWireframe,
|
||||
setCurrentWireframe,
|
||||
addComponent,
|
||||
currentWireframe,
|
||||
showWireframes,
|
||||
clearWireframeComponents,
|
||||
wireframeRenderMode
|
||||
} = useEditorStore()
|
||||
|
||||
// Listen for wireframe generation events from the TLDraw canvas
|
||||
useEffect(() => {
|
||||
const handleWireframeGenerated = (event: CustomEvent<{
|
||||
svgData: string
|
||||
deviceType: 'desktop' | 'tablet' | 'mobile'
|
||||
prompt: string
|
||||
}>) => {
|
||||
try {
|
||||
const { svgData, deviceType, prompt } = event.detail
|
||||
|
||||
// Parse the SVG wireframe
|
||||
const parsedWireframe = wireframeConverter.parseSVGToWireframe(svgData, deviceType)
|
||||
|
||||
// Create wireframe data
|
||||
const wireframeData = {
|
||||
id: `wireframe-${Date.now()}`,
|
||||
name: `Wireframe - ${prompt.substring(0, 50)}...`,
|
||||
deviceType,
|
||||
data: parsedWireframe,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
|
||||
// Add to store
|
||||
addWireframe(wireframeData)
|
||||
setCurrentWireframe(wireframeData)
|
||||
|
||||
// Clear old wireframe components before adding new ones
|
||||
clearWireframeComponents()
|
||||
|
||||
// Convert to a single exact-SVG component if showing wireframes in SVG mode
|
||||
if (showWireframes && wireframeRenderMode === 'svg') {
|
||||
const component = wireframeConverter.convertToSVGComponent(parsedWireframe)
|
||||
addComponent(component)
|
||||
}
|
||||
|
||||
console.log('Wireframe integrated:', wireframeData)
|
||||
} catch (error) {
|
||||
console.error('Error integrating wireframe:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for wireframe generation events
|
||||
window.addEventListener('wireframe:generated', handleWireframeGenerated as EventListener)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('wireframe:generated', handleWireframeGenerated as EventListener)
|
||||
}
|
||||
}, [addWireframe, setCurrentWireframe, addComponent, showWireframes, clearWireframeComponents])
|
||||
|
||||
// Handle wireframe display toggle
|
||||
useEffect(() => {
|
||||
if (showWireframes && currentWireframe) {
|
||||
// Clear old wireframe components first
|
||||
clearWireframeComponents()
|
||||
|
||||
// Convert current wireframe to a single SVG component for display
|
||||
const component = wireframeConverter.convertToSVGComponent(currentWireframe.data)
|
||||
addComponent(component)
|
||||
} else if (!showWireframes) {
|
||||
// Clear wireframe components when hiding wireframes
|
||||
clearWireframeComponents()
|
||||
}
|
||||
}, [showWireframes, currentWireframe, addComponent, clearWireframeComponents])
|
||||
|
||||
return {
|
||||
currentWireframe,
|
||||
showWireframes
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Service to handle wireframe data conversion and management
|
||||
*/
|
||||
export class WireframeIntegrationService {
|
||||
private static instance: WireframeIntegrationService
|
||||
|
||||
static getInstance(): WireframeIntegrationService {
|
||||
if (!WireframeIntegrationService.instance) {
|
||||
WireframeIntegrationService.instance = new WireframeIntegrationService()
|
||||
}
|
||||
return WireframeIntegrationService.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert SVG wireframe to components and add to canvas
|
||||
*/
|
||||
convertAndAddWireframe(
|
||||
svgData: string,
|
||||
deviceType: 'desktop' | 'tablet' | 'mobile',
|
||||
prompt: string
|
||||
) {
|
||||
try {
|
||||
// Parse the SVG wireframe
|
||||
const parsedWireframe = wireframeConverter.parseSVGToWireframe(svgData, deviceType)
|
||||
|
||||
// Create wireframe data
|
||||
const wireframeData = {
|
||||
id: `wireframe-${Date.now()}`,
|
||||
name: `Wireframe - ${prompt.substring(0, 50)}...`,
|
||||
deviceType,
|
||||
data: parsedWireframe,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
|
||||
// Dispatch event to update store
|
||||
window.dispatchEvent(new CustomEvent('wireframe:generated', {
|
||||
detail: { svgData, deviceType, prompt }
|
||||
}))
|
||||
|
||||
return wireframeData
|
||||
} catch (error) {
|
||||
console.error('Error converting wireframe:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert wireframe to individual components
|
||||
*/
|
||||
convertToComponents(wireframe: ParsedWireframe) {
|
||||
return wireframeConverter.convertToComponents(wireframe)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert wireframe to single SVG component
|
||||
*/
|
||||
convertToSVGComponent(wireframe: ParsedWireframe) {
|
||||
return wireframeConverter.convertToSVGComponent(wireframe)
|
||||
}
|
||||
}
|
||||
|
||||
export const wireframeIntegrationService = WireframeIntegrationService.getInstance()
|
||||
Loading…
Reference in New Issue
Block a user