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,874 +1,1090 @@
1
- import {
2
- DndContext,
3
- KeyboardSensor,
4
- PointerSensor,
5
- pointerWithin,
6
- rectIntersection,
7
- useSensor,
8
- useSensors,
9
- type CollisionDetection,
10
- type DragEndEvent,
11
- } from '@dnd-kit/core'
12
- import { sortableKeyboardCoordinates } from '@dnd-kit/sortable'
13
- import { useEffect, useRef, useState } from 'react'
14
- import Canvas from './components/Canvas'
15
- import CodePanel, { type CodeView } from './components/CodePanel'
16
- import ComponentSidebar from './components/ComponentSidebar'
17
- import PageSwitcher from './components/PageSwitcher'
18
- import PropertiesPanel from './components/PropertiesPanel'
19
- import Topbar, { type SaveState } from './components/Topbar'
20
- import { useAppStore } from './stores/useAppStore'
21
- import type { ComponentType, Page, PrimitiveType } from './types'
22
- import { getPageSourcePath } from './utils/codegen'
23
- import { getGeneratorDiagnostic } from './utils/projectPersistence'
24
-
25
- type DragData =
26
- | { kind: 'sidebar-component'; type: ComponentType }
27
- | { kind: 'primitive'; type: PrimitiveType }
28
- | { kind: 'canvas-node'; nodeId: string }
29
- | { kind: 'canvas-root' }
30
- | { kind: 'node-dropzone'; nodeId: string }
31
- | {
32
- kind: 'insertion-zone'
33
- parentNodeId: string | null
34
- index: number
35
- }
36
-
37
- interface ReverseSyncPayload {
38
- type: 'reverse-sync-success' | 'reverse-sync-error'
39
- path: string
40
- pageId?: string
41
- message: string
42
- }
43
-
44
- interface GeneratorDiagnosticPayload {
45
- type: 'generator-diagnostic'
46
- severity: 'error'
47
- phase: 'generate'
48
- code: string
49
- title: string
50
- message: string
51
- path: string
52
- suggestion?: string
53
- }
54
-
55
- interface FileSystemPayload {
56
- type: 'filesystem-change'
57
- path: string
58
- action: 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir'
59
- }
60
-
61
- interface ManagedPagesDeletedPayload {
62
- type: 'managed-pages-deleted'
63
- path: string
64
- pageIds: string[]
65
- message: string
66
- }
67
-
68
- interface ManagedPagesMovedPayload {
69
- type: 'managed-pages-moved'
70
- path: string
71
- targetPath: string
72
- pages: Array<{
73
- pageId: string
74
- previousPath: string
75
- sourcePath: string
76
- }>
77
- message: string
78
- }
79
-
80
- type VisualBuildPayload =
81
- | ReverseSyncPayload
82
- | GeneratorDiagnosticPayload
83
- | FileSystemPayload
84
- | ManagedPagesDeletedPayload
85
- | ManagedPagesMovedPayload
86
-
87
- type ReverseSyncNoticeState =
88
- | (ReverseSyncPayload & {
89
- status: 'success' | 'error' | 'conflict'
90
- })
91
- | (GeneratorDiagnosticPayload & {
92
- status: 'error'
93
- })
94
-
95
- export default function App() {
96
- const [leftPanelOpen, setLeftPanelOpen] = useState(true)
97
- const [leftPanelTab, setLeftPanelTab] =
98
- useState<'files' | 'elements'>('elements')
99
- const [rightPanelOpen, setRightPanelOpen] = useState(true)
100
- const [rightPanelTab, setRightPanelTab] = useState<'code' | 'properties'>('code')
101
- const [codeView, setCodeView] = useState<CodeView>('page')
102
- const [selectedProjectFile, setSelectedProjectFile] = useState<string | null>(
103
- null
104
- )
105
- const [saveState, setSaveState] = useState<SaveState>('idle')
106
- const [reverseSyncNotice, setReverseSyncNotice] =
107
- useState<ReverseSyncNoticeState | null>(null)
108
- const saveResetTimeout = useRef<number | null>(null)
109
- const {
110
- pages,
111
- activePageId,
112
- loadProject,
113
- refreshProjectFromDisk,
114
- isProjectLoaded,
115
- previewMode,
116
- togglePreviewMode,
117
- saveProject,
118
- addComponentAt,
119
- addComponentToNode,
120
- addComponentToNodeAt,
121
- addPrimitiveAt,
122
- addPrimitiveToNode,
123
- addPrimitiveToNodeAt,
124
- moveNode,
125
- setActivePage,
126
- setAppShellComponent,
127
- } = useAppStore()
128
- const sensors = useSensors(
129
- useSensor(PointerSensor, {
130
- activationConstraint: {
131
- distance: 6,
132
- },
133
- }),
134
- useSensor(KeyboardSensor, {
135
- coordinateGetter: sortableKeyboardCoordinates,
136
- })
137
- )
138
- const activePage = pages.find((page) => page.id === activePageId)
139
-
140
- useEffect(() => {
141
- void loadProject()
142
- }, [loadProject])
143
-
144
- useEffect(() => {
145
- const eventSource = new EventSource('/__visualbuild/events')
146
- const handleReverseSync = (event: MessageEvent<string>) => {
147
- let payload: VisualBuildPayload
148
-
149
- try {
150
- payload = JSON.parse(event.data) as VisualBuildPayload
151
- } catch {
152
- return
153
- }
154
-
155
- if (payload.type === 'filesystem-change') {
156
- window.dispatchEvent(
157
- new CustomEvent('visualbuild:filesystem-changed', {
158
- detail: payload,
159
- })
160
- )
161
- return
162
- }
163
-
164
- if (payload.type === 'managed-pages-deleted') {
165
- setSelectedProjectFile((currentPath) => {
166
- if (
167
- currentPath === payload.path ||
168
- currentPath?.startsWith(`${payload.path}/`)
169
- ) {
170
- return null
171
- }
172
-
173
- return currentPath
174
- })
175
- setCodeView('page')
176
- setReverseSyncNotice(null)
177
- void refreshProjectFromDisk(true).then((result) => {
178
- window.dispatchEvent(new Event('visualbuild:filesystem-changed'))
179
-
180
- if (result !== 'updated') {
181
- setReverseSyncNotice({
182
- type: 'reverse-sync-error',
183
- path: payload.path,
184
- message: 'The page was deleted, but the editor could not refresh.',
185
- status: 'error',
186
- })
187
- return
188
- }
189
-
190
- const nextActivePage = useAppStore
191
- .getState()
192
- .pages.find(
193
- (page) => page.id === useAppStore.getState().activePageId
194
- )
195
-
196
- if (nextActivePage) {
197
- window.history.replaceState({}, '', nextActivePage.route)
198
- }
199
- })
200
- return
201
- }
202
-
203
- if (payload.type === 'managed-pages-moved') {
204
- setSelectedProjectFile((currentPath) =>
205
- currentPath
206
- ? replacePathPrefix(
207
- currentPath,
208
- payload.path,
209
- payload.targetPath
210
- )
211
- : null
212
- )
213
- setReverseSyncNotice(null)
214
- void refreshProjectFromDisk(true).then((result) => {
215
- window.dispatchEvent(new Event('visualbuild:filesystem-changed'))
216
-
217
- if (result !== 'updated') {
218
- setReverseSyncNotice({
219
- type: 'reverse-sync-error',
220
- path: payload.targetPath,
221
- message:
222
- 'The page source moved, but the editor could not refresh.',
223
- status: 'error',
224
- })
225
- }
226
- })
227
- return
228
- }
229
-
230
- if (payload.type === 'generator-diagnostic') {
231
- setReverseSyncNotice({ ...payload, status: 'error' })
232
- return
233
- }
234
-
235
- if (payload.type === 'reverse-sync-error') {
236
- setReverseSyncNotice({ ...payload, status: 'error' })
237
- return
238
- }
239
-
240
- void refreshProjectFromDisk().then((result) => {
241
- if (result === 'updated') {
242
- window.dispatchEvent(
243
- new Event('visualbuild:filesystem-changed')
244
- )
245
- setReverseSyncNotice({ ...payload, status: 'success' })
246
- return
247
- }
248
-
249
- setReverseSyncNotice({
250
- ...payload,
251
- status: result === 'skipped-dirty' ? 'conflict' : 'error',
252
- message:
253
- result === 'skipped-dirty'
254
- ? 'Source changed while the canvas has unsaved edits.'
255
- : 'The source changed, but the canvas could not refresh.',
256
- })
257
- })
258
- }
259
-
260
- eventSource.addEventListener(
261
- 'visualbuild',
262
- handleReverseSync as EventListener
263
- )
264
-
265
- return () => eventSource.close()
266
- }, [refreshProjectFromDisk])
267
-
268
- useEffect(
269
- () => () => {
270
- if (saveResetTimeout.current !== null) {
271
- window.clearTimeout(saveResetTimeout.current)
272
- }
273
- },
274
- []
275
- )
276
-
277
- const handleToggleCode = () => {
278
- if (previewMode) {
279
- togglePreviewMode()
280
- setRightPanelTab('code')
281
- setRightPanelOpen(true)
282
- return
283
- }
284
-
285
- if (rightPanelOpen && rightPanelTab === 'code') {
286
- setRightPanelOpen(false)
287
- return
288
- }
289
-
290
- setRightPanelTab('code')
291
- setRightPanelOpen(true)
292
- }
293
-
294
- const handleTogglePreview = () => {
295
- if (!previewMode && activePage) {
296
- window.history.replaceState({}, '', activePage.route)
297
- }
298
-
299
- togglePreviewMode()
300
- }
301
-
302
- const handleSelectProjectFile = (path: string | null) => {
303
- setSelectedProjectFile(path)
304
-
305
- if (path) {
306
- const normalizedPath = path.replace(/\\/g, '/')
307
- const managedPage = pages.find(
308
- (page) => getPageSourcePath(page) === normalizedPath
309
- )
310
-
311
- if (managedPage) {
312
- setActivePage(managedPage.id)
313
- setCodeView('page')
314
- } else {
315
- setCodeView('file')
316
- }
317
- setRightPanelTab('code')
318
- setRightPanelOpen(true)
319
- } else if (codeView === 'file') {
320
- setCodeView('page')
321
- }
322
- }
323
-
324
- const handleSelectCodeWorkspaceFile = (path: string) => {
325
- const normalizedPath = path.replace(/\\/g, '/')
326
- const managedPage = pages.find(
327
- (page) => getPageSourcePath(page) === normalizedPath
328
- )
329
-
330
- setSelectedProjectFile(path)
331
- if (managedPage) {
332
- setActivePage(managedPage.id)
333
- }
334
- setCodeView('file')
335
- }
336
-
337
- const handleSelectPage = (page: Page) => {
338
- setSelectedProjectFile(getPageSourcePath(page))
339
- setCodeView('page')
340
- setRightPanelTab('code')
341
- setRightPanelOpen(true)
342
- }
343
-
344
- const handleDeletePage = (page: Page) => {
345
- if (selectedProjectFile === getPageSourcePath(page)) {
346
- setSelectedProjectFile(null)
347
- setCodeView('page')
348
- }
349
- }
350
-
351
- const handleSave = async () => {
352
- if (saveState === 'saving') {
353
- return
354
- }
355
-
356
- if (saveResetTimeout.current !== null) {
357
- window.clearTimeout(saveResetTimeout.current)
358
- }
359
-
360
- setSaveState('saving')
361
- const minimumOverlayTime = delay(450)
362
-
363
- try {
364
- await saveProject()
365
- await minimumOverlayTime
366
- setSaveState('saved')
367
- saveResetTimeout.current = window.setTimeout(
368
- () => setSaveState('idle'),
369
- 1200
370
- )
371
- } catch (error) {
372
- await minimumOverlayTime
373
- console.error('Failed to save VisualBuild project', error)
374
- const diagnostic = getGeneratorDiagnostic(error)
375
-
376
- if (diagnostic) {
377
- setReverseSyncNotice({
378
- type: 'generator-diagnostic',
379
- ...diagnostic,
380
- status: 'error',
381
- })
382
- }
383
-
384
- setSaveState('error')
385
- saveResetTimeout.current = window.setTimeout(
386
- () => setSaveState('idle'),
387
- 2400
388
- )
389
- }
390
- }
391
-
392
- const handleUseSourceChanges = async () => {
393
- const result = await refreshProjectFromDisk(true)
394
-
395
- if (result === 'updated') {
396
- window.dispatchEvent(new Event('visualbuild:filesystem-changed'))
397
- setReverseSyncNotice((notice) =>
398
- notice && notice.type !== 'generator-diagnostic'
399
- ? {
400
- ...notice,
401
- status: 'success',
402
- message: 'Loaded the source version into the canvas.',
403
- }
404
- : null
405
- )
406
- }
407
- }
408
-
409
- const handleDragEnd = (event: DragEndEvent) => {
410
- const activeData = getDragData(event.active.data.current)
411
- const overData = getDragData(event.over?.data.current)
412
-
413
- if (!activeData || !overData || !activePage) {
414
- return
415
- }
416
-
417
- if (overData.kind === 'insertion-zone') {
418
- if (activeData.kind === 'canvas-node') {
419
- moveNode(
420
- activePageId,
421
- activeData.nodeId,
422
- overData.parentNodeId,
423
- overData.index
424
- )
425
- return
426
- }
427
-
428
- if (activeData.kind === 'primitive') {
429
- if (overData.parentNodeId === null) {
430
- addPrimitiveAt(activePageId, activeData.type, overData.index)
431
- } else {
432
- addPrimitiveToNodeAt(
433
- overData.parentNodeId,
434
- activeData.type,
435
- overData.index
436
- )
437
- }
438
- return
439
- }
440
-
441
- if (activeData.kind === 'sidebar-component') {
442
- if (isGlobalComponent(activeData.type)) {
443
- setGlobalComponent(activeData.type, setAppShellComponent)
444
- } else if (overData.parentNodeId === null) {
445
- addComponentAt(activePageId, activeData.type, overData.index)
446
- } else {
447
- addComponentToNodeAt(
448
- overData.parentNodeId,
449
- activeData.type,
450
- overData.index
451
- )
452
- }
453
- }
454
-
455
- return
456
- }
457
-
458
- if (overData.kind === 'node-dropzone') {
459
- if (activeData.kind === 'canvas-node') {
460
- moveNode(
461
- activePageId,
462
- activeData.nodeId,
463
- overData.nodeId,
464
- Number.POSITIVE_INFINITY
465
- )
466
- return
467
- }
468
-
469
- if (activeData.kind === 'primitive') {
470
- addPrimitiveToNode(overData.nodeId, activeData.type)
471
- return
472
- }
473
-
474
- if (activeData.kind === 'sidebar-component') {
475
- if (isGlobalComponent(activeData.type)) {
476
- setGlobalComponent(activeData.type, setAppShellComponent)
477
- return
478
- }
479
-
480
- addComponentToNode(overData.nodeId, activeData.type)
481
- }
482
-
483
- return
484
- }
485
-
486
- if (overData.kind !== 'canvas-root') {
487
- return
488
- }
489
-
490
- if (activeData.kind === 'canvas-node') {
491
- moveNode(
492
- activePageId,
493
- activeData.nodeId,
494
- null,
495
- Number.POSITIVE_INFINITY
496
- )
497
- return
498
- }
499
-
500
- if (activeData.kind === 'primitive') {
501
- addPrimitiveAt(activePageId, activeData.type, Number.POSITIVE_INFINITY)
502
- return
503
- }
504
-
505
- if (activeData.kind === 'sidebar-component') {
506
- if (isGlobalComponent(activeData.type)) {
507
- setGlobalComponent(activeData.type, setAppShellComponent)
508
- return
509
- }
510
-
511
- addComponentAt(activePageId, activeData.type, Number.POSITIVE_INFINITY)
512
- }
513
- }
514
-
515
- return (
516
- <div className="flex flex-col h-full bg-gray-950 text-gray-100">
517
- <Topbar
518
- showCode={rightPanelOpen && rightPanelTab === 'code'}
519
- previewMode={previewMode}
520
- saveState={saveState}
521
- onToggleCode={handleToggleCode}
522
- onTogglePreview={handleTogglePreview}
523
- onSave={() => {
524
- void handleSave()
525
- }}
526
- />
527
- {reverseSyncNotice && (
528
- <ReverseSyncNotice
529
- notice={reverseSyncNotice}
530
- onDismiss={() => setReverseSyncNotice(null)}
531
- onUseSource={() => {
532
- void handleUseSourceChanges()
533
- }}
534
- />
535
- )}
536
- <DndContext
537
- sensors={sensors}
538
- collisionDetection={canvasCollisionDetection}
539
- onDragEnd={handleDragEnd}
540
- >
541
- <div className="flex flex-1 overflow-hidden">
542
- {!isProjectLoaded ? (
543
- <div className="flex flex-1 items-center justify-center bg-[#eef1f4] text-sm text-gray-500">
544
- Loading project...
545
- </div>
546
- ) : (
547
- <>
548
- {!previewMode && (
549
- <WorkspacePanel
550
- side="left"
551
- open={leftPanelOpen}
552
- activeTab={leftPanelTab}
553
- tabs={[
554
- { id: 'files', label: 'Files' },
555
- { id: 'elements', label: 'Elements' },
556
- ]}
557
- onToggle={() => setLeftPanelOpen((value) => !value)}
558
- onTabChange={(tab) =>
559
- setLeftPanelTab(tab as 'files' | 'elements')
560
- }
561
- >
562
- <ComponentSidebar
563
- view={leftPanelTab}
564
- selectedProjectFile={selectedProjectFile}
565
- onSelectProjectFile={handleSelectProjectFile}
566
- />
567
- </WorkspacePanel>
568
- )}
569
- <div className="flex flex-col flex-1 overflow-hidden">
570
- <div className="flex flex-1 overflow-hidden">
571
- <Canvas />
572
- </div>
573
- {!previewMode && (
574
- <PageSwitcher
575
- onSelectPage={handleSelectPage}
576
- onDeletePage={handleDeletePage}
577
- />
578
- )}
579
- </div>
580
- {!previewMode && (
581
- <WorkspacePanel
582
- side="right"
583
- open={rightPanelOpen}
584
- activeTab={rightPanelTab}
585
- tabs={[
586
- { id: 'code', label: 'Code' },
587
- { id: 'properties', label: 'Properties' },
588
- ]}
589
- onToggle={() => setRightPanelOpen((value) => !value)}
590
- onTabChange={(tab) => {
591
- setRightPanelTab(tab as 'code' | 'properties')
592
- setRightPanelOpen(true)
593
- }}
594
- >
595
- {rightPanelTab === 'code' ? (
596
- <CodePanel
597
- embedded
598
- view={codeView}
599
- filePath={selectedProjectFile}
600
- onViewChange={setCodeView}
601
- onPageRegistered={handleSelectPage}
602
- onWorkspaceSelectFile={handleSelectCodeWorkspaceFile}
603
- />
604
- ) : (
605
- <PropertiesPanel embedded />
606
- )}
607
- </WorkspacePanel>
608
- )}
609
- </>
610
- )}
611
- </div>
612
- </DndContext>
613
- {saveState === 'saving' && <SaveOverlay />}
614
- </div>
615
- )
616
- }
617
-
618
- function replacePathPrefix(
619
- value: string,
620
- previousPath: string,
621
- targetPath: string
622
- ) {
623
- if (
624
- value !== previousPath &&
625
- !value.startsWith(`${previousPath}/`)
626
- ) {
627
- return value
628
- }
629
-
630
- return `${targetPath}${value.slice(previousPath.length)}`
631
- }
632
-
633
- function ReverseSyncNotice({
634
- notice,
635
- onDismiss,
636
- onUseSource,
637
- }: {
638
- notice: ReverseSyncNoticeState
639
- onDismiss: () => void
640
- onUseSource: () => void
641
- }) {
642
- const tone =
643
- notice.status === 'success'
644
- ? 'border-emerald-400/30 bg-emerald-950/95'
645
- : notice.status === 'conflict'
646
- ? 'border-amber-400/30 bg-amber-950/95'
647
- : 'border-red-400/30 bg-red-950/95'
648
-
649
- return (
650
- <div
651
- className={`fixed right-4 top-16 z-[90] w-[390px] max-w-[calc(100vw-2rem)] rounded-md border px-4 py-3 text-white shadow-2xl ${tone}`}
652
- role={notice.status === 'success' ? 'status' : 'alert'}
653
- aria-live="polite"
654
- >
655
- <div className="flex items-start gap-3">
656
- <div className="min-w-0 flex-1">
657
- <p className="text-sm font-semibold">
658
- {notice.type === 'generator-diagnostic'
659
- ? notice.title
660
- : notice.status === 'success'
661
- ? 'Source synced'
662
- : notice.status === 'conflict'
663
- ? 'Choose the version to keep'
664
- : 'Reverse sync paused'}
665
- </p>
666
- <p className="mt-1 text-xs leading-5 text-white/75">
667
- {notice.message}
668
- </p>
669
- <p className="mt-1 truncate font-mono text-[10px] text-white/50">
670
- {notice.path}
671
- </p>
672
- {notice.type === 'generator-diagnostic' &&
673
- notice.suggestion && (
674
- <p className="mt-2 text-xs leading-5 text-white/70">
675
- {notice.suggestion}
676
- </p>
677
- )}
678
- {notice.status === 'conflict' && (
679
- <button
680
- type="button"
681
- onClick={onUseSource}
682
- className="mt-3 cursor-pointer rounded bg-white px-3 py-1.5 text-xs font-semibold text-gray-950 hover:bg-gray-100"
683
- >
684
- Load source changes
685
- </button>
686
- )}
687
- </div>
688
- <button
689
- type="button"
690
- onClick={onDismiss}
691
- aria-label="Dismiss reverse sync notice"
692
- title="Dismiss"
693
- className="flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded text-base text-white/60 hover:bg-white/10 hover:text-white"
694
- >
695
- x
696
- </button>
697
- </div>
698
- </div>
699
- )
700
- }
701
-
702
- function WorkspacePanel({
703
- side,
704
- open,
705
- activeTab,
706
- tabs,
707
- onToggle,
708
- onTabChange,
709
- children,
710
- }: {
711
- side: 'left' | 'right'
712
- open: boolean
713
- activeTab: string
714
- tabs: { id: string; label: string }[]
715
- onToggle: () => void
716
- onTabChange: (tab: string) => void
717
- children: React.ReactNode
718
- }) {
719
- const borderClass = side === 'left' ? 'border-r' : 'border-l'
720
- const collapseLabel = open
721
- ? `Collapse ${side} panel`
722
- : `Expand ${side} panel`
723
- const arrow = side === 'left' ? (open ? '<' : '>') : open ? '>' : '<'
724
-
725
- if (!open) {
726
- return (
727
- <div
728
- className={`flex w-8 shrink-0 items-start justify-center border-white/[0.06] bg-panel pt-2 ${borderClass}`}
729
- >
730
- <button
731
- type="button"
732
- onClick={onToggle}
733
- aria-label={collapseLabel}
734
- title={collapseLabel}
735
- className="flex h-7 w-7 cursor-pointer items-center justify-center rounded text-sm text-gray-400 transition-colors hover:bg-white/[0.08] hover:text-white"
736
- >
737
- {arrow}
738
- </button>
739
- </div>
740
- )
741
- }
742
-
743
- return (
744
- <aside
745
- className={`${side === 'left' ? 'w-64' : 'w-[460px]'} ${borderClass} flex shrink-0 flex-col overflow-hidden border-white/[0.06] bg-panel`}
746
- >
747
- <div className="flex h-10 shrink-0 items-center border-b border-white/[0.06] px-1.5">
748
- <div className="flex min-w-0 flex-1 items-center gap-1">
749
- {tabs.map((tab) => (
750
- <button
751
- key={tab.id}
752
- type="button"
753
- onClick={() => onTabChange(tab.id)}
754
- className={`h-7 cursor-pointer rounded px-2.5 text-[11px] font-medium transition-colors ${
755
- activeTab === tab.id
756
- ? 'bg-white/[0.1] text-white'
757
- : 'text-gray-500 hover:bg-white/[0.05] hover:text-gray-200'
758
- }`}
759
- >
760
- {tab.label}
761
- </button>
762
- ))}
763
- </div>
764
- <button
765
- type="button"
766
- onClick={onToggle}
767
- aria-label={collapseLabel}
768
- title={collapseLabel}
769
- className="flex h-7 w-7 cursor-pointer items-center justify-center rounded text-sm text-gray-400 transition-colors hover:bg-white/[0.08] hover:text-white"
770
- >
771
- {arrow}
772
- </button>
773
- </div>
774
- <div className="min-h-0 flex-1 overflow-hidden">{children}</div>
775
- </aside>
776
- )
777
- }
778
-
779
- function SaveOverlay() {
780
- return (
781
- <div
782
- className="fixed inset-0 z-[100] flex items-center justify-center bg-gray-950/25 px-4 backdrop-blur-sm"
783
- role="status"
784
- aria-live="polite"
785
- aria-label="Saving project"
786
- >
787
- <div className="flex w-[320px] max-w-full items-center gap-3 rounded-lg border border-white/10 bg-[#171a20] px-5 py-4 text-white shadow-2xl">
788
- <span
789
- className="h-5 w-5 shrink-0 animate-spin rounded-full border-2 border-white/20 border-t-blue-400"
790
- aria-hidden="true"
791
- />
792
- <div className="min-w-0">
793
- <p className="text-sm font-medium">Saving project...</p>
794
- <p className="mt-0.5 text-xs text-gray-400">
795
- Updating your React files
796
- </p>
797
- </div>
798
- </div>
799
- </div>
800
- )
801
- }
802
-
803
- function delay(milliseconds: number) {
804
- return new Promise<void>((resolve) => window.setTimeout(resolve, milliseconds))
805
- }
806
-
807
- function isGlobalComponent(type: ComponentType) {
808
- return type === 'Navbar' || type === 'Footer'
809
- }
810
-
811
- function setGlobalComponent(
812
- type: ComponentType,
813
- setAppShellComponent: ReturnType<typeof useAppStore.getState>['setAppShellComponent']
814
- ) {
815
- setAppShellComponent(type === 'Footer' ? 'footer' : 'header', type)
816
- }
817
-
818
- function getDragData(data: unknown): DragData | null {
819
- if (!data || typeof data !== 'object' || !('kind' in data)) {
820
- return null
821
- }
822
-
823
- return data as DragData
824
- }
825
-
826
- const canvasCollisionDetection: CollisionDetection = (args) => {
827
- const activeData = getDragData(args.active.data.current)
828
- const isInvalidSelfTarget = (data: DragData | null) =>
829
- activeData?.kind === 'canvas-node' &&
830
- ((data?.kind === 'node-dropzone' &&
831
- data.nodeId === activeData.nodeId) ||
832
- (data?.kind === 'insertion-zone' &&
833
- data.parentNodeId === activeData.nodeId))
834
- const insertionContainers = args.droppableContainers.filter((container) => {
835
- const data = getDragData(container.data.current)
836
- return data?.kind === 'insertion-zone' && !isInvalidSelfTarget(data)
837
- })
838
- const insertionCollisions = pointerWithin({
839
- ...args,
840
- droppableContainers: insertionContainers,
841
- })
842
-
843
- if (insertionCollisions.length > 0) {
844
- return insertionCollisions
845
- }
846
-
847
- const nodeContainers = args.droppableContainers.filter((container) => {
848
- const data = getDragData(container.data.current)
849
- return data?.kind === 'node-dropzone' && !isInvalidSelfTarget(data)
850
- })
851
- const nodeCollisions = pointerWithin({
852
- ...args,
853
- droppableContainers: nodeContainers,
854
- })
855
-
856
- if (nodeCollisions.length > 0) {
857
- return [...nodeCollisions].sort((first, second) => {
858
- const firstRect = args.droppableRects.get(first.id)
859
- const secondRect = args.droppableRects.get(second.id)
860
- const firstArea = firstRect ? firstRect.width * firstRect.height : Infinity
861
- const secondArea = secondRect
862
- ? secondRect.width * secondRect.height
863
- : Infinity
864
-
865
- return firstArea - secondArea
866
- })
867
- }
868
-
869
- const pointerCollisions = pointerWithin(args)
870
-
871
- return pointerCollisions.length > 0
872
- ? pointerCollisions
873
- : rectIntersection(args)
874
- }
1
+ import {
2
+ DndContext,
3
+ KeyboardSensor,
4
+ PointerSensor,
5
+ pointerWithin,
6
+ rectIntersection,
7
+ useSensor,
8
+ useSensors,
9
+ type CollisionDetection,
10
+ type DragEndEvent,
11
+ } from '@dnd-kit/core'
12
+ import { sortableKeyboardCoordinates } from '@dnd-kit/sortable'
13
+ import { useEffect, useRef, useState } from 'react'
14
+ import Canvas from './components/Canvas'
15
+ import CodePanel, { type CodeView } from './components/CodePanel'
16
+ import ComponentSidebar from './components/ComponentSidebar'
17
+ import DeploymentModal from './components/DeploymentModal'
18
+ import PageSwitcher from './components/PageSwitcher'
19
+ import PropertiesPanel from './components/PropertiesPanel'
20
+ import Topbar, { type SaveState } from './components/Topbar'
21
+ import { useAppStore } from './stores/useAppStore'
22
+ import type {
23
+ ComponentType,
24
+ Page,
25
+ PrimitiveType,
26
+ VisualNode,
27
+ } from './types'
28
+ import { getPageSourcePath } from './utils/codegen'
29
+ import { getGeneratorDiagnostic } from './utils/projectPersistence'
30
+ import {
31
+ createEditorThemeStorageKey,
32
+ normalizeEditorTheme,
33
+ parseStoredEditorTheme,
34
+ resolveEditorTheme,
35
+ } from './theme'
36
+ import type { EditorThemeChoice } from './types'
37
+
38
+ type DragData =
39
+ | { kind: 'sidebar-component'; type: ComponentType }
40
+ | { kind: 'custom-component'; name: string }
41
+ | { kind: 'primitive'; type: PrimitiveType }
42
+ | { kind: 'canvas-node'; nodeId: string }
43
+ | { kind: 'canvas-root' }
44
+ | { kind: 'node-dropzone'; nodeId: string }
45
+ | {
46
+ kind: 'insertion-zone'
47
+ parentNodeId: string | null
48
+ index: number
49
+ }
50
+
51
+ interface ReverseSyncPayload {
52
+ type: 'reverse-sync-success' | 'reverse-sync-error'
53
+ path: string
54
+ pageId?: string
55
+ message: string
56
+ }
57
+
58
+ interface GeneratorDiagnosticPayload {
59
+ type: 'generator-diagnostic'
60
+ severity: 'error'
61
+ phase: 'generate'
62
+ code: string
63
+ title: string
64
+ message: string
65
+ path: string
66
+ suggestion?: string
67
+ }
68
+
69
+ interface FileSystemPayload {
70
+ type: 'filesystem-change'
71
+ path: string
72
+ action: 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir'
73
+ }
74
+
75
+ interface ManagedPagesDeletedPayload {
76
+ type: 'managed-pages-deleted'
77
+ path: string
78
+ pageIds: string[]
79
+ message: string
80
+ }
81
+
82
+ interface ManagedPagesMovedPayload {
83
+ type: 'managed-pages-moved'
84
+ path: string
85
+ targetPath: string
86
+ pages: Array<{
87
+ pageId: string
88
+ previousPath: string
89
+ sourcePath: string
90
+ }>
91
+ message: string
92
+ }
93
+
94
+ type VisualBuildPayload =
95
+ | ReverseSyncPayload
96
+ | GeneratorDiagnosticPayload
97
+ | FileSystemPayload
98
+ | ManagedPagesDeletedPayload
99
+ | ManagedPagesMovedPayload
100
+
101
+ type ReverseSyncNoticeState =
102
+ | (ReverseSyncPayload & {
103
+ status: 'success' | 'error' | 'conflict'
104
+ })
105
+ | (GeneratorDiagnosticPayload & {
106
+ status: 'error'
107
+ })
108
+
109
+ export default function App() {
110
+ const [leftPanelOpen, setLeftPanelOpen] = useState(true)
111
+ const [leftPanelTab, setLeftPanelTab] =
112
+ useState<'files' | 'elements'>('elements')
113
+ const [rightPanelOpen, setRightPanelOpen] = useState(true)
114
+ const [rightPanelTab, setRightPanelTab] = useState<'code' | 'properties'>('code')
115
+ const [codeView, setCodeView] = useState<CodeView>('page')
116
+ const [selectedProjectFile, setSelectedProjectFile] = useState<string | null>(
117
+ null
118
+ )
119
+ const [saveState, setSaveState] = useState<SaveState>('idle')
120
+ const [deploymentOpen, setDeploymentOpen] = useState(false)
121
+ const [editorTheme, setEditorTheme] =
122
+ useState<EditorThemeChoice>('default')
123
+ const [reverseSyncNotice, setReverseSyncNotice] =
124
+ useState<ReverseSyncNoticeState | null>(null)
125
+ const saveResetTimeout = useRef<number | null>(null)
126
+ const preferencesApplied = useRef(false)
127
+ const themePreferenceApplied = useRef(false)
128
+ const {
129
+ projectName,
130
+ appShell,
131
+ pages,
132
+ activePageId,
133
+ selectedComponentId,
134
+ customComponents,
135
+ loadProject,
136
+ refreshCustomComponents,
137
+ refreshProjectFromDisk,
138
+ isProjectLoaded,
139
+ projectConfig,
140
+ previewMode,
141
+ togglePreviewMode,
142
+ saveProject,
143
+ addComponentAt,
144
+ addComponentToNode,
145
+ addComponentToNodeAt,
146
+ addCustomComponentAt,
147
+ addCustomComponentToNode,
148
+ addCustomComponentToNodeAt,
149
+ addPrimitiveAt,
150
+ addPrimitiveToNode,
151
+ addPrimitiveToNodeAt,
152
+ moveNode,
153
+ setActivePage,
154
+ setAppShellComponent,
155
+ } = useAppStore()
156
+ const sensors = useSensors(
157
+ useSensor(PointerSensor, {
158
+ activationConstraint: {
159
+ distance: 6,
160
+ },
161
+ }),
162
+ useSensor(KeyboardSensor, {
163
+ coordinateGetter: sortableKeyboardCoordinates,
164
+ })
165
+ )
166
+ const activePage = pages.find((page) => page.id === activePageId)
167
+
168
+ useEffect(() => {
169
+ if (!isProjectLoaded || preferencesApplied.current) return
170
+
171
+ preferencesApplied.current = true
172
+ const panels = projectConfig.editor.panels
173
+ setLeftPanelOpen(panels.leftOpen)
174
+ setLeftPanelTab(panels.leftTab)
175
+ setRightPanelOpen(panels.rightOpen)
176
+ setRightPanelTab(panels.rightTab)
177
+ setCodeView(panels.codeView)
178
+ }, [isProjectLoaded, projectConfig])
179
+
180
+ useEffect(() => {
181
+ if (!isProjectLoaded || themePreferenceApplied.current) return
182
+
183
+ themePreferenceApplied.current = true
184
+ const storageKey = createEditorThemeStorageKey(projectName)
185
+ const storedTheme = parseStoredEditorTheme(
186
+ window.localStorage.getItem(storageKey)
187
+ )
188
+ setEditorTheme(
189
+ storedTheme ?? normalizeEditorTheme(projectConfig.editor.theme)
190
+ )
191
+ }, [isProjectLoaded, projectConfig.editor.theme, projectName])
192
+
193
+ useEffect(() => {
194
+ const applyTheme = () => {
195
+ document.documentElement.dataset.visualbuildTheme =
196
+ resolveEditorTheme(
197
+ editorTheme,
198
+ window.matchMedia('(prefers-color-scheme: light)').matches
199
+ )
200
+ }
201
+
202
+ applyTheme()
203
+ if (editorTheme !== 'system') return
204
+
205
+ const media = window.matchMedia('(prefers-color-scheme: light)')
206
+ media.addEventListener('change', applyTheme)
207
+ return () => media.removeEventListener('change', applyTheme)
208
+ }, [editorTheme])
209
+
210
+ const handleThemeChange = (theme: EditorThemeChoice) => {
211
+ setEditorTheme(theme)
212
+ window.localStorage.setItem(
213
+ createEditorThemeStorageKey(projectName),
214
+ theme
215
+ )
216
+ }
217
+
218
+ useEffect(() => {
219
+ if (!selectedComponentId || !activePage) return
220
+
221
+ const selectedNode = findVisualNodeById(
222
+ [
223
+ ...(appShell.header ? [appShell.header] : []),
224
+ activePage.root,
225
+ ...activePage.components,
226
+ ...(appShell.footer ? [appShell.footer] : []),
227
+ ],
228
+ selectedComponentId
229
+ )
230
+ const customOwner = findCustomOwnerForNode(
231
+ [
232
+ ...(appShell.header ? [appShell.header] : []),
233
+ ...activePage.components,
234
+ ...(appShell.footer ? [appShell.footer] : []),
235
+ ],
236
+ selectedComponentId,
237
+ customComponents
238
+ )
239
+ const customComponent = customComponents.find(
240
+ (component) =>
241
+ component.name === (customOwner?.type ?? selectedNode?.type)
242
+ )
243
+ const timer = window.setTimeout(() => {
244
+ setSelectedProjectFile(
245
+ customComponent?.importPath ?? getPageSourcePath(activePage)
246
+ )
247
+ setCodeView('file')
248
+ }, 0)
249
+
250
+ return () => window.clearTimeout(timer)
251
+ }, [
252
+ activePage,
253
+ appShell,
254
+ customComponents,
255
+ selectedComponentId,
256
+ ])
257
+
258
+ useEffect(() => {
259
+ void loadProject()
260
+ }, [loadProject])
261
+
262
+ useEffect(() => {
263
+ const eventSource = new EventSource('/__visualbuild/events')
264
+ const handleReverseSync = (event: MessageEvent<string>) => {
265
+ let payload: VisualBuildPayload
266
+
267
+ try {
268
+ payload = JSON.parse(event.data) as VisualBuildPayload
269
+ } catch {
270
+ return
271
+ }
272
+
273
+ if (payload.type === 'filesystem-change') {
274
+ window.dispatchEvent(
275
+ new CustomEvent('visualbuild:filesystem-changed', {
276
+ detail: payload,
277
+ })
278
+ )
279
+ if (/\.(jsx|tsx)$/i.test(payload.path)) {
280
+ void refreshCustomComponents()
281
+ }
282
+ return
283
+ }
284
+
285
+ if (payload.type === 'managed-pages-deleted') {
286
+ setSelectedProjectFile((currentPath) => {
287
+ if (
288
+ currentPath === payload.path ||
289
+ currentPath?.startsWith(`${payload.path}/`)
290
+ ) {
291
+ return null
292
+ }
293
+
294
+ return currentPath
295
+ })
296
+ setCodeView('page')
297
+ setReverseSyncNotice(null)
298
+ void refreshProjectFromDisk(true).then((result) => {
299
+ window.dispatchEvent(new Event('visualbuild:filesystem-changed'))
300
+
301
+ if (result !== 'updated') {
302
+ setReverseSyncNotice({
303
+ type: 'reverse-sync-error',
304
+ path: payload.path,
305
+ message: 'The page was deleted, but the editor could not refresh.',
306
+ status: 'error',
307
+ })
308
+ return
309
+ }
310
+
311
+ const nextActivePage = useAppStore
312
+ .getState()
313
+ .pages.find(
314
+ (page) => page.id === useAppStore.getState().activePageId
315
+ )
316
+
317
+ if (nextActivePage) {
318
+ window.history.replaceState({}, '', nextActivePage.route)
319
+ }
320
+ })
321
+ return
322
+ }
323
+
324
+ if (payload.type === 'managed-pages-moved') {
325
+ setSelectedProjectFile((currentPath) =>
326
+ currentPath
327
+ ? replacePathPrefix(
328
+ currentPath,
329
+ payload.path,
330
+ payload.targetPath
331
+ )
332
+ : null
333
+ )
334
+ setReverseSyncNotice(null)
335
+ void refreshProjectFromDisk(true).then((result) => {
336
+ window.dispatchEvent(new Event('visualbuild:filesystem-changed'))
337
+
338
+ if (result !== 'updated') {
339
+ setReverseSyncNotice({
340
+ type: 'reverse-sync-error',
341
+ path: payload.targetPath,
342
+ message:
343
+ 'The page source moved, but the editor could not refresh.',
344
+ status: 'error',
345
+ })
346
+ }
347
+ })
348
+ return
349
+ }
350
+
351
+ if (payload.type === 'generator-diagnostic') {
352
+ setReverseSyncNotice({ ...payload, status: 'error' })
353
+ return
354
+ }
355
+
356
+ if (payload.type === 'reverse-sync-error') {
357
+ setReverseSyncNotice({ ...payload, status: 'error' })
358
+ return
359
+ }
360
+
361
+ void refreshProjectFromDisk().then((result) => {
362
+ if (result === 'updated') {
363
+ window.dispatchEvent(
364
+ new Event('visualbuild:filesystem-changed')
365
+ )
366
+ setReverseSyncNotice({ ...payload, status: 'success' })
367
+ return
368
+ }
369
+
370
+ setReverseSyncNotice({
371
+ ...payload,
372
+ status: result === 'skipped-dirty' ? 'conflict' : 'error',
373
+ message:
374
+ result === 'skipped-dirty'
375
+ ? 'Source changed while the canvas has unsaved edits.'
376
+ : 'The source changed, but the canvas could not refresh.',
377
+ })
378
+ })
379
+ }
380
+
381
+ eventSource.addEventListener(
382
+ 'visualbuild',
383
+ handleReverseSync as EventListener
384
+ )
385
+
386
+ return () => eventSource.close()
387
+ }, [refreshCustomComponents, refreshProjectFromDisk])
388
+
389
+ useEffect(
390
+ () => () => {
391
+ if (saveResetTimeout.current !== null) {
392
+ window.clearTimeout(saveResetTimeout.current)
393
+ }
394
+ },
395
+ []
396
+ )
397
+
398
+ const handleToggleCode = () => {
399
+ if (previewMode) {
400
+ togglePreviewMode()
401
+ setRightPanelTab('code')
402
+ setRightPanelOpen(true)
403
+ return
404
+ }
405
+
406
+ if (rightPanelOpen && rightPanelTab === 'code') {
407
+ setRightPanelOpen(false)
408
+ return
409
+ }
410
+
411
+ setRightPanelTab('code')
412
+ setRightPanelOpen(true)
413
+ }
414
+
415
+ const handleTogglePreview = () => {
416
+ if (!previewMode && activePage) {
417
+ window.history.replaceState({}, '', activePage.route)
418
+ }
419
+
420
+ togglePreviewMode()
421
+ }
422
+
423
+ const handleSelectProjectFile = (path: string | null) => {
424
+ setSelectedProjectFile(path)
425
+
426
+ if (path) {
427
+ const normalizedPath = path.replace(/\\/g, '/')
428
+ const managedPage = pages.find(
429
+ (page) => getPageSourcePath(page) === normalizedPath
430
+ )
431
+
432
+ if (managedPage) {
433
+ setActivePage(managedPage.id)
434
+ }
435
+ setCodeView('file')
436
+ setRightPanelTab('code')
437
+ setRightPanelOpen(true)
438
+ } else if (codeView === 'file') {
439
+ setCodeView('page')
440
+ }
441
+ }
442
+
443
+ const handleSelectCodeWorkspaceFile = (path: string) => {
444
+ const normalizedPath = path.replace(/\\/g, '/')
445
+ const managedPage = pages.find(
446
+ (page) => getPageSourcePath(page) === normalizedPath
447
+ )
448
+
449
+ setSelectedProjectFile(path)
450
+ if (managedPage) {
451
+ setActivePage(managedPage.id)
452
+ }
453
+ setCodeView('file')
454
+ }
455
+
456
+ const handleSelectPage = (page: Page) => {
457
+ setSelectedProjectFile(getPageSourcePath(page))
458
+ setCodeView('file')
459
+ setRightPanelTab('code')
460
+ setRightPanelOpen(true)
461
+ }
462
+
463
+ const handleDeletePage = (page: Page) => {
464
+ if (selectedProjectFile === getPageSourcePath(page)) {
465
+ setSelectedProjectFile(null)
466
+ setCodeView('page')
467
+ }
468
+ }
469
+
470
+ const handleSave = async () => {
471
+ if (saveState === 'saving') {
472
+ return false
473
+ }
474
+
475
+ if (saveResetTimeout.current !== null) {
476
+ window.clearTimeout(saveResetTimeout.current)
477
+ }
478
+
479
+ setSaveState('saving')
480
+ const minimumOverlayTime = delay(450)
481
+
482
+ try {
483
+ await saveProject()
484
+ await minimumOverlayTime
485
+ setSaveState('saved')
486
+ saveResetTimeout.current = window.setTimeout(
487
+ () => setSaveState('idle'),
488
+ 1200
489
+ )
490
+ return true
491
+ } catch (error) {
492
+ await minimumOverlayTime
493
+ console.error('Failed to save VisualBuild project', error)
494
+ const diagnostic = getGeneratorDiagnostic(error)
495
+
496
+ if (diagnostic) {
497
+ setReverseSyncNotice({
498
+ type: 'generator-diagnostic',
499
+ ...diagnostic,
500
+ status: 'error',
501
+ })
502
+ }
503
+
504
+ setSaveState('error')
505
+ saveResetTimeout.current = window.setTimeout(
506
+ () => setSaveState('idle'),
507
+ 2400
508
+ )
509
+ return false
510
+ }
511
+ }
512
+
513
+ const handleUseSourceChanges = async () => {
514
+ const result = await refreshProjectFromDisk(true)
515
+
516
+ if (result === 'updated') {
517
+ window.dispatchEvent(new Event('visualbuild:filesystem-changed'))
518
+ setReverseSyncNotice((notice) =>
519
+ notice && notice.type !== 'generator-diagnostic'
520
+ ? {
521
+ ...notice,
522
+ status: 'success',
523
+ message: 'Loaded the source version into the canvas.',
524
+ }
525
+ : null
526
+ )
527
+ }
528
+ }
529
+
530
+ const handleDragEnd = (event: DragEndEvent) => {
531
+ const activeData = getDragData(event.active.data.current)
532
+ const overData = getDragData(event.over?.data.current)
533
+
534
+ if (!activeData || !overData || !activePage) {
535
+ return
536
+ }
537
+
538
+ if (overData.kind === 'insertion-zone') {
539
+ if (activeData.kind === 'canvas-node') {
540
+ moveNode(
541
+ activePageId,
542
+ activeData.nodeId,
543
+ overData.parentNodeId,
544
+ overData.index
545
+ )
546
+ return
547
+ }
548
+
549
+ if (activeData.kind === 'primitive') {
550
+ if (overData.parentNodeId === null) {
551
+ addPrimitiveAt(activePageId, activeData.type, overData.index)
552
+ } else {
553
+ addPrimitiveToNodeAt(
554
+ overData.parentNodeId,
555
+ activeData.type,
556
+ overData.index
557
+ )
558
+ }
559
+ return
560
+ }
561
+
562
+ if (activeData.kind === 'sidebar-component') {
563
+ if (isGlobalComponent(activeData.type)) {
564
+ setGlobalComponent(activeData.type, setAppShellComponent)
565
+ } else if (overData.parentNodeId === null) {
566
+ addComponentAt(activePageId, activeData.type, overData.index)
567
+ } else {
568
+ addComponentToNodeAt(
569
+ overData.parentNodeId,
570
+ activeData.type,
571
+ overData.index
572
+ )
573
+ }
574
+ }
575
+
576
+ if (activeData.kind === 'custom-component') {
577
+ if (overData.parentNodeId === null) {
578
+ addCustomComponentAt(activePageId, activeData.name, overData.index)
579
+ } else {
580
+ addCustomComponentToNodeAt(
581
+ overData.parentNodeId,
582
+ activeData.name,
583
+ overData.index
584
+ )
585
+ }
586
+ }
587
+
588
+ return
589
+ }
590
+
591
+ if (overData.kind === 'node-dropzone') {
592
+ if (activeData.kind === 'canvas-node') {
593
+ moveNode(
594
+ activePageId,
595
+ activeData.nodeId,
596
+ overData.nodeId,
597
+ Number.POSITIVE_INFINITY
598
+ )
599
+ return
600
+ }
601
+
602
+ if (activeData.kind === 'primitive') {
603
+ addPrimitiveToNode(overData.nodeId, activeData.type)
604
+ return
605
+ }
606
+
607
+ if (activeData.kind === 'sidebar-component') {
608
+ if (isGlobalComponent(activeData.type)) {
609
+ setGlobalComponent(activeData.type, setAppShellComponent)
610
+ return
611
+ }
612
+
613
+ addComponentToNode(overData.nodeId, activeData.type)
614
+ }
615
+
616
+ if (activeData.kind === 'custom-component') {
617
+ addCustomComponentToNode(overData.nodeId, activeData.name)
618
+ }
619
+
620
+ return
621
+ }
622
+
623
+ if (overData.kind !== 'canvas-root') {
624
+ return
625
+ }
626
+
627
+ if (activeData.kind === 'canvas-node') {
628
+ moveNode(
629
+ activePageId,
630
+ activeData.nodeId,
631
+ null,
632
+ Number.POSITIVE_INFINITY
633
+ )
634
+ return
635
+ }
636
+
637
+ if (activeData.kind === 'primitive') {
638
+ addPrimitiveAt(activePageId, activeData.type, Number.POSITIVE_INFINITY)
639
+ return
640
+ }
641
+
642
+ if (activeData.kind === 'sidebar-component') {
643
+ if (isGlobalComponent(activeData.type)) {
644
+ setGlobalComponent(activeData.type, setAppShellComponent)
645
+ return
646
+ }
647
+
648
+ addComponentAt(activePageId, activeData.type, Number.POSITIVE_INFINITY)
649
+ }
650
+
651
+ if (activeData.kind === 'custom-component') {
652
+ addCustomComponentAt(
653
+ activePageId,
654
+ activeData.name,
655
+ Number.POSITIVE_INFINITY
656
+ )
657
+ }
658
+ }
659
+
660
+ return (
661
+ <div className="vb-editor-shell flex flex-col h-full bg-gray-950 text-gray-100">
662
+ <Topbar
663
+ showCode={rightPanelOpen && rightPanelTab === 'code'}
664
+ previewMode={previewMode}
665
+ saveState={saveState}
666
+ editorTheme={editorTheme}
667
+ onToggleCode={handleToggleCode}
668
+ onTogglePreview={handleTogglePreview}
669
+ onSave={() => {
670
+ void handleSave()
671
+ }}
672
+ onDeploy={() => setDeploymentOpen(true)}
673
+ onThemeChange={handleThemeChange}
674
+ />
675
+ <DeploymentModal
676
+ open={deploymentOpen}
677
+ onClose={() => setDeploymentOpen(false)}
678
+ onBeforeDeploy={handleSave}
679
+ />
680
+ {reverseSyncNotice && (
681
+ <ReverseSyncNotice
682
+ notice={reverseSyncNotice}
683
+ onDismiss={() => setReverseSyncNotice(null)}
684
+ onUseSource={() => {
685
+ void handleUseSourceChanges()
686
+ }}
687
+ />
688
+ )}
689
+ <DndContext
690
+ sensors={sensors}
691
+ collisionDetection={canvasCollisionDetection}
692
+ onDragEnd={handleDragEnd}
693
+ >
694
+ <div className="flex flex-1 overflow-hidden">
695
+ {!isProjectLoaded ? (
696
+ <div data-vb-editor-chrome className="flex flex-1 items-center justify-center bg-[#eef1f4] text-sm text-gray-500">
697
+ Loading project...
698
+ </div>
699
+ ) : (
700
+ <>
701
+ {!previewMode && (
702
+ <WorkspacePanel
703
+ side="left"
704
+ open={leftPanelOpen}
705
+ activeTab={leftPanelTab}
706
+ tabs={[
707
+ { id: 'files', label: 'Files' },
708
+ { id: 'elements', label: 'Elements' },
709
+ ]}
710
+ onToggle={() => setLeftPanelOpen((value) => !value)}
711
+ onTabChange={(tab) =>
712
+ setLeftPanelTab(tab as 'files' | 'elements')
713
+ }
714
+ >
715
+ <ComponentSidebar
716
+ view={leftPanelTab}
717
+ selectedProjectFile={selectedProjectFile}
718
+ onSelectProjectFile={handleSelectProjectFile}
719
+ />
720
+ </WorkspacePanel>
721
+ )}
722
+ <div className="flex flex-col flex-1 overflow-hidden">
723
+ <div className="flex flex-1 overflow-hidden">
724
+ <Canvas />
725
+ </div>
726
+ {!previewMode && (
727
+ <PageSwitcher
728
+ onSelectPage={handleSelectPage}
729
+ onDeletePage={handleDeletePage}
730
+ />
731
+ )}
732
+ </div>
733
+ {!previewMode && (
734
+ <WorkspacePanel
735
+ side="right"
736
+ open={rightPanelOpen}
737
+ activeTab={rightPanelTab}
738
+ tabs={[
739
+ { id: 'code', label: 'Code' },
740
+ { id: 'properties', label: 'Properties' },
741
+ ]}
742
+ onToggle={() => setRightPanelOpen((value) => !value)}
743
+ onTabChange={(tab) => {
744
+ setRightPanelTab(tab as 'code' | 'properties')
745
+ setRightPanelOpen(true)
746
+ }}
747
+ >
748
+ {rightPanelTab === 'code' ? (
749
+ <CodePanel
750
+ embedded
751
+ view={codeView}
752
+ filePath={selectedProjectFile}
753
+ onViewChange={setCodeView}
754
+ onPageRegistered={handleSelectPage}
755
+ onWorkspaceSelectFile={handleSelectCodeWorkspaceFile}
756
+ />
757
+ ) : (
758
+ <PropertiesPanel embedded />
759
+ )}
760
+ </WorkspacePanel>
761
+ )}
762
+ </>
763
+ )}
764
+ </div>
765
+ </DndContext>
766
+ {saveState === 'saving' && <SaveOverlay />}
767
+ </div>
768
+ )
769
+ }
770
+
771
+ function replacePathPrefix(
772
+ value: string,
773
+ previousPath: string,
774
+ targetPath: string
775
+ ) {
776
+ if (
777
+ value !== previousPath &&
778
+ !value.startsWith(`${previousPath}/`)
779
+ ) {
780
+ return value
781
+ }
782
+
783
+ return `${targetPath}${value.slice(previousPath.length)}`
784
+ }
785
+
786
+ function ReverseSyncNotice({
787
+ notice,
788
+ onDismiss,
789
+ onUseSource,
790
+ }: {
791
+ notice: ReverseSyncNoticeState
792
+ onDismiss: () => void
793
+ onUseSource: () => void
794
+ }) {
795
+ const tone =
796
+ notice.status === 'success'
797
+ ? 'border-emerald-400/30 bg-emerald-950/95'
798
+ : notice.status === 'conflict'
799
+ ? 'border-amber-400/30 bg-amber-950/95'
800
+ : 'border-red-400/30 bg-red-950/95'
801
+
802
+ return (
803
+ <div
804
+ className={`fixed right-4 top-16 z-[90] w-[390px] max-w-[calc(100vw-2rem)] rounded-md border px-4 py-3 text-white shadow-2xl ${tone}`}
805
+ role={notice.status === 'success' ? 'status' : 'alert'}
806
+ aria-live="polite"
807
+ >
808
+ <div className="flex items-start gap-3">
809
+ <div className="min-w-0 flex-1">
810
+ <p className="text-sm font-semibold">
811
+ {notice.type === 'generator-diagnostic'
812
+ ? notice.title
813
+ : notice.status === 'success'
814
+ ? 'Source synced'
815
+ : notice.status === 'conflict'
816
+ ? 'Choose the version to keep'
817
+ : 'Reverse sync paused'}
818
+ </p>
819
+ <p className="mt-1 text-xs leading-5 text-white/75">
820
+ {notice.message}
821
+ </p>
822
+ <p className="mt-1 truncate font-mono text-[10px] text-white/50">
823
+ {notice.path}
824
+ </p>
825
+ {notice.type === 'generator-diagnostic' &&
826
+ notice.suggestion && (
827
+ <p className="mt-2 text-xs leading-5 text-white/70">
828
+ {notice.suggestion}
829
+ </p>
830
+ )}
831
+ {notice.status === 'conflict' && (
832
+ <button
833
+ type="button"
834
+ onClick={onUseSource}
835
+ className="mt-3 cursor-pointer rounded bg-white px-3 py-1.5 text-xs font-semibold text-gray-950 hover:bg-gray-100"
836
+ >
837
+ Load source changes
838
+ </button>
839
+ )}
840
+ </div>
841
+ <button
842
+ type="button"
843
+ onClick={onDismiss}
844
+ aria-label="Dismiss reverse sync notice"
845
+ title="Dismiss"
846
+ className="flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded text-base text-white/60 hover:bg-white/10 hover:text-white"
847
+ >
848
+ x
849
+ </button>
850
+ </div>
851
+ </div>
852
+ )
853
+ }
854
+
855
+ function WorkspacePanel({
856
+ side,
857
+ open,
858
+ activeTab,
859
+ tabs,
860
+ onToggle,
861
+ onTabChange,
862
+ children,
863
+ }: {
864
+ side: 'left' | 'right'
865
+ open: boolean
866
+ activeTab: string
867
+ tabs: { id: string; label: string }[]
868
+ onToggle: () => void
869
+ onTabChange: (tab: string) => void
870
+ children: React.ReactNode
871
+ }) {
872
+ const borderClass = side === 'left' ? 'border-r' : 'border-l'
873
+ const collapseLabel = open
874
+ ? `Collapse ${side} panel`
875
+ : `Expand ${side} panel`
876
+ const arrow = side === 'left' ? (open ? '<' : '>') : open ? '>' : '<'
877
+
878
+ if (!open) {
879
+ return (
880
+ <div
881
+ data-vb-editor-chrome
882
+ className={`flex w-8 shrink-0 items-start justify-center border-white/[0.06] bg-panel pt-2 ${borderClass}`}
883
+ >
884
+ <button
885
+ type="button"
886
+ onClick={onToggle}
887
+ aria-label={collapseLabel}
888
+ title={collapseLabel}
889
+ className="flex h-7 w-7 cursor-pointer items-center justify-center rounded text-sm text-gray-400 transition-colors hover:bg-white/[0.08] hover:text-white"
890
+ >
891
+ {arrow}
892
+ </button>
893
+ </div>
894
+ )
895
+ }
896
+
897
+ return (
898
+ <aside
899
+ data-vb-editor-chrome
900
+ className={`${side === 'left' ? 'w-64' : 'w-[460px]'} ${borderClass} flex shrink-0 flex-col overflow-hidden border-white/[0.06] bg-panel`}
901
+ >
902
+ <div className="flex h-10 shrink-0 items-center border-b border-white/[0.06] px-1.5">
903
+ <div className="flex min-w-0 flex-1 items-center gap-1">
904
+ {tabs.map((tab) => (
905
+ <button
906
+ key={tab.id}
907
+ type="button"
908
+ onClick={() => onTabChange(tab.id)}
909
+ className={`h-7 cursor-pointer rounded px-2.5 text-[11px] font-medium transition-colors ${
910
+ activeTab === tab.id
911
+ ? 'bg-white/[0.1] text-white'
912
+ : 'text-gray-500 hover:bg-white/[0.05] hover:text-gray-200'
913
+ }`}
914
+ >
915
+ {tab.label}
916
+ </button>
917
+ ))}
918
+ </div>
919
+ <button
920
+ type="button"
921
+ onClick={onToggle}
922
+ aria-label={collapseLabel}
923
+ title={collapseLabel}
924
+ className="flex h-7 w-7 cursor-pointer items-center justify-center rounded text-sm text-gray-400 transition-colors hover:bg-white/[0.08] hover:text-white"
925
+ >
926
+ {arrow}
927
+ </button>
928
+ </div>
929
+ <div className="min-h-0 flex-1 overflow-hidden">{children}</div>
930
+ </aside>
931
+ )
932
+ }
933
+
934
+ function SaveOverlay() {
935
+ return (
936
+ <div
937
+ data-vb-editor-chrome
938
+ className="fixed inset-0 z-[100] flex items-center justify-center bg-gray-950/25 px-4 backdrop-blur-sm"
939
+ role="status"
940
+ aria-live="polite"
941
+ aria-label="Saving project"
942
+ >
943
+ <div className="flex w-[320px] max-w-full items-center gap-3 rounded-lg border border-white/10 bg-[#171a20] px-5 py-4 text-white shadow-2xl">
944
+ <span
945
+ className="h-5 w-5 shrink-0 animate-spin rounded-full border-2 border-white/20 border-t-blue-400"
946
+ aria-hidden="true"
947
+ />
948
+ <div className="min-w-0">
949
+ <p className="text-sm font-medium">Saving project...</p>
950
+ <p className="mt-0.5 text-xs text-gray-400">
951
+ Updating your React files
952
+ </p>
953
+ </div>
954
+ </div>
955
+ </div>
956
+ )
957
+ }
958
+
959
+ function delay(milliseconds: number) {
960
+ return new Promise<void>((resolve) => window.setTimeout(resolve, milliseconds))
961
+ }
962
+
963
+ function findVisualNodeById(
964
+ nodes: VisualNode[],
965
+ nodeId: string
966
+ ): VisualNode | null {
967
+ for (const node of nodes) {
968
+ if (node.id === nodeId) return node
969
+ const child = findVisualNodeById(node.children, nodeId)
970
+ if (child) return child
971
+ const componentTree = node.props.componentTree
972
+ if (
973
+ typeof componentTree === 'object' &&
974
+ componentTree !== null &&
975
+ Array.isArray((componentTree as { children?: unknown }).children)
976
+ ) {
977
+ const definitionChild = findVisualNodeById(
978
+ (componentTree as { children: VisualNode[] }).children,
979
+ nodeId
980
+ )
981
+ if (definitionChild) return definitionChild
982
+ }
983
+ }
984
+
985
+ return null
986
+ }
987
+
988
+ function findCustomOwnerForNode(
989
+ nodes: VisualNode[],
990
+ nodeId: string,
991
+ definitions: { name: string }[]
992
+ ) {
993
+ const names = new Set(definitions.map((definition) => definition.name))
994
+
995
+ for (const node of nodes) {
996
+ if (names.has(node.type)) {
997
+ const tree = node.props.componentTree
998
+ const children =
999
+ typeof tree === 'object' &&
1000
+ tree !== null &&
1001
+ Array.isArray((tree as { children?: unknown }).children)
1002
+ ? (tree as { children: VisualNode[] }).children
1003
+ : []
1004
+ if (
1005
+ node.id === nodeId ||
1006
+ findVisualNodeById(children, nodeId)
1007
+ ) {
1008
+ return node
1009
+ }
1010
+ }
1011
+
1012
+ const nestedOwner = findCustomOwnerForNode(
1013
+ node.children,
1014
+ nodeId,
1015
+ definitions
1016
+ )
1017
+ if (nestedOwner) return nestedOwner
1018
+ }
1019
+
1020
+ return null
1021
+ }
1022
+
1023
+ function isGlobalComponent(type: ComponentType) {
1024
+ return type === 'Navbar' || type === 'Footer'
1025
+ }
1026
+
1027
+ function setGlobalComponent(
1028
+ type: ComponentType,
1029
+ setAppShellComponent: ReturnType<typeof useAppStore.getState>['setAppShellComponent']
1030
+ ) {
1031
+ setAppShellComponent(type === 'Footer' ? 'footer' : 'header', type)
1032
+ }
1033
+
1034
+ function getDragData(data: unknown): DragData | null {
1035
+ if (!data || typeof data !== 'object' || !('kind' in data)) {
1036
+ return null
1037
+ }
1038
+
1039
+ return data as DragData
1040
+ }
1041
+
1042
+ const canvasCollisionDetection: CollisionDetection = (args) => {
1043
+ const activeData = getDragData(args.active.data.current)
1044
+ const isInvalidSelfTarget = (data: DragData | null) =>
1045
+ activeData?.kind === 'canvas-node' &&
1046
+ ((data?.kind === 'node-dropzone' &&
1047
+ data.nodeId === activeData.nodeId) ||
1048
+ (data?.kind === 'insertion-zone' &&
1049
+ data.parentNodeId === activeData.nodeId))
1050
+ const insertionContainers = args.droppableContainers.filter((container) => {
1051
+ const data = getDragData(container.data.current)
1052
+ return data?.kind === 'insertion-zone' && !isInvalidSelfTarget(data)
1053
+ })
1054
+ const insertionCollisions = pointerWithin({
1055
+ ...args,
1056
+ droppableContainers: insertionContainers,
1057
+ })
1058
+
1059
+ if (insertionCollisions.length > 0) {
1060
+ return insertionCollisions
1061
+ }
1062
+
1063
+ const nodeContainers = args.droppableContainers.filter((container) => {
1064
+ const data = getDragData(container.data.current)
1065
+ return data?.kind === 'node-dropzone' && !isInvalidSelfTarget(data)
1066
+ })
1067
+ const nodeCollisions = pointerWithin({
1068
+ ...args,
1069
+ droppableContainers: nodeContainers,
1070
+ })
1071
+
1072
+ if (nodeCollisions.length > 0) {
1073
+ return [...nodeCollisions].sort((first, second) => {
1074
+ const firstRect = args.droppableRects.get(first.id)
1075
+ const secondRect = args.droppableRects.get(second.id)
1076
+ const firstArea = firstRect ? firstRect.width * firstRect.height : Infinity
1077
+ const secondArea = secondRect
1078
+ ? secondRect.width * secondRect.height
1079
+ : Infinity
1080
+
1081
+ return firstArea - secondArea
1082
+ })
1083
+ }
1084
+
1085
+ const pointerCollisions = pointerWithin(args)
1086
+
1087
+ return pointerCollisions.length > 0
1088
+ ? pointerCollisions
1089
+ : rectIntersection(args)
1090
+ }