frontend changes
This commit is contained in:
parent
d57e20e9d8
commit
f3b998f6b8
@ -1,7 +1,24 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
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;
|
export default nextConfig;
|
||||||
|
|||||||
91
package-lock.json
generated
91
package-lock.json
generated
@ -15,9 +15,12 @@
|
|||||||
"@next/font": "^14.2.15",
|
"@next/font": "^14.2.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-checkbox": "^1.3.2",
|
"@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-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@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-progress": "^1.1.7",
|
||||||
"@radix-ui/react-radio-group": "^1.3.7",
|
"@radix-ui/react-radio-group": "^1.3.7",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
@ -30,14 +33,18 @@
|
|||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
"lucide-react": "^0.539.0",
|
"lucide-react": "^0.539.0",
|
||||||
"next": "15.4.6",
|
"next": "15.4.6",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
|
"react-day-picker": "^9.9.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-resizable-panels": "^3.0.5",
|
"react-resizable-panels": "^3.0.5",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"svg-path-parser": "^1.1.0",
|
"svg-path-parser": "^1.1.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -90,6 +97,12 @@
|
|||||||
"anthropic-ai-sdk": "bin/cli"
|
"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": {
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
@ -4854,6 +4867,22 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||||
@ -4967,6 +4996,34 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/emoji-regex": {
|
||||||
"version": "9.2.2",
|
"version": "9.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||||
@ -8008,6 +8065,27 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.1.0",
|
"version": "19.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
"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"
|
"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": {
|
"node_modules/w3c-keyname": {
|
||||||
"version": "2.2.8",
|
"version": "2.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
|
|||||||
@ -16,9 +16,12 @@
|
|||||||
"@next/font": "^14.2.15",
|
"@next/font": "^14.2.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-checkbox": "^1.3.2",
|
"@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-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@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-progress": "^1.1.7",
|
||||||
"@radix-ui/react-radio-group": "^1.3.7",
|
"@radix-ui/react-radio-group": "^1.3.7",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
@ -31,14 +34,18 @@
|
|||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
"lucide-react": "^0.539.0",
|
"lucide-react": "^0.539.0",
|
||||||
"next": "15.4.6",
|
"next": "15.4.6",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
|
"react-day-picker": "^9.9.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-resizable-panels": "^3.0.5",
|
"react-resizable-panels": "^3.0.5",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"svg-path-parser": "^1.1.0",
|
"svg-path-parser": "^1.1.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -120,3 +120,62 @@
|
|||||||
@apply bg-background text-foreground;
|
@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"
|
"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 { useEditorStore } from "@/lib/store"
|
||||||
import { ComponentRenderer } from "./component-renderer"
|
import { ComponentRenderer } from "@/components/component-renderer"
|
||||||
import { cn } from "@/lib/utils"
|
import { MousePointer } from "lucide-react"
|
||||||
|
|
||||||
export function Canvas() {
|
export function Canvas() {
|
||||||
const canvasRef = useRef<HTMLDivElement>(null)
|
const { components, selectComponent, moveComponent } = useEditorStore()
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||||
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null)
|
const [dragState, setDragState] = useState<{
|
||||||
const [selectedComponents, setSelectedComponents] = useState<Set<string>>(new Set())
|
id: string
|
||||||
|
offsetX: number
|
||||||
const {
|
offsetY: number
|
||||||
components,
|
} | null>(null)
|
||||||
selectedComponent,
|
|
||||||
selectComponent,
|
|
||||||
moveComponent,
|
|
||||||
updateComponent
|
|
||||||
} = useEditorStore()
|
|
||||||
|
|
||||||
const handleMouseDown = (e: React.MouseEvent) => {
|
const { setNodeRef, isOver } = useDroppable({
|
||||||
if (e.target === canvasRef.current) {
|
id: "canvas",
|
||||||
// Clicked on empty canvas, deselect all
|
})
|
||||||
selectComponent(null)
|
|
||||||
setSelectedComponents(new Set())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleComponentClick = (componentId: string, e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
|
|
||||||
const component = components.find(c => c.id === componentId)
|
|
||||||
if (component) {
|
|
||||||
selectComponent(component)
|
|
||||||
setSelectedComponents(new Set([componentId]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleComponentMouseDown = (componentId: string, e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
|
|
||||||
const component = components.find(c => c.id === componentId)
|
|
||||||
if (component) {
|
|
||||||
selectComponent(component)
|
|
||||||
setSelectedComponents(new Set([componentId]))
|
|
||||||
|
|
||||||
// Start dragging
|
|
||||||
setIsDragging(true)
|
|
||||||
setDragStart({ x: e.clientX, y: e.clientY })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMouseMove = (e: React.MouseEvent) => {
|
|
||||||
if (isDragging && dragStart && selectedComponent) {
|
|
||||||
const deltaX = e.clientX - dragStart.x
|
|
||||||
const deltaY = e.clientY - dragStart.y
|
|
||||||
|
|
||||||
const newPosition = {
|
|
||||||
x: Math.max(0, selectedComponent.position.x + deltaX),
|
|
||||||
y: Math.max(0, selectedComponent.position.y + deltaY)
|
|
||||||
}
|
|
||||||
|
|
||||||
moveComponent(selectedComponent.id, newPosition)
|
|
||||||
setDragStart({ x: e.clientX, y: e.clientY })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
|
||||||
setIsDragging(false)
|
|
||||||
setDragStart(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="h-full flex flex-col">
|
||||||
ref={canvasRef}
|
{/* Canvas Header */}
|
||||||
className={cn(
|
<div className="p-4 border-b border-border bg-card">
|
||||||
"relative w-full h-full bg-white overflow-hidden",
|
<h2 className="text-lg font-semibold text-card-foreground">Canvas</h2>
|
||||||
"canvas-grid"
|
<p className="text-sm text-muted-foreground">Drag components here to build your interface</p>
|
||||||
)}
|
|
||||||
onMouseDown={handleMouseDown}
|
|
||||||
onMouseMove={handleMouseMove}
|
|
||||||
onMouseUp={handleMouseUp}
|
|
||||||
onMouseLeave={handleMouseUp}
|
|
||||||
>
|
|
||||||
{/* Canvas Grid Background */}
|
|
||||||
<div className="absolute inset-0 opacity-20">
|
|
||||||
<div
|
|
||||||
className="w-full h-full"
|
|
||||||
style={{
|
|
||||||
backgroundImage: `
|
|
||||||
linear-gradient(rgba(99, 102, 241, 0.1) 1px, transparent 1px),
|
|
||||||
linear-gradient(90deg, rgba(99, 102, 241, 0.1) 1px, transparent 1px)
|
|
||||||
`,
|
|
||||||
backgroundSize: '20px 20px'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Components */}
|
{/* Canvas Area */}
|
||||||
{components.map((component) => (
|
<div
|
||||||
<div
|
ref={(node) => {
|
||||||
key={component.id}
|
setNodeRef(node)
|
||||||
className={cn(
|
containerRef.current = node
|
||||||
"absolute cursor-move select-none",
|
}}
|
||||||
selectedComponents.has(component.id) && "ring-2 ring-blue-500 ring-opacity-50"
|
className={`flex-1 relative canvas-grid overflow-auto ${isOver ? "drop-zone-active" : ""}`}
|
||||||
)}
|
onClick={() => selectComponent(null)}
|
||||||
style={{
|
onMouseMove={(e) => {
|
||||||
left: component.position.x,
|
if (!dragState || !containerRef.current) return
|
||||||
top: component.position.y,
|
const rect = containerRef.current.getBoundingClientRect()
|
||||||
width: component.size?.width || 100,
|
const x = e.clientX - rect.left - dragState.offsetX + containerRef.current.scrollLeft
|
||||||
height: component.size?.height || 100,
|
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}
|
{components.length === 0 ? (
|
||||||
isSelected={selectedComponents.has(component.id)}
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
/>
|
<div className="text-center">
|
||||||
</div>
|
<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>
|
||||||
{/* Drop Zone Indicator */}
|
<h3 className="text-lg font-medium text-foreground mb-2">Start Building</h3>
|
||||||
{isDragging && (
|
<p className="text-muted-foreground max-w-sm">
|
||||||
<div className="absolute inset-0 pointer-events-none">
|
Drag components from the sidebar to start building your interface
|
||||||
<div className="w-full h-full border-2 border-dashed border-blue-400 bg-blue-50 bg-opacity-20" />
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Empty State */}
|
|
||||||
{components.length === 0 && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<div className="text-center text-gray-500">
|
|
||||||
<div className="text-6xl mb-4">🎨</div>
|
|
||||||
<h3 className="text-lg font-medium mb-2">No components yet</h3>
|
|
||||||
<p className="text-sm">Drag components from the palette to get started</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
)}
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,213 +1,132 @@
|
|||||||
"use client"
|
"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 { useState } from "react"
|
||||||
import { Button } from "./ui/button"
|
import {
|
||||||
import { Input } from "./ui/input"
|
Table,
|
||||||
import { Textarea } from "./ui/textarea"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
|
||||||
import { Checkbox } from "./ui/checkbox"
|
|
||||||
import { Switch } from "./ui/switch"
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
|
||||||
import { RadioGroup, RadioGroupItem } from "./ui/radio-group"
|
|
||||||
import { Progress } from "./ui/progress"
|
|
||||||
import { Avatar, AvatarFallback } from "./ui/avatar"
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table"
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"
|
|
||||||
import { Badge } from "./ui/badge"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import {
|
|
||||||
MousePointer,
|
|
||||||
Square,
|
|
||||||
Circle,
|
|
||||||
Type,
|
|
||||||
Image,
|
|
||||||
Layout,
|
|
||||||
BarChart3,
|
|
||||||
Settings,
|
|
||||||
User,
|
|
||||||
Calendar,
|
Calendar,
|
||||||
Mail,
|
Circle,
|
||||||
Phone,
|
Square,
|
||||||
Globe,
|
Type,
|
||||||
Search,
|
MousePointer,
|
||||||
Filter,
|
CreditCard,
|
||||||
Download,
|
|
||||||
Upload,
|
|
||||||
Edit,
|
|
||||||
Trash2,
|
|
||||||
Plus,
|
|
||||||
Minus,
|
|
||||||
Check,
|
|
||||||
X,
|
|
||||||
ArrowRight,
|
|
||||||
ArrowLeft,
|
|
||||||
ArrowUp,
|
|
||||||
ArrowDown,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
Tags as Tabs,
|
||||||
ChevronLeft,
|
BarChart3,
|
||||||
ChevronRight,
|
ToggleLeft,
|
||||||
Menu,
|
User,
|
||||||
MoreHorizontal,
|
Medal as Modal,
|
||||||
Star,
|
Search,
|
||||||
Heart,
|
|
||||||
Share,
|
|
||||||
Bookmark,
|
|
||||||
Flag,
|
|
||||||
AlertCircle,
|
|
||||||
Info,
|
|
||||||
CheckCircle,
|
|
||||||
XCircle,
|
|
||||||
AlertTriangle,
|
|
||||||
HelpCircle
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
const componentCategories = [
|
const COMPONENT_TYPES = [
|
||||||
{
|
{ id: "data-table", name: "Data Table", icon: Table, category: "Data" },
|
||||||
name: "Basic",
|
{ id: "contextmenu", name: "Context Menu", icon: MousePointer, category: "Overlay" },
|
||||||
icon: Square,
|
{ id: "menubar", name: "Menubar", icon: Tabs, category: "Layout" },
|
||||||
components: [
|
{ id: "carousel", name: "Carousel", icon: BarChart3, category: "Display" },
|
||||||
{ type: "button", name: "Button", icon: MousePointer },
|
{ id: "drawer", name: "Drawer", icon: Modal, category: "Overlay" },
|
||||||
{ type: "input", name: "Input", icon: Type },
|
{ id: "table", name: "Table", icon: Table, category: "Data" },
|
||||||
{ type: "textarea", name: "Textarea", icon: Type },
|
{ id: "datepicker", name: "Date Picker", icon: Calendar, category: "Input" },
|
||||||
{ type: "card", name: "Card", icon: Square },
|
{ 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" },
|
||||||
name: "Form",
|
{ id: "button", name: "Button", icon: MousePointer, category: "Action" },
|
||||||
icon: Settings,
|
{ id: "card", name: "Card", icon: CreditCard, category: "Layout" },
|
||||||
components: [
|
{ id: "select", name: "Select", icon: ChevronDown, category: "Input" },
|
||||||
{ type: "checkbox", name: "Checkbox", icon: Check },
|
{ id: "tabs", name: "Tabs", icon: Tabs, category: "Layout" },
|
||||||
{ type: "switch", name: "Switch", icon: Settings },
|
{ id: "progress", name: "Progress", icon: BarChart3, category: "Display" },
|
||||||
{ type: "select", name: "Select", icon: ChevronDown },
|
{ id: "switch", name: "Switch", icon: ToggleLeft, category: "Input" },
|
||||||
{ type: "radiogroup", name: "Radio Group", icon: Circle },
|
{ id: "avatar", name: "Avatar", icon: User, category: "Display" },
|
||||||
]
|
{ id: "dialog", name: "Dialog", icon: Modal, category: "Overlay" },
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Data",
|
|
||||||
icon: BarChart3,
|
|
||||||
components: [
|
|
||||||
{ type: "table", name: "Table", icon: Layout },
|
|
||||||
{ type: "progress", name: "Progress", icon: BarChart3 },
|
|
||||||
{ type: "tabs", name: "Tabs", icon: Layout },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Media",
|
|
||||||
icon: Image,
|
|
||||||
components: [
|
|
||||||
{ type: "avatar", name: "Avatar", icon: User },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export function ComponentPalette() {
|
function DraggableComponent({ component }: { component: (typeof COMPONENT_TYPES)[0] }) {
|
||||||
const [selectedCategory, setSelectedCategory] = useState("Basic")
|
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
id: component.id,
|
||||||
|
})
|
||||||
|
|
||||||
const filteredComponents = componentCategories
|
const style = transform
|
||||||
.find(cat => cat.name === selectedCategory)
|
? {
|
||||||
?.components.filter(comp =>
|
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||||
comp.name.toLowerCase().includes(searchQuery.toLowerCase())
|
}
|
||||||
) || []
|
: undefined
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full flex flex-col bg-white">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="p-4 border-b">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Components</h2>
|
|
||||||
<p className="text-sm text-gray-500">Drag components to canvas</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search */}
|
|
||||||
<div className="p-4 border-b">
|
|
||||||
<Input
|
|
||||||
placeholder="Search components..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Categories */}
|
|
||||||
<div className="p-4 border-b">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{componentCategories.map((category) => (
|
|
||||||
<Button
|
|
||||||
key={category.name}
|
|
||||||
variant={selectedCategory === category.name ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setSelectedCategory(category.name)}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<category.icon className="h-4 w-4" />
|
|
||||||
{category.name}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Components List */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{filteredComponents.map((component) => (
|
|
||||||
<ComponentPreview
|
|
||||||
key={component.type}
|
|
||||||
type={component.type}
|
|
||||||
name={component.name}
|
|
||||||
icon={component.icon}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filteredComponents.length === 0 && (
|
|
||||||
<div className="text-center text-gray-500 py-8">
|
|
||||||
<Search className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
|
||||||
<p className="text-sm">No components found</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ComponentPreviewProps {
|
|
||||||
type: string
|
|
||||||
name: string
|
|
||||||
icon: React.ComponentType<{ className?: string }>
|
|
||||||
}
|
|
||||||
|
|
||||||
function ComponentPreview({ type, name, icon: Icon }: ComponentPreviewProps) {
|
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
|
||||||
|
|
||||||
const handleDragStart = (e: React.DragEvent) => {
|
|
||||||
setIsDragging(true)
|
|
||||||
e.dataTransfer.setData('application/json', JSON.stringify({ type }))
|
|
||||||
e.dataTransfer.effectAllowed = 'copy'
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragEnd = () => {
|
|
||||||
setIsDragging(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
draggable
|
ref={setNodeRef}
|
||||||
onDragStart={handleDragStart}
|
style={style}
|
||||||
onDragEnd={handleDragEnd}
|
{...listeners}
|
||||||
className={cn(
|
{...attributes}
|
||||||
"p-3 border border-gray-200 rounded-lg cursor-grab hover:border-blue-300 hover:shadow-sm transition-all",
|
className={`p-3 bg-card border border-border rounded-md cursor-grab hover:bg-accent/10 transition-colors ${
|
||||||
isDragging && "opacity-50 scale-95"
|
isDragging ? "opacity-50" : ""
|
||||||
)}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center space-y-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="p-2 bg-gray-100 rounded-lg">
|
<component.icon className="w-4 h-4 text-muted-foreground" />
|
||||||
<Icon className="h-4 w-4 text-gray-600" />
|
<span className="text-sm font-medium text-card-foreground">{component.name}</span>
|
||||||
</div>
|
|
||||||
<span className="text-xs font-medium text-gray-700 text-center">
|
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</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"
|
"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 { 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 {
|
interface ComponentRendererProps {
|
||||||
component: ComponentInstance
|
component: ComponentInstance
|
||||||
isSelected?: boolean
|
onClick?: (e: React.MouseEvent) => void
|
||||||
onClick?: () => void
|
|
||||||
onMouseDown?: (e: React.MouseEvent) => void
|
onMouseDown?: (e: React.MouseEvent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ComponentRenderer({
|
export function ComponentRenderer({ component, onClick, onMouseDown }: ComponentRendererProps) {
|
||||||
component,
|
const { selectedComponent, updateComponent } = useEditorStore()
|
||||||
isSelected = false,
|
const isSelected = selectedComponent?.id === component.id
|
||||||
onClick,
|
|
||||||
onMouseDown
|
|
||||||
}: ComponentRendererProps) {
|
|
||||||
const { type, props } = component
|
|
||||||
|
|
||||||
// Handle wireframe components
|
const [checkboxState, setCheckboxState] = useState(component.props.checked || false)
|
||||||
if (type.startsWith('wireframe-')) {
|
const [switchState, setSwitchState] = useState(component.props.checked || false)
|
||||||
return (
|
const [radioValue, setRadioValue] = useState(component.props.value || "")
|
||||||
<WireframeRenderer
|
const [progressValue, setProgressValue] = useState(component.props.value || 50)
|
||||||
component={component}
|
const [selectedDate, setSelectedDate] = useState<Date | undefined>(
|
||||||
onClick={onClick}
|
component.props.selectedDate ? new Date(component.props.selectedDate) : undefined,
|
||||||
onMouseDown={onMouseDown}
|
)
|
||||||
/>
|
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 = () => {
|
const renderComponent = () => {
|
||||||
switch (type) {
|
switch (component.type) {
|
||||||
case 'button':
|
case "button":
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant={props.variant || 'default'}
|
variant={component.props.variant || "default"}
|
||||||
size={props.size || 'default'}
|
onClick={(e) => {
|
||||||
className={cn("w-full h-full", isSelected && "ring-2 ring-blue-500")}
|
e.stopPropagation()
|
||||||
onClick={onClick}
|
window.alert(`${component.props.children || "Button"} was clicked`)
|
||||||
onMouseDown={onMouseDown}
|
}}
|
||||||
>
|
>
|
||||||
{props.children || 'Button'}
|
{component.props.children || "Button"}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
|
|
||||||
case 'input':
|
case "input":
|
||||||
return (
|
return <Input placeholder={component.props.placeholder || "Enter text..."} className="w-full max-w-sm" />
|
||||||
<Input
|
|
||||||
placeholder={props.placeholder || 'Enter text...'}
|
|
||||||
className={cn("w-full h-full", isSelected && "ring-2 ring-blue-500")}
|
|
||||||
onClick={onClick}
|
|
||||||
onMouseDown={onMouseDown}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
case 'textarea':
|
case "textarea":
|
||||||
return (
|
return <Textarea placeholder={component.props.placeholder || "Enter text..."} className="w-full max-w-sm" />
|
||||||
<Textarea
|
|
||||||
placeholder={props.placeholder || 'Enter text...'}
|
|
||||||
className={cn("w-full h-full resize-none", isSelected && "ring-2 ring-blue-500")}
|
|
||||||
onClick={onClick}
|
|
||||||
onMouseDown={onMouseDown}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
case 'card':
|
case "card":
|
||||||
return (
|
return (
|
||||||
<Card className={cn("w-full h-full", isSelected && "ring-2 ring-blue-500")}>
|
<Card className="w-full max-w-sm">
|
||||||
<CardHeader>
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|
||||||
case 'checkbox':
|
case "checkbox":
|
||||||
return (
|
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
|
<Checkbox
|
||||||
id={component.id}
|
id={component.id}
|
||||||
checked={props.checked || false}
|
checked={checkboxState}
|
||||||
onClick={onClick}
|
onCheckedChange={(checked) => {
|
||||||
onMouseDown={onMouseDown}
|
setCheckboxState(!!checked)
|
||||||
|
updateComponent(component.id, { props: { checked: !!checked } })
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<label htmlFor={component.id} className="text-sm">
|
<Label htmlFor={component.id}>{component.props.label || "Checkbox"}</Label>
|
||||||
{props.label || 'Checkbox'}
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
case 'switch':
|
case "switch":
|
||||||
return (
|
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
|
<Switch
|
||||||
checked={props.checked || false}
|
id={component.id}
|
||||||
onClick={onClick}
|
checked={switchState}
|
||||||
onMouseDown={onMouseDown}
|
onCheckedChange={(checked) => {
|
||||||
|
setSwitchState(checked)
|
||||||
|
updateComponent(component.id, { props: { checked } })
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<label className="text-sm">
|
<Label htmlFor={component.id}>{component.props.label || "Switch"}</Label>
|
||||||
{props.label || 'Switch'}
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
case 'select':
|
case "select":
|
||||||
return (
|
return (
|
||||||
<Select>
|
<Select>
|
||||||
<SelectTrigger className={cn("w-full h-full", isSelected && "ring-2 ring-blue-500")}>
|
<SelectTrigger className="w-full max-w-sm">
|
||||||
<SelectValue placeholder={props.placeholder || 'Select option...'} />
|
<SelectValue placeholder={component.props.placeholder || "Select option..."} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(props.options || ['Option 1', 'Option 2']).map((option: string, index: number) => (
|
{(component.props.options || ["Option 1", "Option 2"]).map((option: string, index: number) => (
|
||||||
<SelectItem key={index} value={option}>
|
<SelectItem key={index} value={option.toLowerCase()}>
|
||||||
{option}
|
{option}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
@ -137,117 +256,540 @@ export function ComponentRenderer({
|
|||||||
</Select>
|
</Select>
|
||||||
)
|
)
|
||||||
|
|
||||||
case 'radiogroup':
|
case "radiogroup":
|
||||||
return (
|
return (
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={props.value || 'Option 1'}
|
value={radioValue}
|
||||||
className={cn("w-full h-full", isSelected && "ring-2 ring-blue-500")}
|
onValueChange={(value) => {
|
||||||
onClick={onClick}
|
setRadioValue(value)
|
||||||
onMouseDown={onMouseDown}
|
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">
|
<div key={index} className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value={option} id={`${component.id}-${index}`} />
|
<RadioGroupItem value={option} id={`${component.id}-${index}`} />
|
||||||
<label htmlFor={`${component.id}-${index}`} className="text-sm">
|
<Label htmlFor={`${component.id}-${index}`}>{option}</Label>
|
||||||
{option}
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
)
|
)
|
||||||
|
|
||||||
case 'progress':
|
case "progress":
|
||||||
return (
|
return (
|
||||||
<div className={cn("w-full h-full flex items-center", isSelected && "ring-2 ring-blue-500")}>
|
<div className="w-full max-w-sm space-y-2">
|
||||||
<Progress
|
<Progress value={progressValue} className="w-full" />
|
||||||
value={props.value || 50}
|
<div className="flex items-center justify-between">
|
||||||
max={props.max || 100}
|
<Button
|
||||||
className="w-full"
|
size="sm"
|
||||||
onClick={onClick}
|
variant="outline"
|
||||||
onMouseDown={onMouseDown}
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
case 'avatar':
|
case "avatar":
|
||||||
return (
|
return (
|
||||||
<div className={cn("w-full h-full flex items-center justify-center", isSelected && "ring-2 ring-blue-500")}>
|
<Avatar>
|
||||||
<Avatar onClick={onClick} onMouseDown={onMouseDown}>
|
{component.props.src && <AvatarImage src={component.props.src || "/placeholder.svg"} alt="Avatar" />}
|
||||||
<AvatarFallback>{props.fallback || 'AB'}</AvatarFallback>
|
<AvatarFallback>
|
||||||
</Avatar>
|
{component.props.fallback ? component.props.fallback : <User className="h-4 w-4" />}
|
||||||
</div>
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
)
|
)
|
||||||
|
|
||||||
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 (
|
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>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
{(props.headers || ['Name', 'Email', 'Role']).map((header: string, index: number) => (
|
<TableHead className="w-[40px]">Sel</TableHead>
|
||||||
<TableHead key={index} className="text-xs">{header}</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>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{(props.rows || [
|
{pageRows.map((row: string[], rowIndex: number) => (
|
||||||
['John Doe', 'john@example.com', 'Admin'],
|
<TableRow key={start + rowIndex} className={dataTableSelected.includes(start + rowIndex) ? "bg-accent/20" : ""}>
|
||||||
['Jane Smith', 'jane@example.com', 'User']
|
<TableCell>
|
||||||
]).map((row: string[], rowIndex: number) => (
|
<Checkbox
|
||||||
<TableRow key={rowIndex}>
|
id={`${component.id}-sel-${start + rowIndex}`}
|
||||||
|
checked={dataTableSelected.includes(start + rowIndex)}
|
||||||
|
onCheckedChange={() => toggleRow(start + rowIndex)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
{row.map((cell: string, cellIndex: number) => (
|
{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>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
case 'tabs':
|
case "drawer":
|
||||||
return (
|
return (
|
||||||
<Tabs defaultValue="0" className={cn("w-full h-full", isSelected && "ring-2 ring-blue-500")}>
|
<Drawer>
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<DrawerTrigger asChild>
|
||||||
{(props.tabs || [
|
<Button variant="outline">Open Drawer</Button>
|
||||||
{ label: 'Tab 1', content: 'Content 1' },
|
</DrawerTrigger>
|
||||||
{ label: 'Tab 2', content: 'Content 2' }
|
<DrawerContent>
|
||||||
]).map((tab: any, index: number) => (
|
<DrawerHeader>
|
||||||
<TabsTrigger key={index} value={index.toString()}>
|
<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}
|
{tab.label}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
))}
|
))}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
{(props.tabs || [
|
{(component.props.tabs || [{ content: "Content 1" }, { content: "Content 2" }]).map(
|
||||||
{ label: 'Tab 1', content: 'Content 1' },
|
(tab: any, index: number) => (
|
||||||
{ label: 'Tab 2', content: 'Content 2' }
|
<TabsContent key={index} value={`tab-${index}`}>
|
||||||
]).map((tab: any, index: number) => (
|
{tab.content || `Content ${index + 1}`}
|
||||||
<TabsContent key={index} value={index.toString()} className="mt-2">
|
</TabsContent>
|
||||||
<div className="text-sm">{tab.content}</div>
|
),
|
||||||
</TabsContent>
|
)}
|
||||||
))}
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
)
|
)
|
||||||
|
|
||||||
default:
|
case "datepicker":
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-[360px]"
|
||||||
|
onMouseDown={(e) => e.stopPropagation()} // prevent dragging out of the component
|
||||||
|
onClick={(e) => e.stopPropagation()} // prevent clicking out of the component
|
||||||
|
>
|
||||||
|
<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 (
|
return (
|
||||||
<div
|
<Dialog>
|
||||||
className={cn(
|
<DialogTrigger asChild>
|
||||||
"w-full h-full border-2 border-dashed border-gray-300 rounded-lg flex items-center justify-center text-gray-500 text-sm",
|
<Button variant="outline">Open Dialog</Button>
|
||||||
isSelected && "ring-2 ring-blue-500"
|
</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}
|
onClick={onClick}
|
||||||
onMouseDown={onMouseDown}
|
onMouseDown={onMouseDown}
|
||||||
>
|
/>
|
||||||
{type}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return <div className="p-4 border border-dashed border-border rounded">Unknown Component</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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()}
|
{renderComponent()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -46,6 +46,13 @@ export function DualCanvasEditor({
|
|||||||
// Initialize wireframe integration
|
// Initialize wireframe integration
|
||||||
useWireframeIntegration()
|
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') => {
|
const handleDeviceChange = (device: 'desktop' | 'tablet' | 'mobile') => {
|
||||||
if (onDeviceChange) {
|
if (onDeviceChange) {
|
||||||
onDeviceChange(device)
|
onDeviceChange(device)
|
||||||
@ -143,6 +150,29 @@ export function DualCanvasEditor({
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
{canvasMode === 'components' && (
|
{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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -169,7 +199,7 @@ export function DualCanvasEditor({
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="h-[calc(100vh-100px)] w-full">
|
<div className="h-[calc(100%-80px)] w-full">
|
||||||
{canvasMode === 'wireframe' ? (
|
{canvasMode === 'wireframe' ? (
|
||||||
<div className="h-full w-full flex">
|
<div className="h-full w-full flex">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
|||||||
@ -1407,7 +1407,7 @@ function AIMockupStep({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dual Canvas Editor */}
|
{/* 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
|
<DualCanvasEditor
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
onWireframeGenerated={handleWireframeGenerated}
|
onWireframeGenerated={handleWireframeGenerated}
|
||||||
|
|||||||
@ -1,323 +1,264 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
|
||||||
import { useEditorStore } from "@/lib/store"
|
import { useEditorStore } from "@/lib/store"
|
||||||
import { Button } from "./ui/button"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Input } from "./ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "./ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Textarea } from "./ui/textarea"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { Switch } from "./ui/switch"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Slider } from "./ui/slider"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { ColorPicker } from "./ui/color-picker"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import { Separator } from "./ui/separator"
|
import { Trash2, X } from "lucide-react"
|
||||||
import { Badge } from "./ui/badge"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Trash2, Copy, Move, RotateCcw } from "lucide-react"
|
|
||||||
|
|
||||||
export function PropertiesPanel() {
|
export function PropertiesPanel() {
|
||||||
const { selectedComponent, updateComponent, removeComponent } = useEditorStore()
|
const { selectedComponent, updateComponent, removeComponent, selectComponent } = useEditorStore()
|
||||||
const [localProps, setLocalProps] = useState<Record<string, any>>({})
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (!selectedComponent) {
|
||||||
if (selectedComponent) {
|
return null
|
||||||
setLocalProps(selectedComponent.props)
|
}
|
||||||
}
|
|
||||||
}, [selectedComponent])
|
|
||||||
|
|
||||||
const handlePropChange = (key: string, value: any) => {
|
const handlePropChange = (key: string, value: any) => {
|
||||||
setLocalProps(prev => ({ ...prev, [key]: value }))
|
updateComponent(selectedComponent.id, {
|
||||||
}
|
props: { ...selectedComponent.props, [key]: value },
|
||||||
|
})
|
||||||
const handleSave = () => {
|
|
||||||
if (selectedComponent) {
|
|
||||||
updateComponent(selectedComponent.id, { props: localProps })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
if (selectedComponent) {
|
|
||||||
setLocalProps(selectedComponent.props)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
if (selectedComponent) {
|
removeComponent(selectedComponent.id)
|
||||||
removeComponent(selectedComponent.id)
|
selectComponent(null)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedComponent) {
|
const renderPropertyEditor = () => {
|
||||||
return (
|
switch (selectedComponent.type) {
|
||||||
<div className="h-full flex items-center justify-center text-gray-500">
|
case "button":
|
||||||
<div className="text-center">
|
return (
|
||||||
<div className="text-4xl mb-2">🎯</div>
|
<div className="space-y-4">
|
||||||
<p className="text-sm">Select a component to edit properties</p>
|
<div>
|
||||||
</div>
|
<Label htmlFor="button-text">Button Text</Label>
|
||||||
</div>
|
<Input
|
||||||
)
|
id="button-text"
|
||||||
}
|
value={selectedComponent.props.children || ""}
|
||||||
|
onChange={(e) => handlePropChange("children", e.target.value)}
|
||||||
const renderPropertyEditor = (key: string, value: any) => {
|
placeholder="Button text"
|
||||||
const type = typeof value
|
/>
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'string':
|
|
||||||
if (key.includes('color') || key.includes('Color')) {
|
|
||||||
return (
|
|
||||||
<div key={key} className="space-y-2">
|
|
||||||
<Label htmlFor={key} className="text-xs font-medium">
|
|
||||||
{key.charAt(0).toUpperCase() + key.slice(1)}
|
|
||||||
</Label>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Input
|
|
||||||
id={key}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => handlePropChange(key, e.target.value)}
|
|
||||||
className="text-xs"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="w-6 h-6 rounded border"
|
|
||||||
style={{ backgroundColor: value }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
<div>
|
||||||
}
|
<Label htmlFor="button-variant">Variant</Label>
|
||||||
|
<Select
|
||||||
if (key.includes('text') || key.includes('placeholder') || key.includes('label')) {
|
value={selectedComponent.props.variant || "default"}
|
||||||
return (
|
onValueChange={(value) => handlePropChange("variant", value)}
|
||||||
<div key={key} className="space-y-2">
|
>
|
||||||
<Label htmlFor={key} className="text-xs font-medium">
|
<SelectTrigger>
|
||||||
{key.charAt(0).toUpperCase() + key.slice(1)}
|
<SelectValue />
|
||||||
</Label>
|
</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>
|
||||||
|
)
|
||||||
|
|
||||||
|
case "card":
|
||||||
|
return (
|
||||||
|
<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
|
<Textarea
|
||||||
id={key}
|
id="card-description"
|
||||||
value={value}
|
value={selectedComponent.props.description || ""}
|
||||||
onChange={(e) => handlePropChange(key, e.target.value)}
|
onChange={(e) => handlePropChange("description", e.target.value)}
|
||||||
className="text-xs min-h-[60px]"
|
placeholder="Card description"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={key} className="space-y-2">
|
|
||||||
<Label htmlFor={key} className="text-xs font-medium">
|
|
||||||
{key.charAt(0).toUpperCase() + key.slice(1)}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={key}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => handlePropChange(key, e.target.value)}
|
|
||||||
className="text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
case 'number':
|
case "checkbox":
|
||||||
if (key.includes('size') || key.includes('width') || key.includes('height')) {
|
case "switch":
|
||||||
return (
|
return (
|
||||||
<div key={key} className="space-y-2">
|
<div className="space-y-4">
|
||||||
<Label htmlFor={key} className="text-xs font-medium">
|
<div>
|
||||||
{key.charAt(0).toUpperCase() + key.slice(1)}: {value}
|
<Label htmlFor="label">Label</Label>
|
||||||
</Label>
|
<Input
|
||||||
<Slider
|
id="label"
|
||||||
value={[value]}
|
value={selectedComponent.props.label || ""}
|
||||||
onValueChange={([newValue]) => handlePropChange(key, newValue)}
|
onChange={(e) => handlePropChange("label", e.target.value)}
|
||||||
min={0}
|
placeholder="Label text"
|
||||||
max={key.includes('size') ? 100 : 500}
|
|
||||||
step={1}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
<div className="flex items-center space-x-2">
|
||||||
}
|
<Switch
|
||||||
|
id="checked"
|
||||||
return (
|
checked={selectedComponent.props.checked || false}
|
||||||
<div key={key} className="space-y-2">
|
onCheckedChange={(checked) => handlePropChange("checked", checked)}
|
||||||
<Label htmlFor={key} className="text-xs font-medium">
|
/>
|
||||||
{key.charAt(0).toUpperCase() + key.slice(1)}
|
<Label htmlFor="checked">Checked</Label>
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={key}
|
|
||||||
type="number"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => handlePropChange(key, Number(e.target.value))}
|
|
||||||
className="text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
case 'boolean':
|
|
||||||
return (
|
|
||||||
<div key={key} className="flex items-center justify-between">
|
|
||||||
<Label htmlFor={key} className="text-xs font-medium">
|
|
||||||
{key.charAt(0).toUpperCase() + key.slice(1)}
|
|
||||||
</Label>
|
|
||||||
<Switch
|
|
||||||
id={key}
|
|
||||||
checked={value}
|
|
||||||
onCheckedChange={(checked) => handlePropChange(key, checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
case 'object':
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return (
|
|
||||||
<div key={key} className="space-y-2">
|
|
||||||
<Label className="text-xs font-medium">
|
|
||||||
{key.charAt(0).toUpperCase() + key.slice(1)} ({value.length} items)
|
|
||||||
</Label>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{value.map((item, index) => (
|
|
||||||
<div key={index} className="flex items-center space-x-2">
|
|
||||||
<Input
|
|
||||||
value={item}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newArray = [...value]
|
|
||||||
newArray[index] = e.target.value
|
|
||||||
handlePropChange(key, newArray)
|
|
||||||
}}
|
|
||||||
className="text-xs"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
const newArray = value.filter((_, i) => i !== index)
|
|
||||||
handlePropChange(key, newArray)
|
|
||||||
}}
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
const newArray = [...value, '']
|
|
||||||
handlePropChange(key, newArray)
|
|
||||||
}}
|
|
||||||
className="w-full text-xs"
|
|
||||||
>
|
|
||||||
Add Item
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
}
|
)
|
||||||
|
|
||||||
|
case "progress":
|
||||||
return (
|
return (
|
||||||
<div key={key} className="space-y-2">
|
<div className="space-y-4">
|
||||||
<Label className="text-xs font-medium">
|
<div>
|
||||||
{key.charAt(0).toUpperCase() + key.slice(1)} (Object)
|
<Label htmlFor="progress-value">Value</Label>
|
||||||
</Label>
|
<Input
|
||||||
<div className="text-xs text-gray-500 p-2 bg-gray-100 rounded">
|
id="progress-value"
|
||||||
{JSON.stringify(value, null, 2)}
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={selectedComponent.props.value || 50}
|
||||||
|
onChange={(e) => handlePropChange("value", Number.parseInt(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case "avatar":
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return (
|
return <div className="text-sm text-muted-foreground">No properties available for this component type.</div>
|
||||||
<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 (
|
return (
|
||||||
<div className="h-full flex flex-col bg-white">
|
<div className="h-full flex flex-col overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-4 border-b">
|
<div className="p-4 border-b border-sidebar-border flex items-center justify-between">
|
||||||
<div className="flex items-center justify-between">
|
<h2 className="text-lg font-semibold text-sidebar-foreground">Properties</h2>
|
||||||
<h3 className="text-lg font-semibold text-gray-900">Properties</h3>
|
<Button variant="ghost" size="sm" onClick={() => selectComponent(null)} className="h-8 w-8 p-0">
|
||||||
<Badge variant="outline" className="text-xs">
|
<X className="h-4 w-4" />
|
||||||
{selectedComponent.type}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Component Info */}
|
|
||||||
<div className="p-4 border-b bg-gray-50">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between text-xs">
|
|
||||||
<span className="text-gray-500">ID:</span>
|
|
||||||
<span className="font-mono">{selectedComponent.id}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-xs">
|
|
||||||
<span className="text-gray-500">Position:</span>
|
|
||||||
<span className="font-mono">
|
|
||||||
{Math.round(selectedComponent.position.x)}, {Math.round(selectedComponent.position.y)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{selectedComponent.size && (
|
|
||||||
<div className="flex justify-between text-xs">
|
|
||||||
<span className="text-gray-500">Size:</span>
|
|
||||||
<span className="font-mono">
|
|
||||||
{Math.round(selectedComponent.size.width)} × {Math.round(selectedComponent.size.height)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Properties */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
|
||||||
<div className="space-y-4">
|
|
||||||
{Object.entries(localProps).map(([key, value]) =>
|
|
||||||
renderPropertyEditor(key, value)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{Object.keys(localProps).length === 0 && (
|
|
||||||
<div className="text-center text-gray-500 py-8">
|
|
||||||
<div className="text-4xl mb-2">⚙️</div>
|
|
||||||
<p className="text-sm">No properties available</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="p-4 border-t space-y-2">
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleSave}
|
|
||||||
size="sm"
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleReset}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleDelete}
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
|
||||||
Delete Component
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1 properties-panel-scroll">
|
||||||
|
<div className="p-4 space-y-6">
|
||||||
|
{/* Component Info */}
|
||||||
|
<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 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium">Properties</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>{renderPropertyEditor()}</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 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 */}
|
||||||
|
<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>
|
</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) {
|
if (containsSVG) {
|
||||||
await parseSVGAndRender(editor, responseText)
|
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
|
// NEW: also convert SVG to component instances for the Components tab
|
||||||
try {
|
try {
|
||||||
const parsed = wireframeConverter.parseSVGToWireframe(responseText, targetDevice as 'desktop' | 'tablet' | 'mobile')
|
const parsed = wireframeConverter.parseSVGToWireframe(responseText, targetDevice as 'desktop' | 'tablet' | 'mobile')
|
||||||
@ -1183,6 +1195,19 @@ export default function WireframeCanvas({
|
|||||||
const jsonResponse = JSON.parse(responseText)
|
const jsonResponse = JSON.parse(responseText)
|
||||||
if (jsonResponse.svg) {
|
if (jsonResponse.svg) {
|
||||||
await parseSVGAndRender(editor, 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 {
|
try {
|
||||||
const parsed = wireframeConverter.parseSVGToWireframe(jsonResponse.svg, targetDevice as 'desktop' | 'tablet' | 'mobile')
|
const parsed = wireframeConverter.parseSVGToWireframe(jsonResponse.svg, targetDevice as 'desktop' | 'tablet' | 'mobile')
|
||||||
const componentInstances = wireframeConverter.convertToComponents(parsed)
|
const componentInstances = wireframeConverter.convertToComponents(parsed)
|
||||||
@ -1198,6 +1223,16 @@ export default function WireframeCanvas({
|
|||||||
})
|
})
|
||||||
} else if (jsonResponse.data && jsonResponse.data.svg) {
|
} else if (jsonResponse.data && jsonResponse.data.svg) {
|
||||||
await parseSVGAndRender(editor, 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 {
|
try {
|
||||||
const parsed = wireframeConverter.parseSVGToWireframe(jsonResponse.data.svg, targetDevice as 'desktop' | 'tablet' | 'mobile')
|
const parsed = wireframeConverter.parseSVGToWireframe(jsonResponse.data.svg, targetDevice as 'desktop' | 'tablet' | 'mobile')
|
||||||
const componentInstances = wireframeConverter.convertToComponents(parsed)
|
const componentInstances = wireframeConverter.convertToComponents(parsed)
|
||||||
@ -1284,7 +1319,10 @@ export default function WireframeCanvas({
|
|||||||
|
|
||||||
// Auto-save effect
|
// Auto-save effect
|
||||||
useEffect(() => {
|
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(() => {
|
const autoSaveInterval = setInterval(() => {
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
@ -1294,7 +1332,7 @@ export default function WireframeCanvas({
|
|||||||
}, 30000) // Auto-save every 30 seconds
|
}, 30000) // Auto-save every 30 seconds
|
||||||
|
|
||||||
return () => clearInterval(autoSaveInterval)
|
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
|
// Load wireframe on mount if user is authenticated
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -1316,6 +1354,8 @@ export default function WireframeCanvas({
|
|||||||
const mostRecent = data.wireframes[0] // Assuming they're sorted by created_at desc
|
const mostRecent = data.wireframes[0] // Assuming they're sorted by created_at desc
|
||||||
await loadWireframe(mostRecent.id)
|
await loadWireframe(mostRecent.id)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.log('No recent wireframes found or error loading:', response.status)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('No recent wireframes to load or error loading:', error)
|
console.log('No recent wireframes to load or error loading:', error)
|
||||||
@ -1323,6 +1363,8 @@ export default function WireframeCanvas({
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadRecentWireframe()
|
loadRecentWireframe()
|
||||||
|
} else {
|
||||||
|
console.log('User not authenticated, skipping wireframe loading')
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, user?.id])
|
}, [isAuthenticated, user?.id])
|
||||||
|
|
||||||
@ -1373,10 +1415,11 @@ export default function WireframeCanvas({
|
|||||||
Logged in as: {user?.username || 'User'}
|
Logged in as: {user?.username || 'User'}
|
||||||
</div>
|
</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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={autoSaveEnabled}
|
checked={autoSaveEnabled && isAuthenticated}
|
||||||
|
disabled={!isAuthenticated}
|
||||||
onChange={(e) => setAutoSaveEnabled(e.target.checked)}
|
onChange={(e) => setAutoSaveEnabled(e.target.checked)}
|
||||||
className="w-3 h-3"
|
className="w-3 h-3"
|
||||||
/>
|
/>
|
||||||
@ -1385,8 +1428,13 @@ export default function WireframeCanvas({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => editorRef.current && saveWireframe(editorRef.current, false)}
|
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"
|
disabled={!isAuthenticated}
|
||||||
title="Save wireframe (Ctrl+S)"
|
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
|
Save
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { cn } from "@/lib/utils"
|
|||||||
|
|
||||||
interface WireframeRendererProps {
|
interface WireframeRendererProps {
|
||||||
component: ComponentInstance
|
component: ComponentInstance
|
||||||
onClick?: () => void
|
onClick?: (e: React.MouseEvent) => void
|
||||||
onMouseDown?: (e: React.MouseEvent) => void
|
onMouseDown?: (e: React.MouseEvent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -22,9 +22,9 @@ export function WireframeRenderer({
|
|||||||
y: element.y || 0,
|
y: element.y || 0,
|
||||||
width: element.width || 100,
|
width: element.width || 100,
|
||||||
height: element.height || 50,
|
height: element.height || 50,
|
||||||
fill: element.fill || 'none',
|
fill: element.fill === 'none' ? '#ffffff' : (element.fill || '#ffffff'),
|
||||||
stroke: element.stroke || '#000000',
|
stroke: '#000000',
|
||||||
strokeWidth: element.strokeWidth || 1
|
strokeWidth: 3
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
@ -45,9 +45,9 @@ export function WireframeRenderer({
|
|||||||
cx={element.cx || element.x + element.width / 2}
|
cx={element.cx || element.x + element.width / 2}
|
||||||
cy={element.cy || element.y + element.height / 2}
|
cy={element.cy || element.y + element.height / 2}
|
||||||
r={element.r || Math.min(element.width, element.height) / 2}
|
r={element.r || Math.min(element.width, element.height) / 2}
|
||||||
fill={element.fill || 'none'}
|
fill="#ffffff"
|
||||||
stroke={element.stroke || '#000000'}
|
stroke="#000000"
|
||||||
strokeWidth={element.strokeWidth || 1}
|
strokeWidth={3}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -57,10 +57,12 @@ export function WireframeRenderer({
|
|||||||
key={index}
|
key={index}
|
||||||
x={element.x || 0}
|
x={element.x || 0}
|
||||||
y={element.y || 0}
|
y={element.y || 0}
|
||||||
fill={element.fill || '#000000'}
|
fill="#000000"
|
||||||
fontSize={element.fontSize || 16}
|
fontSize={element.fontSize || 16}
|
||||||
fontFamily={element.fontFamily || 'sans-serif'}
|
fontFamily={element.fontFamily || 'Arial, sans-serif'}
|
||||||
fontWeight={element.fontWeight || 'normal'}
|
fontWeight="bold"
|
||||||
|
textAnchor="middle"
|
||||||
|
style={{ color: '#000000' }}
|
||||||
>
|
>
|
||||||
{element.text || ''}
|
{element.text || ''}
|
||||||
</text>
|
</text>
|
||||||
@ -74,8 +76,8 @@ export function WireframeRenderer({
|
|||||||
y1={element.y || 0}
|
y1={element.y || 0}
|
||||||
x2={(element.x || 0) + element.width}
|
x2={(element.x || 0) + element.width}
|
||||||
y2={(element.y || 0) + element.height}
|
y2={(element.y || 0) + element.height}
|
||||||
stroke={element.stroke || '#000000'}
|
stroke="#000000"
|
||||||
strokeWidth={element.strokeWidth || 1}
|
strokeWidth={3}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -84,8 +86,10 @@ export function WireframeRenderer({
|
|||||||
<rect
|
<rect
|
||||||
key={index}
|
key={index}
|
||||||
{...elementProps}
|
{...elementProps}
|
||||||
fill="none"
|
fill="#ffffff"
|
||||||
|
stroke="#000000"
|
||||||
strokeDasharray="5,5"
|
strokeDasharray="5,5"
|
||||||
|
strokeWidth={3}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -103,8 +107,10 @@ export function WireframeRenderer({
|
|||||||
<rect
|
<rect
|
||||||
key={index}
|
key={index}
|
||||||
{...elementProps}
|
{...elementProps}
|
||||||
fill="none"
|
fill="#ffffff"
|
||||||
|
stroke="#000000"
|
||||||
strokeDasharray="3,3"
|
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">
|
<div className="p-2 bg-gray-100 border-b text-xs font-medium text-gray-600">
|
||||||
Wireframe ({deviceType}) - {elements?.length || 0} elements
|
Wireframe ({deviceType}) - {elements?.length || 0} elements
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-4 bg-white">
|
||||||
<svg
|
<svg
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
viewBox={`0 0 ${width} ${height}`}
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
className="max-w-full max-h-full"
|
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) =>
|
{elements?.map((element: any, index: number) =>
|
||||||
renderWireframeElement(element, index)
|
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">
|
<div className="p-1 bg-gray-100 border-b text-xs font-medium text-gray-600">
|
||||||
{type.replace('wireframe-', '')}
|
{type.replace('wireframe-', '')}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2">
|
<div className="p-2 bg-white">
|
||||||
<svg
|
<svg
|
||||||
width={elementProps.width}
|
width={elementProps.width}
|
||||||
height={elementProps.height}
|
height={elementProps.height}
|
||||||
viewBox={`0 0 ${elementProps.width} ${elementProps.height}`}
|
viewBox={`0 0 ${elementProps.width} ${elementProps.height}`}
|
||||||
className="max-w-full max-h-full"
|
className="max-w-full max-h-full"
|
||||||
|
style={{
|
||||||
|
border: '2px solid #000000',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderRadius: '4px'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{renderWireframeElement({ type: type.replace('wireframe-', ''), ...elementProps }, 0)}
|
{renderWireframeElement({ type: type.replace('wireframe-', ''), ...elementProps }, 0)}
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@ -27,12 +27,11 @@ interface EditorState {
|
|||||||
showWireframes: boolean
|
showWireframes: boolean
|
||||||
wireframeRenderMode: 'svg' | 'editable'
|
wireframeRenderMode: 'svg' | 'editable'
|
||||||
addComponent: (component: ComponentInstance) => void
|
addComponent: (component: ComponentInstance) => void
|
||||||
addComponents: (components: ComponentInstance[]) => void
|
|
||||||
setComponents: (components: ComponentInstance[]) => void
|
|
||||||
updateComponent: (id: string, updates: Partial<ComponentInstance>) => void
|
updateComponent: (id: string, updates: Partial<ComponentInstance>) => void
|
||||||
removeComponent: (id: string) => void
|
removeComponent: (id: string) => void
|
||||||
selectComponent: (component: ComponentInstance | null) => void
|
selectComponent: (component: ComponentInstance | null) => void
|
||||||
moveComponent: (id: string, position: { x: number; y: number }) => void
|
moveComponent: (id: string, position: { x: number; y: number }) => void
|
||||||
|
setComponents: (components: ComponentInstance[]) => void
|
||||||
clearAll: () => void
|
clearAll: () => void
|
||||||
addWireframe: (wireframe: WireframeData) => void
|
addWireframe: (wireframe: WireframeData) => void
|
||||||
updateWireframe: (id: string, updates: Partial<WireframeData>) => void
|
updateWireframe: (id: string, updates: Partial<WireframeData>) => void
|
||||||
@ -59,13 +58,6 @@ export const useEditorStore = create<EditorState>()(
|
|||||||
components: [...state.components, component],
|
components: [...state.components, component],
|
||||||
})),
|
})),
|
||||||
|
|
||||||
addComponents: (components) =>
|
|
||||||
set((state) => ({
|
|
||||||
components: [...state.components, ...components],
|
|
||||||
})),
|
|
||||||
|
|
||||||
setComponents: (components) => set({ components }),
|
|
||||||
|
|
||||||
updateComponent: (id, updates) =>
|
updateComponent: (id, updates) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
components: state.components.map((comp) =>
|
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)),
|
components: state.components.map((comp) => (comp.id === id ? { ...comp, position } : comp)),
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
setComponents: (components) => set({ components }),
|
||||||
|
|
||||||
clearAll: () =>
|
clearAll: () =>
|
||||||
set({
|
set({
|
||||||
components: [],
|
components: [],
|
||||||
@ -153,4 +147,4 @@ export const useEditorStore = create<EditorState>()(
|
|||||||
name: "editor-storage",
|
name: "editor-storage",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
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 { useEffect } from 'react'
|
||||||
import { useEditorStore } from './store'
|
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
|
* Hook to integrate wireframe data from the backend with the component canvas
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user