create-visualbuild-app 0.1.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 +123 -0
  2. package/package.json +85 -0
  3. package/scripts/create-builder-app.mjs +501 -0
  4. package/scripts/vb-dev.mjs +32 -0
  5. package/scripts/vb-generate.mjs +224 -0
  6. package/templates/default/app/README.md +21 -0
  7. package/templates/default/app/eslint.config.js +25 -0
  8. package/templates/default/app/index.html +15 -0
  9. package/templates/default/app/src/index.css +23 -0
  10. package/templates/default/app/src/main.tsx +10 -0
  11. package/templates/default/app/tsconfig.app.json +21 -0
  12. package/templates/default/app/tsconfig.json +7 -0
  13. package/templates/default/app/tsconfig.node.json +19 -0
  14. package/templates/default/app/visualbuild/generated-files.json +3 -0
  15. package/templates/default/app/visualbuild/pages.json +12 -0
  16. package/templates/default/app/vite.config.ts +7 -0
  17. package/templates/default/builder/README.md +21 -0
  18. package/templates/default/builder/eslint.config.js +25 -0
  19. package/templates/default/builder/tsconfig.app.json +21 -0
  20. package/templates/default/builder/tsconfig.json +7 -0
  21. package/templates/default/builder/tsconfig.node.json +22 -0
  22. package/visual-app-builder/index.html +15 -0
  23. package/visual-app-builder/server/parseReactPage.ts +571 -0
  24. package/visual-app-builder/shared/generateReactSource.d.mts +37 -0
  25. package/visual-app-builder/shared/generateReactSource.mjs +443 -0
  26. package/visual-app-builder/src/App.tsx +874 -0
  27. package/visual-app-builder/src/components/Canvas.tsx +1059 -0
  28. package/visual-app-builder/src/components/CodePanel.tsx +812 -0
  29. package/visual-app-builder/src/components/ComponentSidebar.tsx +302 -0
  30. package/visual-app-builder/src/components/PageSwitcher.tsx +161 -0
  31. package/visual-app-builder/src/components/ProjectExplorer.tsx +1054 -0
  32. package/visual-app-builder/src/components/PropertiesPanel.tsx +692 -0
  33. package/visual-app-builder/src/components/Topbar.tsx +128 -0
  34. package/visual-app-builder/src/data/componentRegistry.tsx +292 -0
  35. package/visual-app-builder/src/data/primitiveRegistry.ts +368 -0
  36. package/visual-app-builder/src/index.css +111 -0
  37. package/visual-app-builder/src/main.tsx +10 -0
  38. package/visual-app-builder/src/stores/useAppStore.ts +1265 -0
  39. package/visual-app-builder/src/tailwindGeneratedSafelist.ts +36 -0
  40. package/visual-app-builder/src/types/index.ts +261 -0
  41. package/visual-app-builder/src/utils/codegen.ts +66 -0
  42. package/visual-app-builder/src/utils/projectFiles.ts +146 -0
  43. package/visual-app-builder/src/utils/projectPersistence.ts +177 -0
  44. package/visual-app-builder/vite.config.ts +1479 -0
