create-visualbuild-app 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +306 -123
  2. package/docs/user-guide.md +759 -0
  3. package/package.json +92 -85
  4. package/scripts/create-builder-app.mjs +513 -501
  5. package/scripts/vb-dev.mjs +41 -32
  6. package/scripts/vb-generate.mjs +332 -224
  7. package/templates/default/app/.vercelignore +6 -0
  8. package/templates/default/app/README.md +56 -21
  9. package/templates/default/app/visualbuild/components.json +3 -0
  10. package/templates/default/app/visualbuild/config.d.ts +48 -0
  11. package/templates/default/app/visualbuild.config.ts +38 -0
  12. package/templates/default/builder/README.md +37 -21
  13. package/visual-app-builder/server/parseReactPage.ts +776 -571
  14. package/visual-app-builder/shared/createReactComponentSource.d.mts +2 -0
  15. package/visual-app-builder/shared/createReactComponentSource.mjs +45 -0
  16. package/visual-app-builder/shared/generateReactSource.d.mts +57 -37
  17. package/visual-app-builder/shared/generateReactSource.mjs +608 -443
  18. package/visual-app-builder/shared/npmBuildCommand.d.mts +17 -0
  19. package/visual-app-builder/shared/npmBuildCommand.mjs +26 -0
  20. package/visual-app-builder/shared/syncCustomComponentSource.d.mts +13 -0
  21. package/visual-app-builder/shared/syncCustomComponentSource.mjs +345 -0
  22. package/visual-app-builder/shared/vercelDeployment.d.mts +31 -0
  23. package/visual-app-builder/shared/vercelDeployment.mjs +266 -0
  24. package/visual-app-builder/shared/visualbuildConfig.d.mts +144 -0
  25. package/visual-app-builder/shared/visualbuildConfig.mjs +578 -0
  26. package/visual-app-builder/src/App.tsx +1090 -874
  27. package/visual-app-builder/src/components/Canvas.tsx +1116 -1059
  28. package/visual-app-builder/src/components/CodePanel.tsx +1147 -812
  29. package/visual-app-builder/src/components/ComponentSidebar.tsx +365 -302
  30. package/visual-app-builder/src/components/DeploymentModal.tsx +295 -0
  31. package/visual-app-builder/src/components/PageSwitcher.tsx +133 -133
  32. package/visual-app-builder/src/components/ProjectExplorer.tsx +1054 -1054
  33. package/visual-app-builder/src/components/PropertiesPanel.tsx +792 -692
  34. package/visual-app-builder/src/components/Topbar.tsx +257 -128
  35. package/visual-app-builder/src/data/componentRegistry.tsx +613 -292
  36. package/visual-app-builder/src/data/tailwindClassCatalog.ts +95 -0
  37. package/visual-app-builder/src/index.css +383 -111
  38. package/visual-app-builder/src/stores/useAppStore.ts +2385 -1265
  39. package/visual-app-builder/src/theme.ts +71 -0
  40. package/visual-app-builder/src/types/index.ts +350 -261
  41. package/visual-app-builder/src/utils/codegen.ts +90 -66
  42. package/visual-app-builder/src/utils/deployment.ts +54 -0
  43. package/visual-app-builder/src/utils/projectPersistence.ts +218 -177
  44. package/visual-app-builder/vite.config.ts +1946 -1479
