frontend changes
This commit is contained in:
parent
d57e20e9d8
commit
f3b998f6b8
@ -1,7 +1,24 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
transpilePackages: ['@tldraw/tldraw'],
|
||||
webpack: (config, { isServer }) => {
|
||||
// Fix tldraw duplication issues
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
'@tldraw/utils': require.resolve('@tldraw/utils'),
|
||||
'@tldraw/state': require.resolve('@tldraw/state'),
|
||||
'@tldraw/state-react': require.resolve('@tldraw/state-react'),
|
||||
'@tldraw/store': require.resolve('@tldraw/store'),
|
||||
'@tldraw/validate': require.resolve('@tldraw/validate'),
|
||||
'@tldraw/tlschema': require.resolve('@tldraw/tlschema'),
|
||||
'@tldraw/editor': require.resolve('@tldraw/editor'),
|
||||
'tldraw': require.resolve('tldraw'),
|
||||
'@tldraw/tldraw': require.resolve('@tldraw/tldraw'),
|
||||
};
|
||||
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
91
package-lock.json
generated
91
package-lock.json
generated
@ -15,9 +15,12 @@
|
||||
"@next/font": "^14.2.15",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
@ -30,14 +33,18 @@
|
||||
"axios": "^1.11.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"lucide-react": "^0.539.0",
|
||||
"next": "15.4.6",
|
||||
"react": "19.1.0",
|
||||
"react-day-picker": "^9.9.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",
|
||||
"vaul": "^1.1.2",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -90,6 +97,12 @@
|
||||
"anthropic-ai-sdk": "bin/cli"
|
||||
}
|
||||
},
|
||||
"node_modules/@date-fns/tz": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
|
||||
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
@ -4854,6 +4867,22 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns-jalali": {
|
||||
"version": "4.1.0-0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz",
|
||||
"integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
@ -4967,6 +4996,34 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/embla-carousel": {
|
||||
"version": "8.6.0",
|
||||
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
|
||||
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/embla-carousel-react": {
|
||||
"version": "8.6.0",
|
||||
"resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz",
|
||||
"integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"embla-carousel": "8.6.0",
|
||||
"embla-carousel-reactive-utils": "8.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/embla-carousel-reactive-utils": {
|
||||
"version": "8.6.0",
|
||||
"resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz",
|
||||
"integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"embla-carousel": "8.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
@ -8008,6 +8065,27 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-day-picker": {
|
||||
"version": "9.9.0",
|
||||
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.9.0.tgz",
|
||||
"integrity": "sha512-NtkJbuX6cl/VaGNb3sVVhmMA6LSMnL5G3xNL+61IyoZj0mUZFWTg4hmj7PHjIQ8MXN9dHWhUHFoJWG6y60DKSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@date-fns/tz": "^1.4.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns-jalali": "^4.1.0-0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/gpbl"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||
@ -9204,6 +9282,19 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vaul": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
|
||||
"integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
|
||||
@ -16,9 +16,12 @@
|
||||
"@next/font": "^14.2.15",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
@ -31,14 +34,18 @@
|
||||
"axios": "^1.11.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"lucide-react": "^0.539.0",
|
||||
"next": "15.4.6",
|
||||
"react": "19.1.0",
|
||||
"react-day-picker": "^9.9.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",
|
||||
"vaul": "^1.1.2",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -120,3 +120,62 @@
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* Added custom styles for drag-and-drop editor */
|
||||
.drag-overlay {
|
||||
@apply opacity-50 rotate-3 scale-105;
|
||||
}
|
||||
|
||||
.drop-zone-active {
|
||||
@apply ring-2 ring-accent bg-accent/5;
|
||||
}
|
||||
|
||||
.canvas-grid {
|
||||
background-image: linear-gradient(rgba(99, 102, 241, 0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(99, 102, 241, 0.1) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.resizer {
|
||||
@apply bg-border hover:bg-accent transition-colors;
|
||||
}
|
||||
|
||||
.resizer:hover {
|
||||
@apply bg-accent;
|
||||
}
|
||||
|
||||
/* Enhanced scrolling for panels */
|
||||
[data-slot="scroll-area-viewport"] {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--border)) transparent;
|
||||
}
|
||||
|
||||
[data-slot="scroll-area-viewport"]::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
[data-slot="scroll-area-viewport"]::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
[data-slot="scroll-area-viewport"]::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(var(--border));
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
[data-slot="scroll-area-viewport"]::-webkit-scrollbar-thumb:hover {
|
||||
background-color: hsl(var(--accent));
|
||||
}
|
||||
|
||||
/* Smooth scrolling for panels */
|
||||
.component-panel-scroll {
|
||||
scroll-behavior: smooth;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.properties-panel-scroll {
|
||||
scroll-behavior: smooth;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
@ -1,143 +1,82 @@
|
||||
"use client"
|
||||
|
||||
import { useRef, useState } from "react"
|
||||
import { useDroppable } from "@dnd-kit/core"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useEditorStore } from "@/lib/store"
|
||||
import { ComponentRenderer } from "./component-renderer"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ComponentRenderer } from "@/components/component-renderer"
|
||||
import { MousePointer } from "lucide-react"
|
||||
|
||||
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, selectComponent, moveComponent } = useEditorStore()
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const [dragState, setDragState] = useState<{
|
||||
id: string
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
} | null>(null)
|
||||
|
||||
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)
|
||||
}
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: "canvas",
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Canvas Header */}
|
||||
<div className="p-4 border-b border-border bg-card">
|
||||
<h2 className="text-lg font-semibold text-card-foreground">Canvas</h2>
|
||||
<p className="text-sm text-muted-foreground">Drag components here to build your interface</p>
|
||||
</div>
|
||||
|
||||
{/* Canvas Area */}
|
||||
<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'
|
||||
ref={(node) => {
|
||||
setNodeRef(node)
|
||||
containerRef.current = node
|
||||
}}
|
||||
/>
|
||||
</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,
|
||||
className={`flex-1 relative canvas-grid overflow-auto ${isOver ? "drop-zone-active" : ""}`}
|
||||
onClick={() => selectComponent(null)}
|
||||
onMouseMove={(e) => {
|
||||
if (!dragState || !containerRef.current) return
|
||||
const rect = containerRef.current.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left - dragState.offsetX + containerRef.current.scrollLeft
|
||||
const y = e.clientY - rect.top - dragState.offsetY + containerRef.current.scrollTop
|
||||
moveComponent(dragState.id, { x: Math.max(0, x), y: Math.max(0, y) })
|
||||
}}
|
||||
onClick={(e) => handleComponentClick(component.id, e)}
|
||||
onMouseDown={(e) => handleComponentMouseDown(component.id, e)}
|
||||
onMouseUp={() => setDragState(null)}
|
||||
onMouseLeave={() => setDragState(null)}
|
||||
>
|
||||
<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 && (
|
||||
{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 className="text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-muted flex items-center justify-center">
|
||||
<MousePointer className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">Start Building</h3>
|
||||
<p className="text-muted-foreground max-w-sm">
|
||||
Drag components from the sidebar to start building your interface
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
components.map((component) => (
|
||||
<ComponentRenderer
|
||||
key={component.id}
|
||||
component={component}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
selectComponent(component)
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
const target = e.currentTarget as HTMLDivElement
|
||||
const rect = target.getBoundingClientRect()
|
||||
const offsetX = e.clientX - rect.left
|
||||
const offsetY = e.clientY - rect.top
|
||||
setDragState({ id: component.id, offsetX, offsetY })
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,213 +1,132 @@
|
||||
"use client"
|
||||
|
||||
import { useDraggable } from "@dnd-kit/core"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
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,
|
||||
Table,
|
||||
Calendar,
|
||||
Mail,
|
||||
Phone,
|
||||
Globe,
|
||||
Search,
|
||||
Filter,
|
||||
Download,
|
||||
Upload,
|
||||
Edit,
|
||||
Trash2,
|
||||
Plus,
|
||||
Minus,
|
||||
Check,
|
||||
X,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Circle,
|
||||
Square,
|
||||
Type,
|
||||
MousePointer,
|
||||
CreditCard,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Menu,
|
||||
MoreHorizontal,
|
||||
Star,
|
||||
Heart,
|
||||
Share,
|
||||
Bookmark,
|
||||
Flag,
|
||||
AlertCircle,
|
||||
Info,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
HelpCircle
|
||||
Tags as Tabs,
|
||||
BarChart3,
|
||||
ToggleLeft,
|
||||
User,
|
||||
Medal as Modal,
|
||||
Search,
|
||||
} 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 },
|
||||
]
|
||||
}
|
||||
const COMPONENT_TYPES = [
|
||||
{ id: "data-table", name: "Data Table", icon: Table, category: "Data" },
|
||||
{ id: "contextmenu", name: "Context Menu", icon: MousePointer, category: "Overlay" },
|
||||
{ id: "menubar", name: "Menubar", icon: Tabs, category: "Layout" },
|
||||
{ id: "carousel", name: "Carousel", icon: BarChart3, category: "Display" },
|
||||
{ id: "drawer", name: "Drawer", icon: Modal, category: "Overlay" },
|
||||
{ id: "table", name: "Table", icon: Table, category: "Data" },
|
||||
{ id: "datepicker", name: "Date Picker", icon: Calendar, category: "Input" },
|
||||
{ id: "radiogroup", name: "Radio Group", icon: Circle, category: "Input" },
|
||||
{ id: "checkbox", name: "Checkbox", icon: Square, category: "Input" },
|
||||
{ id: "input", name: "Input", icon: Type, category: "Input" },
|
||||
{ id: "textarea", name: "Textarea", icon: Type, category: "Input" },
|
||||
{ id: "button", name: "Button", icon: MousePointer, category: "Action" },
|
||||
{ id: "card", name: "Card", icon: CreditCard, category: "Layout" },
|
||||
{ id: "select", name: "Select", icon: ChevronDown, category: "Input" },
|
||||
{ id: "tabs", name: "Tabs", icon: Tabs, category: "Layout" },
|
||||
{ id: "progress", name: "Progress", icon: BarChart3, category: "Display" },
|
||||
{ id: "switch", name: "Switch", icon: ToggleLeft, category: "Input" },
|
||||
{ id: "avatar", name: "Avatar", icon: User, category: "Display" },
|
||||
{ id: "dialog", name: "Dialog", icon: Modal, category: "Overlay" },
|
||||
]
|
||||
|
||||
export function ComponentPalette() {
|
||||
const [selectedCategory, setSelectedCategory] = useState("Basic")
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
function DraggableComponent({ component }: { component: (typeof COMPONENT_TYPES)[0] }) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
id: component.id,
|
||||
})
|
||||
|
||||
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)
|
||||
const style = transform
|
||||
? {
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
}
|
||||
: undefined
|
||||
|
||||
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"
|
||||
)}
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={`p-3 bg-card border border-border rounded-md cursor-grab hover:bg-accent/10 transition-colors ${
|
||||
isDragging ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<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 className="flex items-center gap-2">
|
||||
<component.icon className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-card-foreground">{component.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ComponentPalette() {
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("All")
|
||||
|
||||
const categories = ["All", ...Array.from(new Set(COMPONENT_TYPES.map((c) => c.category)))]
|
||||
|
||||
const filteredComponents = COMPONENT_TYPES.filter((component) => {
|
||||
const matchesSearch = component.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
const matchesCategory = selectedCategory === "All" || component.category === selectedCategory
|
||||
return matchesSearch && matchesCategory
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-sidebar-border">
|
||||
<h2 className="text-lg font-semibold text-sidebar-foreground mb-3">Components</h2>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search components..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9 bg-input border-border"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
className={`px-2 py-1 text-xs rounded-md transition-colors ${
|
||||
selectedCategory === category
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "bg-sidebar hover:bg-sidebar-accent/20 text-sidebar-foreground"
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Component List */}
|
||||
<ScrollArea className="flex-1 component-panel-scroll">
|
||||
<div className="p-4 space-y-2">
|
||||
{filteredComponents.map((component) => (
|
||||
<DraggableComponent key={component.id} component={component} />
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,135 +1,254 @@
|
||||
"use client"
|
||||
|
||||
import { ComponentInstance } from "@/lib/store"
|
||||
import type React from "react"
|
||||
import { useState } from "react"
|
||||
|
||||
import { type ComponentInstance, useEditorStore } from "@/lib/store"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu"
|
||||
import {
|
||||
Menubar,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
} from "@/components/ui/menubar"
|
||||
import {
|
||||
Carousel as UiCarousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
} from "@/components/ui/carousel"
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Plus, Minus, User } from "lucide-react"
|
||||
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
|
||||
onClick?: (e: React.MouseEvent) => void
|
||||
onMouseDown?: (e: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
export function ComponentRenderer({
|
||||
component,
|
||||
isSelected = false,
|
||||
onClick,
|
||||
onMouseDown
|
||||
}: ComponentRendererProps) {
|
||||
const { type, props } = component
|
||||
export function ComponentRenderer({ component, onClick, onMouseDown }: ComponentRendererProps) {
|
||||
const { selectedComponent, updateComponent } = useEditorStore()
|
||||
const isSelected = selectedComponent?.id === component.id
|
||||
|
||||
// Handle wireframe components
|
||||
if (type.startsWith('wireframe-')) {
|
||||
return (
|
||||
<WireframeRenderer
|
||||
component={component}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
/>
|
||||
const [checkboxState, setCheckboxState] = useState(component.props.checked || false)
|
||||
const [switchState, setSwitchState] = useState(component.props.checked || false)
|
||||
const [radioValue, setRadioValue] = useState(component.props.value || "")
|
||||
const [progressValue, setProgressValue] = useState(component.props.value || 50)
|
||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>(
|
||||
component.props.selectedDate ? new Date(component.props.selectedDate) : undefined,
|
||||
)
|
||||
}
|
||||
const [tableData, setTableData] = useState(
|
||||
component.props.rows || [
|
||||
["John", "john@example.com"],
|
||||
["Jane", "jane@example.com"],
|
||||
["kenil", "kenil@example.com"],
|
||||
["kavya", "kavya@example.com"],
|
||||
["raj", "raj@example.com"]
|
||||
],
|
||||
)
|
||||
const [editingCell, setEditingCell] = useState<{ row: number; col: number } | null>(null)
|
||||
const [editValue, setEditValue] = useState("")
|
||||
const [cardTitle, setCardTitle] = useState(component.props.title || "Card Title")
|
||||
const [cardDescription, setCardDescription] = useState(component.props.description || "Card description")
|
||||
const [cardContent, setCardContent] = useState(component.props.content || "Card content goes here")
|
||||
const [editingCardField, setEditingCardField] = useState<string | null>(null)
|
||||
const [dataTableSortAsc, setDataTableSortAsc] = useState(true)
|
||||
const [dataTablePage, setDataTablePage] = useState(1)
|
||||
const [dataTableSelected, setDataTableSelected] = useState<number[]>([])
|
||||
const [tableSortAsc, setTableSortAsc] = useState(true)
|
||||
const [tablePage, setTablePage] = useState(1)
|
||||
const [tableSelected, setTableSelected] = useState<number[]>([])
|
||||
|
||||
// Handle regular UI components
|
||||
const renderComponent = () => {
|
||||
switch (type) {
|
||||
case 'button':
|
||||
switch (component.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}
|
||||
variant={component.props.variant || "default"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
window.alert(`${component.props.children || "Button"} was clicked`)
|
||||
}}
|
||||
>
|
||||
{props.children || 'Button'}
|
||||
{component.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 "input":
|
||||
return <Input placeholder={component.props.placeholder || "Enter text..."} className="w-full max-w-sm" />
|
||||
|
||||
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 "textarea":
|
||||
return <Textarea placeholder={component.props.placeholder || "Enter text..."} className="w-full max-w-sm" />
|
||||
|
||||
case 'card':
|
||||
case "card":
|
||||
return (
|
||||
<Card className={cn("w-full h-full", isSelected && "ring-2 ring-blue-500")}>
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">{props.title || 'Card Title'}</CardTitle>
|
||||
<CardTitle>
|
||||
{editingCardField === "title" ? (
|
||||
<Input
|
||||
value={cardTitle}
|
||||
onChange={(e) => setCardTitle(e.target.value)}
|
||||
onBlur={() => {
|
||||
setEditingCardField(null)
|
||||
updateComponent(component.id, { props: { title: cardTitle } })
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setEditingCardField(null)
|
||||
updateComponent(component.id, { props: { title: cardTitle } })
|
||||
}
|
||||
}}
|
||||
className="text-lg font-semibold"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditingCardField("title")
|
||||
}}
|
||||
className="cursor-text hover:bg-muted/50 px-1 rounded"
|
||||
>
|
||||
{cardTitle}
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{editingCardField === "description" ? (
|
||||
<Input
|
||||
value={cardDescription}
|
||||
onChange={(e) => setCardDescription(e.target.value)}
|
||||
onBlur={() => {
|
||||
setEditingCardField(null)
|
||||
updateComponent(component.id, { props: { description: cardDescription } })
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setEditingCardField(null)
|
||||
updateComponent(component.id, { props: { description: cardDescription } })
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditingCardField("description")
|
||||
}}
|
||||
className="cursor-text hover:bg-muted/50 px-1 rounded"
|
||||
>
|
||||
{cardDescription}
|
||||
</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-gray-600">{props.description || 'Card description'}</p>
|
||||
{editingCardField === "content" ? (
|
||||
<Textarea
|
||||
value={cardContent}
|
||||
onChange={(e) => setCardContent(e.target.value)}
|
||||
onBlur={() => {
|
||||
setEditingCardField(null)
|
||||
updateComponent(component.id, { props: { content: cardContent } })
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditingCardField("content")
|
||||
}}
|
||||
className="cursor-text hover:bg-muted/50 px-1 rounded"
|
||||
>
|
||||
{cardContent}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
case 'checkbox':
|
||||
case "checkbox":
|
||||
return (
|
||||
<div className={cn("flex items-center space-x-2 w-full h-full", isSelected && "ring-2 ring-blue-500")}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={component.id}
|
||||
checked={props.checked || false}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
checked={checkboxState}
|
||||
onCheckedChange={(checked) => {
|
||||
setCheckboxState(!!checked)
|
||||
updateComponent(component.id, { props: { checked: !!checked } })
|
||||
}}
|
||||
/>
|
||||
<label htmlFor={component.id} className="text-sm">
|
||||
{props.label || 'Checkbox'}
|
||||
</label>
|
||||
<Label htmlFor={component.id}>{component.props.label || "Checkbox"}</Label>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'switch':
|
||||
case "switch":
|
||||
return (
|
||||
<div className={cn("flex items-center space-x-2 w-full h-full", isSelected && "ring-2 ring-blue-500")}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={props.checked || false}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
id={component.id}
|
||||
checked={switchState}
|
||||
onCheckedChange={(checked) => {
|
||||
setSwitchState(checked)
|
||||
updateComponent(component.id, { props: { checked } })
|
||||
}}
|
||||
/>
|
||||
<label className="text-sm">
|
||||
{props.label || 'Switch'}
|
||||
</label>
|
||||
<Label htmlFor={component.id}>{component.props.label || "Switch"}</Label>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'select':
|
||||
case "select":
|
||||
return (
|
||||
<Select>
|
||||
<SelectTrigger className={cn("w-full h-full", isSelected && "ring-2 ring-blue-500")}>
|
||||
<SelectValue placeholder={props.placeholder || 'Select option...'} />
|
||||
<SelectTrigger className="w-full max-w-sm">
|
||||
<SelectValue placeholder={component.props.placeholder || "Select option..."} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(props.options || ['Option 1', 'Option 2']).map((option: string, index: number) => (
|
||||
<SelectItem key={index} value={option}>
|
||||
{(component.props.options || ["Option 1", "Option 2"]).map((option: string, index: number) => (
|
||||
<SelectItem key={index} value={option.toLowerCase()}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
@ -137,116 +256,539 @@ export function ComponentRenderer({
|
||||
</Select>
|
||||
)
|
||||
|
||||
case 'radiogroup':
|
||||
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}
|
||||
value={radioValue}
|
||||
onValueChange={(value) => {
|
||||
setRadioValue(value)
|
||||
updateComponent(component.id, { props: { value } })
|
||||
}}
|
||||
>
|
||||
{(props.options || ['Option 1', 'Option 2']).map((option: string, index: number) => (
|
||||
{(component.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>
|
||||
<Label htmlFor={`${component.id}-${index}`}>{option}</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)
|
||||
|
||||
case 'progress':
|
||||
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 className="w-full max-w-sm space-y-2">
|
||||
<Progress value={progressValue} className="w-full" />
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const newValue = Math.max(0, progressValue - 10)
|
||||
setProgressValue(newValue)
|
||||
updateComponent(component.id, { props: { value: newValue } })
|
||||
}}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground">{progressValue}%</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const newValue = Math.min(100, progressValue + 10)
|
||||
setProgressValue(newValue)
|
||||
updateComponent(component.id, { props: { value: newValue } })
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'avatar':
|
||||
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>
|
||||
{component.props.src && <AvatarImage src={component.props.src || "/placeholder.svg"} alt="Avatar" />}
|
||||
<AvatarFallback>
|
||||
{component.props.fallback ? component.props.fallback : <User className="h-4 w-4" />}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'table':
|
||||
case "table": {
|
||||
const headers: string[] = component.props.headers || ["Name", "Email"]
|
||||
const pageSize: number = component.props.pageSize || 5
|
||||
const sortIndex: number = component.props.sortIndex ?? 0
|
||||
const sorted = [...tableData].sort((a, b) => {
|
||||
const va = a[sortIndex]?.toString().toLowerCase() ?? ""
|
||||
const vb = b[sortIndex]?.toString().toLowerCase() ?? ""
|
||||
const cmp = va.localeCompare(vb)
|
||||
return tableSortAsc ? cmp : -cmp
|
||||
})
|
||||
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize))
|
||||
const page = Math.min(tablePage, totalPages)
|
||||
const start = (page - 1) * pageSize
|
||||
const pageRows = sorted.slice(start, start + pageSize)
|
||||
|
||||
const toggleRow = (idx: number) => {
|
||||
setTableSelected((sel) => (sel.includes(idx) ? sel.filter((i) => i !== idx) : [...sel, idx]))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("w-full h-full overflow-auto", isSelected && "ring-2 ring-blue-500")}>
|
||||
<div className="w-full max-w-md space-y-2">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{(props.headers || ['Name', 'Email', 'Role']).map((header: string, index: number) => (
|
||||
<TableHead key={index} className="text-xs">{header}</TableHead>
|
||||
<TableHead className="w-[40px]">Sel</TableHead>
|
||||
{headers.map((header: string, index: number) => (
|
||||
<TableHead key={index}>
|
||||
<button
|
||||
className="underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (sortIndex === index) setTableSortAsc(!tableSortAsc)
|
||||
updateComponent(component.id, { props: { sortIndex: index } })
|
||||
}}
|
||||
>
|
||||
{header}
|
||||
</button>
|
||||
</TableHead>
|
||||
))}
|
||||
<TableHead className="w-[50px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pageRows.map((row: string[], rowIndex: number) => (
|
||||
<TableRow key={start + rowIndex} className={tableSelected.includes(start + rowIndex) ? "bg-accent/20" : ""}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
id={`${component.id}-t-sel-${start + rowIndex}`}
|
||||
checked={tableSelected.includes(start + rowIndex)}
|
||||
onCheckedChange={() => toggleRow(start + rowIndex)}
|
||||
/>
|
||||
</TableCell>
|
||||
{row.map((cell: string, cellIndex: number) => (
|
||||
<TableCell key={cellIndex}>
|
||||
{editingCell?.row === start + rowIndex && editingCell?.col === cellIndex ? (
|
||||
<Input
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={() => {
|
||||
const newData = [...sorted]
|
||||
newData[start + rowIndex][cellIndex] = editValue
|
||||
// map back to original order not required for display; persist merged
|
||||
const merged = [...tableData]
|
||||
merged[start + rowIndex] = newData[start + rowIndex]
|
||||
setTableData(merged)
|
||||
updateComponent(component.id, { props: { rows: merged } })
|
||||
setEditingCell(null)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
const newData = [...sorted]
|
||||
newData[start + rowIndex][cellIndex] = editValue
|
||||
const merged = [...tableData]
|
||||
merged[start + rowIndex] = newData[start + rowIndex]
|
||||
setTableData(merged)
|
||||
updateComponent(component.id, { props: { rows: merged } })
|
||||
setEditingCell(null)
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
className="h-8"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditingCell({ row: start + rowIndex, col: cellIndex })
|
||||
setEditValue(cell)
|
||||
}}
|
||||
className="cursor-text hover:bg-muted/50 px-1 rounded block min-h-[20px]"
|
||||
>
|
||||
{cell}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const merged = tableData.filter((_: string[], index: number) => index !== start + rowIndex)
|
||||
setTableData(merged)
|
||||
updateComponent(component.id, { props: { rows: merged } })
|
||||
}}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setTablePage(Math.max(1, page - 1))
|
||||
}}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">Page {page} / {totalPages}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setTablePage(Math.min(totalPages, page + 1))
|
||||
}}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const newRow = Array(tableData[0]?.length || headers.length || 2).fill("New")
|
||||
const newData = [...tableData, newRow]
|
||||
setTableData(newData)
|
||||
updateComponent(component.id, { props: { rows: newData } })
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Add Row
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
case "data-table": {
|
||||
const headers: string[] = component.props.headers || ["Name", "Email"]
|
||||
const pageSize: number = component.props.pageSize || 5
|
||||
const sortIndex: number = component.props.sortIndex ?? 0
|
||||
const sorted = [...tableData].sort((a, b) => {
|
||||
const va = a[sortIndex]?.toString().toLowerCase() ?? ""
|
||||
const vb = b[sortIndex]?.toString().toLowerCase() ?? ""
|
||||
const cmp = va.localeCompare(vb)
|
||||
return dataTableSortAsc ? cmp : -cmp
|
||||
})
|
||||
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize))
|
||||
const page = Math.min(dataTablePage, totalPages)
|
||||
const start = (page - 1) * pageSize
|
||||
const pageRows = sorted.slice(start, start + pageSize)
|
||||
|
||||
const toggleRow = (idx: number) => {
|
||||
setDataTableSelected((sel) =>
|
||||
sel.includes(idx) ? sel.filter((i) => i !== idx) : [...sel, idx],
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md space-y-2">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px]">Sel</TableHead>
|
||||
{headers.map((header: string, index: number) => (
|
||||
<TableHead key={index}>
|
||||
<button
|
||||
className="underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (sortIndex === index) setDataTableSortAsc(!dataTableSortAsc)
|
||||
updateComponent(component.id, { props: { sortIndex: index } })
|
||||
}}
|
||||
>
|
||||
{header}
|
||||
</button>
|
||||
</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}>
|
||||
{pageRows.map((row: string[], rowIndex: number) => (
|
||||
<TableRow key={start + rowIndex} className={dataTableSelected.includes(start + rowIndex) ? "bg-accent/20" : ""}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
id={`${component.id}-sel-${start + rowIndex}`}
|
||||
checked={dataTableSelected.includes(start + rowIndex)}
|
||||
onCheckedChange={() => toggleRow(start + rowIndex)}
|
||||
/>
|
||||
</TableCell>
|
||||
{row.map((cell: string, cellIndex: number) => (
|
||||
<TableCell key={cellIndex} className="text-xs">{cell}</TableCell>
|
||||
<TableCell key={cellIndex}>
|
||||
{editingCell?.row === start + rowIndex && editingCell?.col === cellIndex ? (
|
||||
<Input
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={() => {
|
||||
const merged = [...tableData]
|
||||
merged[start + rowIndex][cellIndex] = editValue
|
||||
setTableData(merged)
|
||||
updateComponent(component.id, { props: { rows: merged } })
|
||||
setEditingCell(null)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
const merged = [...tableData]
|
||||
merged[start + rowIndex][cellIndex] = editValue
|
||||
setTableData(merged)
|
||||
updateComponent(component.id, { props: { rows: merged } })
|
||||
setEditingCell(null)
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
className="h-8"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditingCell({ row: start + rowIndex, col: cellIndex })
|
||||
setEditValue(cell)
|
||||
}}
|
||||
className="cursor-text hover:bg-muted/50 px-1 rounded block min-h-[20px]"
|
||||
>
|
||||
{cell}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const newRow = Array(headers.length || 2).fill("New")
|
||||
const newData = [...tableData, newRow]
|
||||
setTableData(newData)
|
||||
updateComponent(component.id, { props: { rows: newData } })
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Add Row
|
||||
</Button>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDataTablePage(Math.max(1, page - 1))
|
||||
}}
|
||||
>
|
||||
Prev
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Page {page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDataTablePage(Math.min(totalPages, page + 1))
|
||||
}}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
case "contextmenu":
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div className="w-[220px] h-[120px] bg-muted/30 flex items-center justify-center rounded">
|
||||
Right-click me
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-64">
|
||||
<ContextMenuLabel>Quick Actions</ContextMenuLabel>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={(e: React.MouseEvent) => e.stopPropagation()}>Copy</ContextMenuItem>
|
||||
<ContextMenuItem onClick={(e: React.MouseEvent) => e.stopPropagation()}>Paste</ContextMenuItem>
|
||||
<ContextMenuItem onClick={(e: React.MouseEvent) => e.stopPropagation()}>Delete</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
|
||||
case "menubar":
|
||||
return (
|
||||
<Menubar>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>File</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem onClick={(e: React.MouseEvent) => e.stopPropagation()}>New</MenubarItem>
|
||||
<MenubarItem onClick={(e: React.MouseEvent) => e.stopPropagation()}>Open</MenubarItem>
|
||||
<MenubarItem onClick={(e: React.MouseEvent) => e.stopPropagation()}>Save</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>Edit</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem onClick={(e: React.MouseEvent) => e.stopPropagation()}>Cut</MenubarItem>
|
||||
<MenubarItem onClick={(e: React.MouseEvent) => e.stopPropagation()}>Copy</MenubarItem>
|
||||
<MenubarItem onClick={(e: React.MouseEvent) => e.stopPropagation()}>Paste</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
)
|
||||
|
||||
case "carousel":
|
||||
return (
|
||||
<div className="w-[260px]">
|
||||
<UiCarousel className="relative">
|
||||
<CarouselContent>
|
||||
{(["One", "Two", "Three"] as string[]).map((label, idx) => (
|
||||
<CarouselItem key={idx} className="p-4">
|
||||
<Card className="h-[120px] flex items-center justify-center">
|
||||
<CardContent className="p-0">
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
<CarouselPrevious />
|
||||
<CarouselNext />
|
||||
</UiCarousel>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'tabs':
|
||||
case "drawer":
|
||||
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()}>
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<Button variant="outline">Open Drawer</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{component.props.title || "Drawer Title"}</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
{component.props.description || "This is a drawer. You can close it below."}
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="p-4">
|
||||
<DrawerClose asChild>
|
||||
<Button variant="secondary">Close</Button>
|
||||
</DrawerClose>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
|
||||
case "tabs":
|
||||
return (
|
||||
<Tabs defaultValue="tab-0" className="w-full max-w-sm">
|
||||
<TabsList>
|
||||
{(component.props.tabs || [{ label: "Tab 1" }, { label: "Tab 2" }]).map((tab: any, index: number) => (
|
||||
<TabsTrigger key={index} value={`tab-${index}`}>
|
||||
{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>
|
||||
{(component.props.tabs || [{ content: "Content 1" }, { content: "Content 2" }]).map(
|
||||
(tab: any, index: number) => (
|
||||
<TabsContent key={index} value={`tab-${index}`}>
|
||||
{tab.content || `Content ${index + 1}`}
|
||||
</TabsContent>
|
||||
))}
|
||||
),
|
||||
)}
|
||||
</Tabs>
|
||||
)
|
||||
|
||||
default:
|
||||
case "datepicker":
|
||||
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}
|
||||
className="w-[360px]"
|
||||
onMouseDown={(e) => e.stopPropagation()} // prevent dragging out of the component
|
||||
onClick={(e) => e.stopPropagation()} // prevent clicking out of the component
|
||||
>
|
||||
{type}
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={(date: Date | undefined) => {
|
||||
setSelectedDate(date)
|
||||
updateComponent(component.id, {
|
||||
props: {
|
||||
...component.props,
|
||||
selectedDate: date?.toISOString(),
|
||||
},
|
||||
})
|
||||
}}
|
||||
className="rounded-md border w-[360px] p-4 [--cell-size:2.4rem]"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case "dialog":
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">Open Dialog</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{component.props.title || "Dialog Title"}</DialogTitle>
|
||||
<DialogDescription>{component.props.description || "Dialog description"}</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
// Wireframe components
|
||||
case "wireframe-svg":
|
||||
case "wireframe-rect":
|
||||
case "wireframe-circle":
|
||||
case "wireframe-text":
|
||||
case "wireframe-line":
|
||||
case "wireframe-path":
|
||||
case "wireframe-group":
|
||||
return (
|
||||
<WireframeRenderer
|
||||
component={component}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
/>
|
||||
)
|
||||
|
||||
default:
|
||||
return <div className="p-4 border border-dashed border-border rounded">Unknown Component</div>
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<div
|
||||
className={`absolute cursor-pointer transition-all ${
|
||||
isSelected ? "ring-2 ring-accent ring-offset-2" : "hover:ring-1 hover:ring-border"
|
||||
}`}
|
||||
style={{
|
||||
left: component.position.x,
|
||||
top: component.position.y,
|
||||
}}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
>
|
||||
{renderComponent()}
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -46,6 +46,13 @@ export function DualCanvasEditor({
|
||||
// Initialize wireframe integration
|
||||
useWireframeIntegration()
|
||||
|
||||
// Auto-show wireframes when components are added
|
||||
useEffect(() => {
|
||||
if (components.length > 0 && components.some(comp => comp.type.startsWith('wireframe-'))) {
|
||||
setShowWireframes(true)
|
||||
}
|
||||
}, [components, setShowWireframes])
|
||||
|
||||
const handleDeviceChange = (device: 'desktop' | 'tablet' | 'mobile') => {
|
||||
if (onDeviceChange) {
|
||||
onDeviceChange(device)
|
||||
@ -143,6 +150,29 @@ export function DualCanvasEditor({
|
||||
</Tabs>
|
||||
{canvasMode === 'components' && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Switch to wireframe mode to generate wireframe
|
||||
setCanvasMode('wireframe')
|
||||
// Trigger wireframe generation after a short delay
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent("tldraw:generate", {
|
||||
detail: {
|
||||
prompt: "Dashboard with header, left sidebar, 3 stats cards, a line chart and a data table, plus footer.",
|
||||
device: selectedDevice
|
||||
}
|
||||
}))
|
||||
// Switch back to components mode after generation
|
||||
setTimeout(() => setCanvasMode('components'), 2000)
|
||||
}, 100)
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<PenTool className="h-4 w-4" />
|
||||
Generate Wireframe
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@ -169,7 +199,7 @@ export function DualCanvasEditor({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="h-[calc(100vh-100px)] w-full">
|
||||
<div className="h-[calc(100%-80px)] w-full">
|
||||
{canvasMode === 'wireframe' ? (
|
||||
<div className="h-full w-full flex">
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
@ -1407,7 +1407,7 @@ function AIMockupStep({
|
||||
</div>
|
||||
|
||||
{/* Dual Canvas Editor */}
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl overflow-hidden" style={{ height: '700px' }}>
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl overflow-hidden h-[80vh] min-h-[600px]">
|
||||
<DualCanvasEditor
|
||||
className="h-full w-full"
|
||||
onWireframeGenerated={handleWireframeGenerated}
|
||||
|
||||
@ -1,323 +1,264 @@
|
||||
"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"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Trash2, X } from "lucide-react"
|
||||
|
||||
export function PropertiesPanel() {
|
||||
const { selectedComponent, updateComponent, removeComponent } = useEditorStore()
|
||||
const [localProps, setLocalProps] = useState<Record<string, any>>({})
|
||||
const { selectedComponent, updateComponent, removeComponent, selectComponent } = useEditorStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedComponent) {
|
||||
setLocalProps(selectedComponent.props)
|
||||
if (!selectedComponent) {
|
||||
return null
|
||||
}
|
||||
}, [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)
|
||||
}
|
||||
updateComponent(selectedComponent.id, {
|
||||
props: { ...selectedComponent.props, [key]: value },
|
||||
})
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (selectedComponent) {
|
||||
removeComponent(selectedComponent.id)
|
||||
}
|
||||
selectComponent(null)
|
||||
}
|
||||
|
||||
if (!selectedComponent) {
|
||||
const renderPropertyEditor = () => {
|
||||
switch (selectedComponent.type) {
|
||||
case "button":
|
||||
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">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="button-text">Button Text</Label>
|
||||
<Input
|
||||
id={key}
|
||||
value={value}
|
||||
onChange={(e) => handlePropChange(key, e.target.value)}
|
||||
className="text-xs"
|
||||
id="button-text"
|
||||
value={selectedComponent.props.children || ""}
|
||||
onChange={(e) => handlePropChange("children", e.target.value)}
|
||||
placeholder="Button text"
|
||||
/>
|
||||
<div
|
||||
className="w-6 h-6 rounded border"
|
||||
style={{ backgroundColor: value }}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="button-variant">Variant</Label>
|
||||
<Select
|
||||
value={selectedComponent.props.variant || "default"}
|
||||
onValueChange={(value) => handlePropChange("variant", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
<SelectItem value="destructive">Destructive</SelectItem>
|
||||
<SelectItem value="outline">Outline</SelectItem>
|
||||
<SelectItem value="secondary">Secondary</SelectItem>
|
||||
<SelectItem value="ghost">Ghost</SelectItem>
|
||||
<SelectItem value="link">Link</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case "input":
|
||||
case "textarea":
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="placeholder">Placeholder</Label>
|
||||
<Input
|
||||
id="placeholder"
|
||||
value={selectedComponent.props.placeholder || ""}
|
||||
onChange={(e) => handlePropChange("placeholder", e.target.value)}
|
||||
placeholder="Enter placeholder text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (key.includes('text') || key.includes('placeholder') || key.includes('label')) {
|
||||
case "card":
|
||||
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="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="card-title">Title</Label>
|
||||
<Input
|
||||
id="card-title"
|
||||
value={selectedComponent.props.title || ""}
|
||||
onChange={(e) => handlePropChange("title", e.target.value)}
|
||||
placeholder="Card title"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="card-description">Description</Label>
|
||||
<Textarea
|
||||
id={key}
|
||||
value={value}
|
||||
onChange={(e) => handlePropChange(key, e.target.value)}
|
||||
className="text-xs min-h-[60px]"
|
||||
id="card-description"
|
||||
value={selectedComponent.props.description || ""}
|
||||
onChange={(e) => handlePropChange("description", e.target.value)}
|
||||
placeholder="Card description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
case "checkbox":
|
||||
case "switch":
|
||||
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="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="label">Label</Label>
|
||||
<Input
|
||||
id={key}
|
||||
value={value}
|
||||
onChange={(e) => handlePropChange(key, e.target.value)}
|
||||
className="text-xs"
|
||||
id="label"
|
||||
value={selectedComponent.props.label || ""}
|
||||
onChange={(e) => handlePropChange("label", e.target.value)}
|
||||
placeholder="Label text"
|
||||
/>
|
||||
</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>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id={key}
|
||||
checked={value}
|
||||
onCheckedChange={(checked) => handlePropChange(key, checked)}
|
||||
id="checked"
|
||||
checked={selectedComponent.props.checked || false}
|
||||
onCheckedChange={(checked) => handlePropChange("checked", checked)}
|
||||
/>
|
||||
<Label htmlFor="checked">Checked</Label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'object':
|
||||
if (Array.isArray(value)) {
|
||||
case "progress":
|
||||
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">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="progress-value">Value</Label>
|
||||
<Input
|
||||
value={item}
|
||||
onChange={(e) => {
|
||||
const newArray = [...value]
|
||||
newArray[index] = e.target.value
|
||||
handlePropChange(key, newArray)
|
||||
}}
|
||||
className="text-xs"
|
||||
id="progress-value"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={selectedComponent.props.value || 50}
|
||||
onChange={(e) => handlePropChange("value", Number.parseInt(e.target.value))}
|
||||
/>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
case "avatar":
|
||||
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 className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="fallback">Fallback Text</Label>
|
||||
<Input
|
||||
id="fallback"
|
||||
value={selectedComponent.props.fallback || ""}
|
||||
onChange={(e) => handlePropChange("fallback", e.target.value)}
|
||||
placeholder="AB"
|
||||
maxLength={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="text-sm text-muted-foreground">No properties available for this component type.</div>
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-white">
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
{/* 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 className="p-4 border-b border-sidebar-border flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-sidebar-foreground">Properties</h2>
|
||||
<Button variant="ghost" size="sm" onClick={() => selectComponent(null)} className="h-8 w-8 p-0">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 properties-panel-scroll">
|
||||
<div className="p-4 space-y-6">
|
||||
{/* 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>
|
||||
)}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Component Info</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Type</Label>
|
||||
<p className="text-sm font-medium capitalize">{selectedComponent.type}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">ID</Label>
|
||||
<p className="text-sm font-mono text-muted-foreground">{selectedComponent.id}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 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>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Properties</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>{renderPropertyEditor()}</CardContent>
|
||||
</Card>
|
||||
|
||||
{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>
|
||||
{/* Position */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Position</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label htmlFor="pos-x" className="text-xs">
|
||||
X
|
||||
</Label>
|
||||
<Input
|
||||
id="pos-x"
|
||||
type="number"
|
||||
value={selectedComponent.position.x}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
position: { ...selectedComponent.position, x: Number.parseInt(e.target.value) || 0 },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label htmlFor="pos-y" className="text-xs">
|
||||
Y
|
||||
</Label>
|
||||
<Input
|
||||
id="pos-y"
|
||||
type="number"
|
||||
value={selectedComponent.position.y}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
position: { ...selectedComponent.position, y: Number.parseInt(e.target.value) || 0 },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 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" />
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="destructive" size="sm" onClick={handleDelete} className="w-full">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete Component
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
213
src/components/ui/calendar.tsx
Normal file
213
src/components/ui/calendar.tsx
Normal file
@ -0,0 +1,213 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react"
|
||||
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"relative flex flex-col gap-4 md:flex-row",
|
||||
defaultClassNames.months
|
||||
),
|
||||
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn(
|
||||
"bg-popover absolute inset-0 opacity-0",
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn("mt-2 flex w-full", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"w-[--cell-size] select-none",
|
||||
defaultClassNames.week_number_header
|
||||
),
|
||||
week_number: cn(
|
||||
"text-muted-foreground select-none text-[0.8rem]",
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
day: cn(
|
||||
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
"bg-accent rounded-l-md",
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-[--cell-size] items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
262
src/components/ui/carousel.tsx
Normal file
262
src/components/ui/carousel.tsx
Normal file
@ -0,0 +1,262 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react"
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1]
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||
type CarouselOptions = UseCarouselParameters[0]
|
||||
type CarouselPlugin = UseCarouselParameters[1]
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions
|
||||
plugins?: CarouselPlugin
|
||||
orientation?: "horizontal" | "vertical"
|
||||
setApi?: (api: CarouselApi) => void
|
||||
}
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||
scrollPrev: () => void
|
||||
scrollNext: () => void
|
||||
canScrollPrev: boolean
|
||||
canScrollNext: boolean
|
||||
} & CarouselProps
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const Carousel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & CarouselProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins
|
||||
)
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
|
||||
setCanScrollPrev(api.canScrollPrev())
|
||||
setCanScrollNext(api.canScrollNext())
|
||||
}, [])
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev()
|
||||
}, [api])
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext()
|
||||
}, [api])
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault()
|
||||
scrollPrev()
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
scrollNext()
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) {
|
||||
return
|
||||
}
|
||||
|
||||
setApi(api)
|
||||
}, [api, setApi])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
|
||||
onSelect(api)
|
||||
api.on("reInit", onSelect)
|
||||
api.on("select", onSelect)
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect)
|
||||
}
|
||||
}, [api, onSelect])
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
)
|
||||
}
|
||||
)
|
||||
Carousel.displayName = "Carousel"
|
||||
|
||||
const CarouselContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { carouselRef, orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div ref={carouselRef} className="overflow-hidden">
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
CarouselContent.displayName = "CarouselContent"
|
||||
|
||||
const CarouselItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
CarouselItem.displayName = "CarouselItem"
|
||||
|
||||
const CarouselPrevious = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-left-12 top-1/2 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
CarouselPrevious.displayName = "CarouselPrevious"
|
||||
|
||||
const CarouselNext = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-right-12 top-1/2 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
CarouselNext.displayName = "CarouselNext"
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
}
|
||||
200
src/components/ui/context-menu.tsx
Normal file
200
src/components/ui/context-menu.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ContextMenu = ContextMenuPrimitive.Root
|
||||
|
||||
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
|
||||
|
||||
const ContextMenuGroup = ContextMenuPrimitive.Group
|
||||
|
||||
const ContextMenuPortal = ContextMenuPrimitive.Portal
|
||||
|
||||
const ContextMenuSub = ContextMenuPrimitive.Sub
|
||||
|
||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
|
||||
|
||||
const ContextMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
))
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const ContextMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
|
||||
|
||||
const ContextMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
))
|
||||
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
|
||||
|
||||
const ContextMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
|
||||
|
||||
const ContextMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
ContextMenuCheckboxItem.displayName =
|
||||
ContextMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const ContextMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-4 w-4 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
))
|
||||
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const ContextMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold text-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
|
||||
|
||||
const ContextMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
|
||||
|
||||
const ContextMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
ContextMenuShortcut.displayName = "ContextMenuShortcut"
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
}
|
||||
115
src/components/ui/drawer.tsx
Normal file
115
src/components/ui/drawer.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Drawer as DrawerPrimitive } from "vaul"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Drawer = ({
|
||||
shouldScaleBackground = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||
<DrawerPrimitive.Root
|
||||
shouldScaleBackground={shouldScaleBackground}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
Drawer.displayName = "Drawer"
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger
|
||||
|
||||
const DrawerPortal = DrawerPrimitive.Portal
|
||||
|
||||
const DrawerClose = DrawerPrimitive.Close
|
||||
|
||||
const DrawerOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
|
||||
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
))
|
||||
DrawerContent.displayName = "DrawerContent"
|
||||
|
||||
const DrawerHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DrawerHeader.displayName = "DrawerHeader"
|
||||
|
||||
const DrawerFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />
|
||||
)
|
||||
DrawerFooter.displayName = "DrawerFooter"
|
||||
|
||||
const DrawerTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
|
||||
|
||||
const DrawerDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
256
src/components/ui/menubar.tsx
Normal file
256
src/components/ui/menubar.tsx
Normal file
@ -0,0 +1,256 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as MenubarPrimitive from "@radix-ui/react-menubar"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function MenubarMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
|
||||
return <MenubarPrimitive.Menu {...props} />
|
||||
}
|
||||
|
||||
function MenubarGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
|
||||
return <MenubarPrimitive.Group {...props} />
|
||||
}
|
||||
|
||||
function MenubarPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
|
||||
return <MenubarPrimitive.Portal {...props} />
|
||||
}
|
||||
|
||||
function MenubarRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
|
||||
return <MenubarPrimitive.RadioGroup {...props} />
|
||||
}
|
||||
|
||||
function MenubarSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
|
||||
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
|
||||
}
|
||||
|
||||
const Menubar = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Menubar.displayName = MenubarPrimitive.Root.displayName
|
||||
|
||||
const MenubarTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
|
||||
|
||||
const MenubarSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
))
|
||||
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
|
||||
|
||||
const MenubarSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-menubar-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
|
||||
|
||||
const MenubarContent = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
|
||||
>(
|
||||
(
|
||||
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
|
||||
ref
|
||||
) => (
|
||||
<MenubarPrimitive.Portal>
|
||||
<MenubarPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-menubar-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenubarPrimitive.Portal>
|
||||
)
|
||||
)
|
||||
MenubarContent.displayName = MenubarPrimitive.Content.displayName
|
||||
|
||||
const MenubarItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarItem.displayName = MenubarPrimitive.Item.displayName
|
||||
|
||||
const MenubarCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<MenubarPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.CheckboxItem>
|
||||
))
|
||||
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
|
||||
|
||||
const MenubarRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Circle className="h-4 w-4 fill-current" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.RadioItem>
|
||||
))
|
||||
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
|
||||
|
||||
const MenubarLabel = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
|
||||
|
||||
const MenubarSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
|
||||
|
||||
const MenubarShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
MenubarShortcut.displayname = "MenubarShortcut"
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarSeparator,
|
||||
MenubarLabel,
|
||||
MenubarCheckboxItem,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarPortal,
|
||||
MenubarSubContent,
|
||||
MenubarSubTrigger,
|
||||
MenubarGroup,
|
||||
MenubarSub,
|
||||
MenubarShortcut,
|
||||
}
|
||||
@ -1157,6 +1157,18 @@ export default function WireframeCanvas({
|
||||
if (containsSVG) {
|
||||
await parseSVGAndRender(editor, responseText)
|
||||
|
||||
// Dispatch wireframe generation event for Components mode integration
|
||||
window.dispatchEvent(new CustomEvent('wireframe:generated', {
|
||||
detail: {
|
||||
svgData: responseText,
|
||||
deviceType: targetDevice,
|
||||
prompt: prompt
|
||||
}
|
||||
}))
|
||||
|
||||
// Show success message
|
||||
console.log('Wireframe generated successfully and integrated into Components mode')
|
||||
|
||||
// NEW: also convert SVG to component instances for the Components tab
|
||||
try {
|
||||
const parsed = wireframeConverter.parseSVGToWireframe(responseText, targetDevice as 'desktop' | 'tablet' | 'mobile')
|
||||
@ -1183,6 +1195,19 @@ export default function WireframeCanvas({
|
||||
const jsonResponse = JSON.parse(responseText)
|
||||
if (jsonResponse.svg) {
|
||||
await parseSVGAndRender(editor, jsonResponse.svg)
|
||||
|
||||
// Dispatch wireframe generation event for Components mode integration
|
||||
window.dispatchEvent(new CustomEvent('wireframe:generated', {
|
||||
detail: {
|
||||
svgData: jsonResponse.svg,
|
||||
deviceType: targetDevice,
|
||||
prompt: prompt
|
||||
}
|
||||
}))
|
||||
|
||||
// Show success message
|
||||
console.log('Wireframe generated successfully and integrated into Components mode')
|
||||
|
||||
try {
|
||||
const parsed = wireframeConverter.parseSVGToWireframe(jsonResponse.svg, targetDevice as 'desktop' | 'tablet' | 'mobile')
|
||||
const componentInstances = wireframeConverter.convertToComponents(parsed)
|
||||
@ -1198,6 +1223,16 @@ export default function WireframeCanvas({
|
||||
})
|
||||
} else if (jsonResponse.data && jsonResponse.data.svg) {
|
||||
await parseSVGAndRender(editor, jsonResponse.data.svg)
|
||||
|
||||
// Dispatch wireframe generation event for Components mode integration
|
||||
window.dispatchEvent(new CustomEvent('wireframe:generated', {
|
||||
detail: {
|
||||
svgData: jsonResponse.data.svg,
|
||||
deviceType: targetDevice,
|
||||
prompt: prompt
|
||||
}
|
||||
}))
|
||||
|
||||
try {
|
||||
const parsed = wireframeConverter.parseSVGToWireframe(jsonResponse.data.svg, targetDevice as 'desktop' | 'tablet' | 'mobile')
|
||||
const componentInstances = wireframeConverter.convertToComponents(parsed)
|
||||
@ -1284,7 +1319,10 @@ export default function WireframeCanvas({
|
||||
|
||||
// Auto-save effect
|
||||
useEffect(() => {
|
||||
if (!autoSaveEnabled || !editorRef.current) return
|
||||
if (!autoSaveEnabled || !editorRef.current || !isAuthenticated) {
|
||||
console.log('Auto-save disabled, no editor, or user not authenticated')
|
||||
return
|
||||
}
|
||||
|
||||
const autoSaveInterval = setInterval(() => {
|
||||
if (editorRef.current) {
|
||||
@ -1294,7 +1332,7 @@ export default function WireframeCanvas({
|
||||
}, 30000) // Auto-save every 30 seconds
|
||||
|
||||
return () => clearInterval(autoSaveInterval)
|
||||
}, [autoSaveEnabled, selectedDevice]) // Include selectedDevice in dependencies
|
||||
}, [autoSaveEnabled, selectedDevice, isAuthenticated]) // Include selectedDevice and isAuthenticated in dependencies
|
||||
|
||||
// Load wireframe on mount if user is authenticated
|
||||
useEffect(() => {
|
||||
@ -1316,6 +1354,8 @@ export default function WireframeCanvas({
|
||||
const mostRecent = data.wireframes[0] // Assuming they're sorted by created_at desc
|
||||
await loadWireframe(mostRecent.id)
|
||||
}
|
||||
} else {
|
||||
console.log('No recent wireframes found or error loading:', response.status)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('No recent wireframes to load or error loading:', error)
|
||||
@ -1323,6 +1363,8 @@ export default function WireframeCanvas({
|
||||
}
|
||||
|
||||
loadRecentWireframe()
|
||||
} else {
|
||||
console.log('User not authenticated, skipping wireframe loading')
|
||||
}
|
||||
}, [isAuthenticated, user?.id])
|
||||
|
||||
@ -1373,10 +1415,11 @@ export default function WireframeCanvas({
|
||||
Logged in as: {user?.username || 'User'}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<label className={`flex items-center gap-2 text-xs ${isAuthenticated ? 'text-gray-600' : 'text-gray-400'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoSaveEnabled}
|
||||
checked={autoSaveEnabled && isAuthenticated}
|
||||
disabled={!isAuthenticated}
|
||||
onChange={(e) => setAutoSaveEnabled(e.target.checked)}
|
||||
className="w-3 h-3"
|
||||
/>
|
||||
@ -1385,8 +1428,13 @@ export default function WireframeCanvas({
|
||||
|
||||
<button
|
||||
onClick={() => editorRef.current && saveWireframe(editorRef.current, false)}
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white text-xs px-2 py-1 rounded transition-colors"
|
||||
title="Save wireframe (Ctrl+S)"
|
||||
disabled={!isAuthenticated}
|
||||
className={`text-xs px-2 py-1 rounded transition-colors ${
|
||||
isAuthenticated
|
||||
? 'bg-blue-500 hover:bg-blue-600 text-white'
|
||||
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
}`}
|
||||
title={isAuthenticated ? "Save wireframe (Ctrl+S)" : "Please sign in to save wireframes"}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
|
||||
@ -5,7 +5,7 @@ import { cn } from "@/lib/utils"
|
||||
|
||||
interface WireframeRendererProps {
|
||||
component: ComponentInstance
|
||||
onClick?: () => void
|
||||
onClick?: (e: React.MouseEvent) => void
|
||||
onMouseDown?: (e: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
@ -22,9 +22,9 @@ export function WireframeRenderer({
|
||||
y: element.y || 0,
|
||||
width: element.width || 100,
|
||||
height: element.height || 50,
|
||||
fill: element.fill || 'none',
|
||||
stroke: element.stroke || '#000000',
|
||||
strokeWidth: element.strokeWidth || 1
|
||||
fill: element.fill === 'none' ? '#ffffff' : (element.fill || '#ffffff'),
|
||||
stroke: '#000000',
|
||||
strokeWidth: 3
|
||||
}
|
||||
|
||||
switch (element.type) {
|
||||
@ -45,9 +45,9 @@ export function WireframeRenderer({
|
||||
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}
|
||||
fill="#ffffff"
|
||||
stroke="#000000"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -57,10 +57,12 @@ export function WireframeRenderer({
|
||||
key={index}
|
||||
x={element.x || 0}
|
||||
y={element.y || 0}
|
||||
fill={element.fill || '#000000'}
|
||||
fill="#000000"
|
||||
fontSize={element.fontSize || 16}
|
||||
fontFamily={element.fontFamily || 'sans-serif'}
|
||||
fontWeight={element.fontWeight || 'normal'}
|
||||
fontFamily={element.fontFamily || 'Arial, sans-serif'}
|
||||
fontWeight="bold"
|
||||
textAnchor="middle"
|
||||
style={{ color: '#000000' }}
|
||||
>
|
||||
{element.text || ''}
|
||||
</text>
|
||||
@ -74,8 +76,8 @@ export function WireframeRenderer({
|
||||
y1={element.y || 0}
|
||||
x2={(element.x || 0) + element.width}
|
||||
y2={(element.y || 0) + element.height}
|
||||
stroke={element.stroke || '#000000'}
|
||||
strokeWidth={element.strokeWidth || 1}
|
||||
stroke="#000000"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -84,8 +86,10 @@ export function WireframeRenderer({
|
||||
<rect
|
||||
key={index}
|
||||
{...elementProps}
|
||||
fill="none"
|
||||
fill="#ffffff"
|
||||
stroke="#000000"
|
||||
strokeDasharray="5,5"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -103,8 +107,10 @@ export function WireframeRenderer({
|
||||
<rect
|
||||
key={index}
|
||||
{...elementProps}
|
||||
fill="none"
|
||||
fill="#ffffff"
|
||||
stroke="#000000"
|
||||
strokeDasharray="3,3"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -125,13 +131,17 @@ export function WireframeRenderer({
|
||||
<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">
|
||||
<div className="p-4 bg-white">
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="max-w-full max-h-full"
|
||||
style={{ border: '1px solid #e5e7eb' }}
|
||||
style={{
|
||||
border: '3px solid #000000',
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '8px'
|
||||
}}
|
||||
>
|
||||
{elements?.map((element: any, index: number) =>
|
||||
renderWireframeElement(element, index)
|
||||
@ -165,12 +175,17 @@ export function WireframeRenderer({
|
||||
<div className="p-1 bg-gray-100 border-b text-xs font-medium text-gray-600">
|
||||
{type.replace('wireframe-', '')}
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<div className="p-2 bg-white">
|
||||
<svg
|
||||
width={elementProps.width}
|
||||
height={elementProps.height}
|
||||
viewBox={`0 0 ${elementProps.width} ${elementProps.height}`}
|
||||
className="max-w-full max-h-full"
|
||||
style={{
|
||||
border: '2px solid #000000',
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
{renderWireframeElement({ type: type.replace('wireframe-', ''), ...elementProps }, 0)}
|
||||
</svg>
|
||||
|
||||
@ -27,12 +27,11 @@ interface EditorState {
|
||||
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
|
||||
setComponents: (components: ComponentInstance[]) => void
|
||||
clearAll: () => void
|
||||
addWireframe: (wireframe: WireframeData) => void
|
||||
updateWireframe: (id: string, updates: Partial<WireframeData>) => void
|
||||
@ -59,13 +58,6 @@ export const useEditorStore = create<EditorState>()(
|
||||
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) =>
|
||||
@ -102,6 +94,8 @@ export const useEditorStore = create<EditorState>()(
|
||||
components: state.components.map((comp) => (comp.id === id ? { ...comp, position } : comp)),
|
||||
})),
|
||||
|
||||
setComponents: (components) => set({ components }),
|
||||
|
||||
clearAll: () =>
|
||||
set({
|
||||
components: [],
|
||||
|
||||
90
src/lib/wireframe-converter.ts
Normal file
90
src/lib/wireframe-converter.ts
Normal file
@ -0,0 +1,90 @@
|
||||
export interface ParsedWireframe {
|
||||
id: string
|
||||
elements: WireframeElement[]
|
||||
metadata: {
|
||||
deviceType: 'desktop' | 'tablet' | 'mobile'
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
}
|
||||
|
||||
export interface WireframeElement {
|
||||
id: string
|
||||
type: 'rect' | 'circle' | 'text' | 'line' | 'path' | 'group'
|
||||
x: number
|
||||
y: number
|
||||
width?: number
|
||||
height?: number
|
||||
content?: string
|
||||
style?: {
|
||||
fill?: string
|
||||
stroke?: string
|
||||
strokeWidth?: number
|
||||
fontSize?: number
|
||||
fontFamily?: string
|
||||
opacity?: number
|
||||
}
|
||||
children?: WireframeElement[]
|
||||
}
|
||||
|
||||
export function parseWireframeData(data: any): ParsedWireframe {
|
||||
// This is a placeholder implementation
|
||||
// In a real application, this would parse the actual wireframe data
|
||||
return {
|
||||
id: data.id || `wireframe-${Date.now()}`,
|
||||
elements: data.elements || [],
|
||||
metadata: {
|
||||
deviceType: data.deviceType || 'desktop',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Wireframe converter service
|
||||
export const wireframeConverter = {
|
||||
parseSVGToWireframe: (svgData: string, deviceType: 'desktop' | 'tablet' | 'mobile'): ParsedWireframe => {
|
||||
// Placeholder implementation
|
||||
return {
|
||||
id: `wireframe-${Date.now()}`,
|
||||
elements: [],
|
||||
metadata: {
|
||||
deviceType,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
convertToComponents: (wireframe: ParsedWireframe) => {
|
||||
// Convert wireframe elements to individual components
|
||||
return wireframe.elements.map(element => ({
|
||||
id: `${element.type}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
type: `wireframe-${element.type}`,
|
||||
props: {
|
||||
...element,
|
||||
svgData: element
|
||||
},
|
||||
position: {
|
||||
x: element.x,
|
||||
y: element.y
|
||||
}
|
||||
}))
|
||||
},
|
||||
|
||||
convertToSVGComponent: (wireframe: ParsedWireframe) => {
|
||||
// Convert entire wireframe to a single SVG component
|
||||
return {
|
||||
id: `wireframe-svg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
type: 'wireframe-svg',
|
||||
props: {
|
||||
svgData: wireframe,
|
||||
deviceType: wireframe.metadata.deviceType
|
||||
},
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useEditorStore } from './store'
|
||||
import { wireframeConverter, ParsedWireframe } from './wireframe-converter.tsx'
|
||||
import { wireframeConverter, ParsedWireframe } from './wireframe-converter'
|
||||
|
||||
/**
|
||||
* Hook to integrate wireframe data from the backend with the component canvas
|
||||
|
||||
Loading…
Reference in New Issue
Block a user