@@ -0,0 +1,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 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
+ }
@@ -0,0 +1,161 @@
1
+ import { useState } from 'react'
2
+ import { useAppStore } from '../stores/useAppStore'
3
+ import type { Page } from '../types'
4
+
5
+ export default function PageSwitcher({
6
+ onSelectPage,
7
+ onDeletePage,
8
+ }: {
9
+ onSelectPage?: (page: Page) => void
10
+ onDeletePage?: (page: Page) => void
11
+ }) {
12
+ const {
13
+ pages,
14
+ activePageId,
15
+ setActivePage,
16
+ addPage,
17
+ deletePage,
18
+ saveProject,
19
+ } = useAppStore()
20
+ const [adding, setAdding] = useState(false)
21
+ const [newName, setNewName] = useState('')
22
+ const [newRoute, setNewRoute] = useState('/')
23
+ const [error, setError] = useState<string | null>(null)
24
+ const [busy, setBusy] = useState(false)
25
+
26
+ const handleAdd = async () => {
27
+ if (!newName.trim()) return
28
+ const route = newRoute.startsWith('/') ? newRoute : `/${newRoute}`
29
+
30
+ if (pages.some((page) => page.route === route)) {
31
+ setError('Route already exists')
32
+ return
33
+ }
34
+
35
+ if (
36
+ pages.some(
37
+ (page) => page.name.toLowerCase() === newName.trim().toLowerCase()
38
+ )
39
+ ) {
40
+ setError('Page name already exists')
41
+ return
42
+ }
43
+
44
+ setBusy(true)
45
+
46
+ try {
47
+ const page = addPage(newName.trim(), route)
48
+ setActivePage(page.id)
49
+ await saveProject()
50
+ onSelectPage?.(page)
51
+ setNewName('')
52
+ setNewRoute('/')
53
+ setError(null)
54
+ setAdding(false)
55
+ } catch (saveError) {
56
+ setError(getErrorMessage(saveError))
57
+ } finally {
58
+ setBusy(false)
59
+ }
60
+ }
61
+
62
+ const handleDelete = async (page: Page) => {
63
+ if (busy) return
64
+ setBusy(true)
65
+
66
+ try {
67
+ deletePage(page.id)
68
+ await saveProject()
69
+ onDeletePage?.(page)
70
+ } catch (saveError) {
71
+ setError(getErrorMessage(saveError))
72
+ } finally {
73
+ setBusy(false)
74
+ }
75
+ }
76
+
77
+ return (
78
+ <div className="flex items-center gap-1 px-3 h-9 bg-topbar border-t border-white/[0.06] shrink-0 overflow-x-auto">
79
+ {pages.map((page) => (
80
+ <div key={page.id} className="flex items-center group shrink-0">
81
+ <button
82
+ onClick={() => {
83
+ setActivePage(page.id)
84
+ onSelectPage?.(page)
85
+ }}
86
+ className={`px-3 py-1 text-xs rounded transition-colors cursor-pointer select-none ${
87
+ activePageId === page.id
88
+ ? 'bg-white/[0.1] text-white'
89
+ : 'text-gray-500 hover:text-gray-300 hover:bg-white/[0.05]'
90
+ }`}
91
+ >
92
+ {page.name}
93
+ <span className="ml-1 text-gray-600">{page.route}</span>
94
+ </button>
95
+ {pages.length > 1 && (
96
+ <button
97
+ onClick={() => void handleDelete(page)}
98
+ disabled={busy}
99
+ className="ml-0.5 w-4 h-4 text-gray-600 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all text-xs flex items-center justify-center cursor-pointer select-none"
100
+ >
101
+ ×
102
+ </button>
103
+ )}
104
+ </div>
105
+ ))}
106
+
107
+ {adding ? (
108
+ <div className="flex items-center gap-1 shrink-0">
109
+ <input
110
+ autoFocus
111
+ value={newName}
112
+ onChange={(e) => {
113
+ setNewName(e.target.value)
114
+ setError(null)
115
+ }}
116
+ onKeyDown={(e) => e.key === 'Enter' && void handleAdd()}
117
+ placeholder="Page name"
118
+ className="w-24 px-2 py-0.5 text-xs bg-white/[0.08] border border-white/[0.12] rounded text-white placeholder-gray-600 focus:outline-none focus:border-accent/50"
119
+ />
120
+ <input
121
+ value={newRoute}
122
+ onChange={(e) => {
123
+ setNewRoute(e.target.value)
124
+ setError(null)
125
+ }}
126
+ placeholder="/route"
127
+ className="w-20 px-2 py-0.5 text-xs bg-white/[0.08] border border-white/[0.12] rounded text-white placeholder-gray-600 focus:outline-none focus:border-accent/50"
128
+ />
129
+ <button
130
+ onClick={() => void handleAdd()}
131
+ disabled={busy}
132
+ className="text-xs text-accent hover:text-white transition-colors cursor-pointer select-none disabled:cursor-default disabled:text-gray-600"
133
+ >
134
+ {busy ? 'Adding...' : 'Add'}
135
+ </button>
136
+ <button
137
+ onClick={() => {
138
+ setAdding(false)
139
+ setError(null)
140
+ }}
141
+ className="text-xs text-gray-600 hover:text-gray-300 transition-colors cursor-pointer select-none"
142
+ >
143
+ Cancel
144
+ </button>
145
+ {error && <span className="text-[10px] text-red-400">{error}</span>}
146
+ </div>
147
+ ) : (
148
+ <button
149
+ onClick={() => setAdding(true)}
150
+ className="px-2 py-1 text-xs text-gray-600 hover:text-gray-300 hover:bg-white/[0.05] rounded transition-colors shrink-0 cursor-pointer select-none"
151
+ >
152
+ + Add page
153
+ </button>
154
+ )}
155
+ </div>
156
+ )
157
+ }
158
+
159
+ function getErrorMessage(error: unknown) {
160
+ return error instanceof Error ? error.message : 'Page operation failed'
161
+ }