@@ -1,302 +1,365 @@
1
- import { useDraggable } from '@dnd-kit/core'
2
- import { CSS } from '@dnd-kit/utilities'
3
- import { useState } from 'react'
4
- import { components } from '../data/componentRegistry'
5
- import { primitives } from '../data/primitiveRegistry'
6
- import { useAppStore } from '../stores/useAppStore'
7
- import type { ComponentDefinition, PrimitiveDefinition } from '../types'
8
- import ProjectExplorer from './ProjectExplorer'
9
-
10
- export default function ComponentSidebar({
11
- view,
12
- selectedProjectFile,
13
- onSelectProjectFile,
14
- }: {
15
- view: 'files' | 'elements'
16
- selectedProjectFile: string | null
17
- onSelectProjectFile: (path: string | null) => void
18
- }) {
19
- const {
20
- activePageId,
21
- selectedComponentId,
22
- addComponent,
23
- addPrimitive,
24
- addPrimitiveToNode,
25
- setAppShellComponent,
26
- } = useAppStore()
27
- const globalComponents = components.filter((component) =>
28
- ['Navbar', 'Footer'].includes(component.type)
29
- )
30
- const pageComponents = components.filter(
31
- (component) => !['Navbar', 'Footer'].includes(component.type)
32
- )
33
- const primitiveGroups = groupPrimitivesByCategory(primitives)
34
- const [openSections, setOpenSections] = useState<Set<string>>(
35
- () => new Set(['Global', 'Page Blocks', 'Semantic'])
36
- )
37
- const toggleSection = (section: string) => {
38
- setOpenSections((current) => {
39
- const next = new Set(current)
40
-
41
- if (next.has(section)) {
42
- next.delete(section)
43
- } else {
44
- next.add(section)
45
- }
46
-
47
- return next
48
- })
49
- }
50
- return (
51
- <aside className="flex h-full w-full flex-col overflow-hidden bg-sidebar">
52
- <div className="px-3 py-2.5 border-b border-white/[0.06]">
53
- <p className="text-[10px] font-semibold uppercase tracking-widest text-gray-500">
54
- {view === 'files' ? 'Project files' : 'Elements'}
55
- </p>
56
- </div>
57
-
58
- <div
59
- className={`min-h-0 flex-1 ${
60
- view === 'files' ? 'overflow-hidden' : 'overflow-y-auto p-2 space-y-1'
61
- }`}
62
- >
63
- {view === 'files' ? (
64
- <ProjectExplorer
65
- selectedPath={selectedProjectFile}
66
- onSelectFile={onSelectProjectFile}
67
- />
68
- ) : (
69
- <>
70
- <AccordionSection
71
- title="Global"
72
- count={globalComponents.length}
73
- isOpen={openSections.has('Global')}
74
- onToggle={() => toggleSection('Global')}
75
- >
76
- {globalComponents.map((component) => {
77
- const slot = component.type === 'Footer' ? 'footer' : 'header'
78
-
79
- return (
80
- <ComponentSidebarItem
81
- key={component.type}
82
- component={component}
83
- isDisabled={false}
84
- onAdd={() => setAppShellComponent(slot, component.type)}
85
- />
86
- )
87
- })}
88
- </AccordionSection>
89
-
90
- <AccordionSection
91
- title="Page Blocks"
92
- count={pageComponents.length}
93
- isOpen={openSections.has('Page Blocks')}
94
- onToggle={() => toggleSection('Page Blocks')}
95
- >
96
- {pageComponents.map((component) => {
97
- return (
98
- <ComponentSidebarItem
99
- key={component.type}
100
- component={component}
101
- isDisabled={false}
102
- onAdd={() => addComponent(activePageId, component.type)}
103
- />
104
- )
105
- })}
106
- </AccordionSection>
107
-
108
- {primitiveGroups.map(([category, items]) => (
109
- <AccordionSection
110
- key={category}
111
- title={category}
112
- count={items.length}
113
- isOpen={openSections.has(category)}
114
- onToggle={() => toggleSection(category)}
115
- >
116
- {items.map((primitive) => (
117
- <PrimitiveSidebarItem
118
- key={primitive.type}
119
- primitive={primitive}
120
- isClickDisabled={selectedComponentId === null}
121
- onAdd={() => {
122
- if (selectedComponentId) {
123
- addPrimitiveToNode(selectedComponentId, primitive.type)
124
- return
125
- }
126
-
127
- addPrimitive(activePageId, primitive.type)
128
- }}
129
- />
130
- ))}
131
- </AccordionSection>
132
- ))}
133
- </>
134
- )}
135
- </div>
136
-
137
- {view === 'elements' && (
138
- <div className="px-3 py-2 border-t border-white/[0.06]">
139
- <p className="text-[10px] text-gray-600">Drag or click to add</p>
140
- </div>
141
- )}
142
- </aside>
143
- )
144
- }
145
-
146
- function AccordionSection({
147
- title,
148
- count,
149
- isOpen,
150
- onToggle,
151
- children,
152
- }: {
153
- title: string
154
- count: number
155
- isOpen: boolean
156
- onToggle: () => void
157
- children: React.ReactNode
158
- }) {
159
- return (
160
- <section className="border-b border-white/[0.04] pb-1 last:border-b-0">
161
- <button
162
- type="button"
163
- onClick={onToggle}
164
- className="flex w-full cursor-pointer select-none items-center justify-between rounded px-2 py-1.5 text-left transition-colors hover:bg-white/[0.04]"
165
- >
166
- <span className="text-[10px] font-medium uppercase tracking-widest text-gray-500">
167
- {title}
168
- </span>
169
- <span className="flex items-center gap-1 text-[10px] text-gray-600">
170
- {count}
171
- <span
172
- className={`inline-block transition-transform ${
173
- isOpen ? 'rotate-90' : ''
174
- }`}
175
- aria-hidden="true"
176
- >
177
- &gt;
178
- </span>
179
- </span>
180
- </button>
181
- {isOpen && <div className="space-y-0.5 pb-1">{children}</div>}
182
- </section>
183
- )
184
- }
185
-
186
- function groupPrimitivesByCategory(items: PrimitiveDefinition[]) {
187
- const groups = new Map<string, PrimitiveDefinition[]>()
188
-
189
- for (const item of items) {
190
- groups.set(item.category, [...(groups.get(item.category) ?? []), item])
191
- }
192
-
193
- return Array.from(groups.entries())
194
- }
195
-
196
- function ComponentSidebarItem({
197
- component,
198
- isDisabled,
199
- onAdd,
200
- }: {
201
- component: ComponentDefinition
202
- isDisabled: boolean
203
- onAdd: () => void
204
- }) {
205
- const { attributes, listeners, setNodeRef, transform, isDragging } =
206
- useDraggable({
207
- id: `sidebar:${component.type}`,
208
- disabled: isDisabled,
209
- data: {
210
- kind: 'sidebar-component',
211
- type: component.type,
212
- },
213
- })
214
- const style = {
215
- transform: CSS.Translate.toString(transform),
216
- }
217
-
218
- return (
219
- <button
220
- ref={setNodeRef}
221
- type="button"
222
- disabled={isDisabled}
223
- onClick={onAdd}
224
- style={style}
225
- className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md text-left group transition-colors ${
226
- isDisabled
227
- ? 'opacity-45 cursor-not-allowed'
228
- : 'cursor-grab select-none hover:bg-white/[0.06] active:cursor-grabbing'
229
- } ${isDragging ? 'opacity-70 bg-white/[0.08]' : ''}`}
230
- {...listeners}
231
- {...attributes}
232
- >
233
- <span className="text-base text-gray-500 group-hover:text-accent transition-colors w-5 text-center leading-none">
234
- {component.icon}
235
- </span>
236
- <div className="min-w-0">
237
- <p className="text-xs font-medium text-gray-300 group-hover:text-white transition-colors leading-tight">
238
- {component.label}
239
- </p>
240
- <p className="text-[10px] text-gray-600 group-hover:text-gray-400 transition-colors leading-tight mt-0.5 truncate">
241
- {getComponentDescription(component.type, component.description)}
242
- </p>
243
- </div>
244
- </button>
245
- )
246
- }
247
-
248
- function PrimitiveSidebarItem({
249
- primitive,
250
- isClickDisabled,
251
- onAdd,
252
- }: {
253
- primitive: PrimitiveDefinition
254
- isClickDisabled: boolean
255
- onAdd: () => void
256
- }) {
257
- const { attributes, listeners, setNodeRef, transform, isDragging } =
258
- useDraggable({
259
- id: `primitive:${primitive.type}`,
260
- data: {
261
- kind: 'primitive',
262
- type: primitive.type,
263
- },
264
- })
265
- const style = {
266
- transform: CSS.Translate.toString(transform),
267
- }
268
-
269
- return (
270
- <button
271
- ref={setNodeRef}
272
- type="button"
273
- onClick={onAdd}
274
- style={style}
275
- title={
276
- isClickDisabled
277
- ? `Drag ${primitive.label} onto a component or select a component first`
278
- : `Add ${primitive.label} to selected component`
279
- }
280
- className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md text-left group transition-colors cursor-grab select-none hover:bg-white/[0.06] active:cursor-grabbing ${
281
- isDragging ? 'opacity-70 bg-white/[0.08]' : ''
282
- } ${isClickDisabled ? 'opacity-80' : ''}`}
283
- {...listeners}
284
- {...attributes}
285
- >
286
- <div className="min-w-0">
287
- <p className="text-xs font-medium text-gray-300 group-hover:text-white transition-colors leading-tight">
288
- {primitive.label}
289
- </p>
290
- <p className="text-[10px] text-gray-600 group-hover:text-gray-400 transition-colors leading-tight mt-0.5 truncate">
291
- {primitive.description}
292
- </p>
293
- </div>
294
- </button>
295
- )
296
- }
297
-
298
- function getComponentDescription(type: string, fallback: string) {
299
- if (type === 'Navbar') return 'Global header across all pages'
300
- if (type === 'Footer') return 'Global footer across all pages'
301
- return fallback
302
- }
1
+ import { useDraggable } from '@dnd-kit/core'
2
+ import { CSS } from '@dnd-kit/utilities'
3
+ import { useState } from 'react'
4
+ import { components } from '../data/componentRegistry'
5
+ import { primitives } from '../data/primitiveRegistry'
6
+ import { useAppStore } from '../stores/useAppStore'
7
+ import type { ComponentDefinition, PrimitiveDefinition } from '../types'
8
+ import type { CustomComponentDefinition } from '../types'
9
+ import ProjectExplorer from './ProjectExplorer'
10
+
11
+ export default function ComponentSidebar({
12
+ view,
13
+ selectedProjectFile,
14
+ onSelectProjectFile,
15
+ }: {
16
+ view: 'files' | 'elements'
17
+ selectedProjectFile: string | null
18
+ onSelectProjectFile: (path: string | null) => void
19
+ }) {
20
+ const {
21
+ activePageId,
22
+ selectedComponentId,
23
+ addComponent,
24
+ addPrimitive,
25
+ addPrimitiveToNode,
26
+ setAppShellComponent,
27
+ customComponents,
28
+ addCustomComponent,
29
+ } = useAppStore()
30
+ const globalComponents = components.filter((component) =>
31
+ ['Navbar', 'Footer'].includes(component.type)
32
+ )
33
+ const pageComponents = components.filter(
34
+ (component) => !['Navbar', 'Footer'].includes(component.type)
35
+ )
36
+ const primitiveGroups = groupPrimitivesByCategory(primitives)
37
+ const [openSections, setOpenSections] = useState<Set<string>>(
38
+ () => new Set(['Global', 'Page Blocks', 'Custom', 'Semantic'])
39
+ )
40
+ const toggleSection = (section: string) => {
41
+ setOpenSections((current) => {
42
+ const next = new Set(current)
43
+
44
+ if (next.has(section)) {
45
+ next.delete(section)
46
+ } else {
47
+ next.add(section)
48
+ }
49
+
50
+ return next
51
+ })
52
+ }
53
+ return (
54
+ <aside className="flex h-full w-full flex-col overflow-hidden bg-sidebar">
55
+ <div className="px-3 py-2.5 border-b border-white/[0.06]">
56
+ <p className="text-[10px] font-semibold uppercase tracking-widest text-gray-500">
57
+ {view === 'files' ? 'Project files' : 'Elements'}
58
+ </p>
59
+ </div>
60
+
61
+ <div
62
+ className={`min-h-0 flex-1 ${
63
+ view === 'files' ? 'overflow-hidden' : 'overflow-y-auto p-2 space-y-1'
64
+ }`}
65
+ >
66
+ {view === 'files' ? (
67
+ <ProjectExplorer
68
+ selectedPath={selectedProjectFile}
69
+ onSelectFile={onSelectProjectFile}
70
+ />
71
+ ) : (
72
+ <>
73
+ <AccordionSection
74
+ title="Global"
75
+ count={globalComponents.length}
76
+ isOpen={openSections.has('Global')}
77
+ onToggle={() => toggleSection('Global')}
78
+ >
79
+ {globalComponents.map((component) => {
80
+ const slot = component.type === 'Footer' ? 'footer' : 'header'
81
+
82
+ return (
83
+ <ComponentSidebarItem
84
+ key={component.type}
85
+ component={component}
86
+ isDisabled={false}
87
+ onAdd={() => setAppShellComponent(slot, component.type)}
88
+ />
89
+ )
90
+ })}
91
+ </AccordionSection>
92
+
93
+ {customComponents.length > 0 && (
94
+ <AccordionSection
95
+ title="Custom"
96
+ count={customComponents.length}
97
+ isOpen={openSections.has('Custom')}
98
+ onToggle={() => toggleSection('Custom')}
99
+ >
100
+ {customComponents.map((component) => (
101
+ <CustomComponentSidebarItem
102
+ key={component.name}
103
+ component={component}
104
+ onAdd={() => addCustomComponent(activePageId, component.name)}
105
+ />
106
+ ))}
107
+ </AccordionSection>
108
+ )}
109
+
110
+ <AccordionSection
111
+ title="Page Blocks"
112
+ count={pageComponents.length}
113
+ isOpen={openSections.has('Page Blocks')}
114
+ onToggle={() => toggleSection('Page Blocks')}
115
+ >
116
+ {pageComponents.map((component) => {
117
+ return (
118
+ <ComponentSidebarItem
119
+ key={component.type}
120
+ component={component}
121
+ isDisabled={false}
122
+ onAdd={() => addComponent(activePageId, component.type)}
123
+ />
124
+ )
125
+ })}
126
+ </AccordionSection>
127
+
128
+ {primitiveGroups.map(([category, items]) => (
129
+ <AccordionSection
130
+ key={category}
131
+ title={category}
132
+ count={items.length}
133
+ isOpen={openSections.has(category)}
134
+ onToggle={() => toggleSection(category)}
135
+ >
136
+ {items.map((primitive) => (
137
+ <PrimitiveSidebarItem
138
+ key={primitive.type}
139
+ primitive={primitive}
140
+ isClickDisabled={selectedComponentId === null}
141
+ onAdd={() => {
142
+ if (selectedComponentId) {
143
+ addPrimitiveToNode(selectedComponentId, primitive.type)
144
+ return
145
+ }
146
+
147
+ addPrimitive(activePageId, primitive.type)
148
+ }}
149
+ />
150
+ ))}
151
+ </AccordionSection>
152
+ ))}
153
+ </>
154
+ )}
155
+ </div>
156
+
157
+ {view === 'elements' && (
158
+ <div className="px-3 py-2 border-t border-white/[0.06]">
159
+ <p className="text-[10px] text-gray-600">Drag or click to add</p>
160
+ </div>
161
+ )}
162
+ </aside>
163
+ )
164
+ }
165
+
166
+ function CustomComponentSidebarItem({
167
+ component,
168
+ onAdd,
169
+ }: {
170
+ component: CustomComponentDefinition
171
+ onAdd: () => void
172
+ }) {
173
+ const { attributes, listeners, setNodeRef, transform, isDragging } =
174
+ useDraggable({
175
+ id: `custom:${component.name}`,
176
+ data: {
177
+ kind: 'custom-component',
178
+ name: component.name,
179
+ },
180
+ })
181
+
182
+ return (
183
+ <button
184
+ ref={setNodeRef}
185
+ type="button"
186
+ onClick={onAdd}
187
+ style={{ transform: CSS.Translate.toString(transform) }}
188
+ className={`w-full flex cursor-grab select-none items-center gap-2.5 rounded-md px-2.5 py-2 text-left transition-colors hover:bg-white/[0.06] active:cursor-grabbing ${
189
+ isDragging ? 'bg-white/[0.08] opacity-70' : ''
190
+ }`}
191
+ {...listeners}
192
+ {...attributes}
193
+ >
194
+ <span className="w-5 text-center text-base leading-none text-gray-500">
195
+ {component.icon}
196
+ </span>
197
+ <div className="min-w-0">
198
+ <p className="text-xs font-medium leading-tight text-gray-300">
199
+ {component.label}
200
+ </p>
201
+ <p className="mt-0.5 truncate text-[10px] leading-tight text-gray-600">
202
+ {component.description}
203
+ </p>
204
+ </div>
205
+ </button>
206
+ )
207
+ }
208
+
209
+ function AccordionSection({
210
+ title,
211
+ count,
212
+ isOpen,
213
+ onToggle,
214
+ children,
215
+ }: {
216
+ title: string
217
+ count: number
218
+ isOpen: boolean
219
+ onToggle: () => void
220
+ children: React.ReactNode
221
+ }) {
222
+ return (
223
+ <section className="border-b border-white/[0.04] pb-1 last:border-b-0">
224
+ <button
225
+ type="button"
226
+ onClick={onToggle}
227
+ className="flex w-full cursor-pointer select-none items-center justify-between rounded px-2 py-1.5 text-left transition-colors hover:bg-white/[0.04]"
228
+ >
229
+ <span className="text-[10px] font-medium uppercase tracking-widest text-gray-500">
230
+ {title}
231
+ </span>
232
+ <span className="flex items-center gap-1 text-[10px] text-gray-600">
233
+ {count}
234
+ <span
235
+ className={`inline-block transition-transform ${
236
+ isOpen ? 'rotate-90' : ''
237
+ }`}
238
+ aria-hidden="true"
239
+ >
240
+ &gt;
241
+ </span>
242
+ </span>
243
+ </button>
244
+ {isOpen && <div className="space-y-0.5 pb-1">{children}</div>}
245
+ </section>
246
+ )
247
+ }
248
+
249
+ function groupPrimitivesByCategory(items: PrimitiveDefinition[]) {
250
+ const groups = new Map<string, PrimitiveDefinition[]>()
251
+
252
+ for (const item of items) {
253
+ groups.set(item.category, [...(groups.get(item.category) ?? []), item])
254
+ }
255
+
256
+ return Array.from(groups.entries())
257
+ }
258
+
259
+ function ComponentSidebarItem({
260
+ component,
261
+ isDisabled,
262
+ onAdd,
263
+ }: {
264
+ component: ComponentDefinition
265
+ isDisabled: boolean
266
+ onAdd: () => void
267
+ }) {
268
+ const { attributes, listeners, setNodeRef, transform, isDragging } =
269
+ useDraggable({
270
+ id: `sidebar:${component.type}`,
271
+ disabled: isDisabled,
272
+ data: {
273
+ kind: 'sidebar-component',
274
+ type: component.type,
275
+ },
276
+ })
277
+ const style = {
278
+ transform: CSS.Translate.toString(transform),
279
+ }
280
+
281
+ return (
282
+ <button
283
+ ref={setNodeRef}
284
+ type="button"
285
+ disabled={isDisabled}
286
+ onClick={onAdd}
287
+ style={style}
288
+ className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md text-left group transition-colors ${
289
+ isDisabled
290
+ ? 'opacity-45 cursor-not-allowed'
291
+ : 'cursor-grab select-none hover:bg-white/[0.06] active:cursor-grabbing'
292
+ } ${isDragging ? 'opacity-70 bg-white/[0.08]' : ''}`}
293
+ {...listeners}
294
+ {...attributes}
295
+ >
296
+ <span className="text-base text-gray-500 group-hover:text-accent transition-colors w-5 text-center leading-none">
297
+ {component.icon}
298
+ </span>
299
+ <div className="min-w-0">
300
+ <p className="text-xs font-medium text-gray-300 group-hover:text-white transition-colors leading-tight">
301
+ {component.label}
302
+ </p>
303
+ <p className="text-[10px] text-gray-600 group-hover:text-gray-400 transition-colors leading-tight mt-0.5 truncate">
304
+ {getComponentDescription(component.type, component.description)}
305
+ </p>
306
+ </div>
307
+ </button>
308
+ )
309
+ }
310
+
311
+ function PrimitiveSidebarItem({
312
+ primitive,
313
+ isClickDisabled,
314
+ onAdd,
315
+ }: {
316
+ primitive: PrimitiveDefinition
317
+ isClickDisabled: boolean
318
+ onAdd: () => void
319
+ }) {
320
+ const { attributes, listeners, setNodeRef, transform, isDragging } =
321
+ useDraggable({
322
+ id: `primitive:${primitive.type}`,
323
+ data: {
324
+ kind: 'primitive',
325
+ type: primitive.type,
326
+ },
327
+ })
328
+ const style = {
329
+ transform: CSS.Translate.toString(transform),
330
+ }
331
+
332
+ return (
333
+ <button
334
+ ref={setNodeRef}
335
+ type="button"
336
+ onClick={onAdd}
337
+ style={style}
338
+ title={
339
+ isClickDisabled
340
+ ? `Drag ${primitive.label} onto a component or select a component first`
341
+ : `Add ${primitive.label} to selected component`
342
+ }
343
+ className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md text-left group transition-colors cursor-grab select-none hover:bg-white/[0.06] active:cursor-grabbing ${
344
+ isDragging ? 'opacity-70 bg-white/[0.08]' : ''
345
+ } ${isClickDisabled ? 'opacity-80' : ''}`}
346
+ {...listeners}
347
+ {...attributes}
348
+ >
349
+ <div className="min-w-0">
350
+ <p className="text-xs font-medium text-gray-300 group-hover:text-white transition-colors leading-tight">
351
+ {primitive.label}
352
+ </p>
353
+ <p className="text-[10px] text-gray-600 group-hover:text-gray-400 transition-colors leading-tight mt-0.5 truncate">
354
+ {primitive.description}
355
+ </p>
356
+ </div>
357
+ </button>
358
+ )
359
+ }
360
+
361
+ function getComponentDescription(type: string, fallback: string) {
362
+ if (type === 'Navbar') return 'Global header across all pages'
363
+ if (type === 'Footer') return 'Global footer across all pages'
364
+ return fallback
365
+ }