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,812 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { useAppStore } from '../stores/useAppStore'
4
+ import {
5
+ getPageSourcePath,
6
+ generateAppCode,
7
+ generateLayoutCode,
8
+ generatePageCode,
9
+ generateProjectSchema,
10
+ } from '../utils/codegen'
11
+ import { importProjectPage } from '../utils/projectPersistence'
12
+ import {
13
+ loadProjectFile,
14
+ saveProjectFile,
15
+ type ProjectFile,
16
+ } from '../utils/projectFiles'
17
+ import type { Page } from '../types'
18
+ import ProjectExplorer from './ProjectExplorer'
19
+
20
+ export type CodeView = 'page' | 'app' | 'layout' | 'schema' | 'file'
21
+
22
+ interface FileSystemChangeDetail {
23
+ type: 'filesystem-change'
24
+ path: string
25
+ action: 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir'
26
+ }
27
+
28
+ const externalMoveMatchWindowMs = 1600
29
+
30
+ function getProjectFileName(path: string) {
31
+ return path.split('/').pop() ?? path
32
+ }
33
+
34
+ export default function CodePanel({
35
+ view,
36
+ onViewChange,
37
+ onPageRegistered,
38
+ onWorkspaceSelectFile,
39
+ filePath,
40
+ embedded = false,
41
+ }: {
42
+ view: CodeView
43
+ onViewChange: (view: CodeView) => void
44
+ onPageRegistered?: (page: Page) => void
45
+ onWorkspaceSelectFile?: (path: string) => void
46
+ filePath?: string | null
47
+ embedded?: boolean
48
+ }) {
49
+ const {
50
+ appShell,
51
+ pages,
52
+ activePageId,
53
+ projectName,
54
+ registerPage,
55
+ saveProject,
56
+ setActivePage,
57
+ setSelectedComponent,
58
+ } = useAppStore()
59
+ const [copied, setCopied] = useState(false)
60
+ const [projectFile, setProjectFile] = useState<ProjectFile | null>(null)
61
+ const [fileContent, setFileContent] = useState('')
62
+ const [fileLoading, setFileLoading] = useState(false)
63
+ const [fileError, setFileError] = useState<string | null>(null)
64
+ const [fileSaveState, setFileSaveState] = useState<
65
+ 'idle' | 'saving' | 'saved'
66
+ >('idle')
67
+ const [fileReloadVersion, setFileReloadVersion] = useState(0)
68
+ const [externalFileChangePending, setExternalFileChangePending] =
69
+ useState(false)
70
+ const fileStateRef = useRef({
71
+ view,
72
+ filePath,
73
+ projectFile,
74
+ fileContent,
75
+ })
76
+ const workspaceSelectFileRef = useRef(onWorkspaceSelectFile)
77
+ const recentAddedFilesRef = useRef(new Map<string, number>())
78
+ const pendingMovedFileRef = useRef<{
79
+ path: string
80
+ fileName: string
81
+ } | null>(null)
82
+ const missingFileTimerRef = useRef<number | null>(null)
83
+ const [registeringPage, setRegisteringPage] = useState(false)
84
+ const [registerName, setRegisterName] = useState('')
85
+ const [registerRoute, setRegisterRoute] = useState('')
86
+ const [registerError, setRegisterError] = useState<string | null>(null)
87
+ const [registerBusy, setRegisterBusy] = useState(false)
88
+ const [workspaceOpen, setWorkspaceOpen] = useState(false)
89
+ const activePage = pages.find((page) => page.id === activePageId) ?? pages[0]
90
+
91
+ useEffect(() => {
92
+ if (!workspaceOpen) return
93
+
94
+ const previousOverflow = document.body.style.overflow
95
+ const closeOnEscape = (event: KeyboardEvent) => {
96
+ if (event.key === 'Escape') {
97
+ setWorkspaceOpen(false)
98
+ }
99
+ }
100
+
101
+ document.body.style.overflow = 'hidden'
102
+ window.addEventListener('keydown', closeOnEscape)
103
+
104
+ return () => {
105
+ document.body.style.overflow = previousOverflow
106
+ window.removeEventListener('keydown', closeOnEscape)
107
+ }
108
+ }, [workspaceOpen])
109
+
110
+ useEffect(() => {
111
+ fileStateRef.current = {
112
+ view,
113
+ filePath,
114
+ projectFile,
115
+ fileContent,
116
+ }
117
+ }, [fileContent, filePath, projectFile, view])
118
+
119
+ useEffect(() => {
120
+ workspaceSelectFileRef.current = onWorkspaceSelectFile
121
+ }, [onWorkspaceSelectFile])
122
+
123
+ useEffect(
124
+ () => () => {
125
+ if (missingFileTimerRef.current !== null) {
126
+ window.clearTimeout(missingFileTimerRef.current)
127
+ }
128
+ },
129
+ []
130
+ )
131
+
132
+ useEffect(() => {
133
+ if (view !== 'file' || !filePath) return
134
+ let cancelled = false
135
+
136
+ pendingMovedFileRef.current = null
137
+ if (missingFileTimerRef.current !== null) {
138
+ window.clearTimeout(missingFileTimerRef.current)
139
+ missingFileTimerRef.current = null
140
+ }
141
+
142
+ void Promise.resolve().then(async () => {
143
+ if (cancelled) return
144
+ setFileLoading(true)
145
+ setFileError(null)
146
+ setFileSaveState('idle')
147
+ setExternalFileChangePending(false)
148
+
149
+ try {
150
+ const nextFile = await loadProjectFile(filePath)
151
+
152
+ if (cancelled) return
153
+ setProjectFile(nextFile)
154
+ setFileContent(nextFile.content)
155
+ } catch (error) {
156
+ if (cancelled) return
157
+ setProjectFile(null)
158
+ setFileError(getErrorMessage(error))
159
+ } finally {
160
+ if (!cancelled) setFileLoading(false)
161
+ }
162
+ })
163
+
164
+ return () => {
165
+ cancelled = true
166
+ }
167
+ }, [filePath, fileReloadVersion, view])
168
+
169
+ useEffect(() => {
170
+ const handleFileSystemChange = (event: Event) => {
171
+ const detail = (event as CustomEvent<FileSystemChangeDetail>).detail
172
+ const current = fileStateRef.current
173
+ const normalizedEventPath = detail?.path.replace(/\\/g, '/') ?? ''
174
+ const normalizedCurrentPath = current.filePath?.replace(/\\/g, '/')
175
+ const now = Date.now()
176
+
177
+ if (
178
+ !detail ||
179
+ detail.type !== 'filesystem-change' ||
180
+ current.view !== 'file' ||
181
+ !normalizedCurrentPath
182
+ ) {
183
+ return
184
+ }
185
+
186
+ for (const [addedPath, addedAt] of recentAddedFilesRef.current) {
187
+ if (now - addedAt > externalMoveMatchWindowMs) {
188
+ recentAddedFilesRef.current.delete(addedPath)
189
+ }
190
+ }
191
+
192
+ if (detail.action === 'add') {
193
+ recentAddedFilesRef.current.set(normalizedEventPath, now)
194
+ const pendingMove = pendingMovedFileRef.current
195
+
196
+ if (
197
+ pendingMove &&
198
+ normalizedEventPath !== pendingMove.path &&
199
+ getProjectFileName(normalizedEventPath) === pendingMove.fileName
200
+ ) {
201
+ pendingMovedFileRef.current = null
202
+ if (missingFileTimerRef.current !== null) {
203
+ window.clearTimeout(missingFileTimerRef.current)
204
+ missingFileTimerRef.current = null
205
+ }
206
+ setFileError(null)
207
+ workspaceSelectFileRef.current?.(normalizedEventPath)
208
+ return
209
+ }
210
+ }
211
+
212
+ const selectedPathWasRemoved =
213
+ (detail.action === 'unlink' &&
214
+ normalizedEventPath === normalizedCurrentPath) ||
215
+ (detail.action === 'unlinkDir' &&
216
+ normalizedCurrentPath.startsWith(`${normalizedEventPath}/`))
217
+
218
+ if (selectedPathWasRemoved) {
219
+ const hasLocalEdits =
220
+ current.projectFile?.content !== current.fileContent
221
+
222
+ if (hasLocalEdits) {
223
+ setExternalFileChangePending(true)
224
+ return
225
+ }
226
+
227
+ const fileName = getProjectFileName(normalizedCurrentPath)
228
+ const movedPath = Array.from(
229
+ recentAddedFilesRef.current.entries()
230
+ )
231
+ .filter(
232
+ ([addedPath, addedAt]) =>
233
+ addedPath !== normalizedCurrentPath &&
234
+ now - addedAt <= externalMoveMatchWindowMs &&
235
+ getProjectFileName(addedPath) === fileName
236
+ )
237
+ .sort((left, right) => right[1] - left[1])[0]?.[0]
238
+
239
+ if (movedPath) {
240
+ setFileError(null)
241
+ workspaceSelectFileRef.current?.(movedPath)
242
+ return
243
+ }
244
+
245
+ pendingMovedFileRef.current = {
246
+ path: normalizedCurrentPath,
247
+ fileName,
248
+ }
249
+ setFileError(null)
250
+
251
+ if (missingFileTimerRef.current !== null) {
252
+ window.clearTimeout(missingFileTimerRef.current)
253
+ }
254
+ missingFileTimerRef.current = window.setTimeout(() => {
255
+ if (pendingMovedFileRef.current?.path !== normalizedCurrentPath) {
256
+ return
257
+ }
258
+
259
+ pendingMovedFileRef.current = null
260
+ missingFileTimerRef.current = null
261
+ setProjectFile(null)
262
+ setFileError('This file was removed from the project.')
263
+ }, externalMoveMatchWindowMs)
264
+ return
265
+ }
266
+
267
+ if (normalizedEventPath !== normalizedCurrentPath) {
268
+ return
269
+ }
270
+
271
+ const hasLocalEdits =
272
+ current.projectFile?.content !== current.fileContent
273
+
274
+ if (hasLocalEdits) {
275
+ setExternalFileChangePending(true)
276
+ return
277
+ }
278
+
279
+ setFileReloadVersion((version) => version + 1)
280
+ }
281
+
282
+ window.addEventListener(
283
+ 'visualbuild:filesystem-changed',
284
+ handleFileSystemChange
285
+ )
286
+ return () =>
287
+ window.removeEventListener(
288
+ 'visualbuild:filesystem-changed',
289
+ handleFileSystemChange
290
+ )
291
+ }, [])
292
+
293
+ const generated = useMemo(() => {
294
+ if (view === 'file') {
295
+ return {
296
+ filename: filePath ?? 'No file selected',
297
+ code: fileContent,
298
+ }
299
+ }
300
+
301
+ if (view === 'page') {
302
+ return {
303
+ filename: getPageSourcePath(activePage),
304
+ code: generatePageCode(activePage),
305
+ }
306
+ }
307
+
308
+ if (view === 'app') {
309
+ return {
310
+ filename: 'src/App.tsx',
311
+ code: generateAppCode(pages, appShell),
312
+ }
313
+ }
314
+
315
+ if (view === 'layout') {
316
+ return {
317
+ filename: 'src/layouts/AppLayout.tsx',
318
+ code: generateLayoutCode(appShell),
319
+ }
320
+ }
321
+
322
+ if (view === 'schema') {
323
+ return {
324
+ filename: 'visualbuild/pages.json',
325
+ code: generateProjectSchema(pages, appShell, projectName),
326
+ }
327
+ }
328
+
329
+ return {
330
+ filename: 'visualbuild/pages.json',
331
+ code: generateProjectSchema(pages, appShell, projectName),
332
+ }
333
+ }, [activePage, appShell, fileContent, filePath, pages, projectName, view])
334
+
335
+ const copyCode = () => {
336
+ void navigator.clipboard?.writeText(generated.code)
337
+ setCopied(true)
338
+ window.setTimeout(() => setCopied(false), 1000)
339
+ }
340
+
341
+ const saveFile = async () => {
342
+ if (!filePath || !projectFile || projectFile.isBinary || projectFile.isTooLarge) {
343
+ return
344
+ }
345
+
346
+ setFileSaveState('saving')
347
+ setFileError(null)
348
+
349
+ try {
350
+ await saveProjectFile(filePath, fileContent)
351
+ setProjectFile({
352
+ ...projectFile,
353
+ content: fileContent,
354
+ size: new Blob([fileContent]).size,
355
+ })
356
+ setExternalFileChangePending(false)
357
+ setFileSaveState('saved')
358
+ window.setTimeout(() => setFileSaveState('idle'), 1200)
359
+ } catch (error) {
360
+ setFileError(getErrorMessage(error))
361
+ setFileSaveState('idle')
362
+ }
363
+ }
364
+
365
+ const isFileDirty =
366
+ view === 'file' && projectFile?.content !== fileContent
367
+ const normalizedFilePath = filePath?.replace(/\\/g, '/') ?? ''
368
+ const canRegisterFileAsPage =
369
+ view === 'file' &&
370
+ Boolean(projectFile) &&
371
+ /^src\/.+\.(jsx|tsx)$/i.test(normalizedFilePath) &&
372
+ !pages.some((page) => getPageSourcePath(page) === normalizedFilePath)
373
+
374
+ const openPageRegistration = () => {
375
+ const name = derivePageName(normalizedFilePath)
376
+
377
+ setRegisterName(name)
378
+ setRegisterRoute(derivePageRoute(name))
379
+ setRegisterError(null)
380
+ setRegisteringPage(true)
381
+ }
382
+
383
+ const registerFileAsPage = async () => {
384
+ const name = registerName.trim()
385
+ const routeInput = registerRoute.trim()
386
+ const route = routeInput.startsWith('/') ? routeInput : `/${routeInput}`
387
+
388
+ if (!filePath || !name || !routeInput) {
389
+ setRegisterError('Enter a page name and route')
390
+ return
391
+ }
392
+
393
+ if (
394
+ pages.some((page) => page.name.toLowerCase() === name.toLowerCase())
395
+ ) {
396
+ setRegisterError('Page name already exists')
397
+ return
398
+ }
399
+
400
+ if (pages.some((page) => page.route === route)) {
401
+ setRegisterError('Route already exists')
402
+ return
403
+ }
404
+
405
+ setRegisterBusy(true)
406
+ setRegisterError(null)
407
+
408
+ try {
409
+ if (isFileDirty) {
410
+ await saveProjectFile(filePath, fileContent)
411
+ setProjectFile((current) =>
412
+ current
413
+ ? {
414
+ ...current,
415
+ content: fileContent,
416
+ size: new Blob([fileContent]).size,
417
+ }
418
+ : current
419
+ )
420
+ }
421
+
422
+ const page = await importProjectPage(filePath, name, route)
423
+ registerPage(page)
424
+ setActivePage(page.id)
425
+ await saveProject()
426
+ onViewChange('page')
427
+ onPageRegistered?.(page)
428
+ setRegisteringPage(false)
429
+ } catch (error) {
430
+ setRegisterError(getErrorMessage(error))
431
+ } finally {
432
+ setRegisterBusy(false)
433
+ }
434
+ }
435
+
436
+ const panelContent = (
437
+ <>
438
+ <div className="h-10 px-3 border-b border-white/[0.06] flex items-center justify-between gap-3">
439
+ <div className="min-w-0">
440
+ <p className="text-[10px] font-semibold uppercase tracking-widest text-gray-500">
441
+ Code
442
+ </p>
443
+ <p className="text-[10px] text-gray-400 truncate">
444
+ {generated.filename}
445
+ </p>
446
+ </div>
447
+ <div className="flex shrink-0 items-center gap-1">
448
+ {!workspaceOpen && (
449
+ <button
450
+ type="button"
451
+ onClick={() => setWorkspaceOpen(true)}
452
+ className="cursor-pointer rounded bg-white/[0.06] px-2 py-1 text-[10px] font-medium text-gray-300 transition-colors hover:bg-white/[0.1] hover:text-white"
453
+ title="Open a larger code workspace"
454
+ >
455
+ Open editor
456
+ </button>
457
+ )}
458
+ {canRegisterFileAsPage && (
459
+ <button
460
+ type="button"
461
+ onClick={openPageRegistration}
462
+ className="cursor-pointer rounded bg-emerald-500/15 px-2 py-1 text-[10px] font-medium text-emerald-300 transition-colors hover:bg-emerald-500/25 hover:text-emerald-200"
463
+ >
464
+ Register page
465
+ </button>
466
+ )}
467
+ {view === 'file' && externalFileChangePending && (
468
+ <button
469
+ type="button"
470
+ onClick={() => {
471
+ setExternalFileChangePending(false)
472
+ setFileReloadVersion((version) => version + 1)
473
+ }}
474
+ className="cursor-pointer rounded bg-amber-500/15 px-2 py-1 text-[10px] font-medium text-amber-300 transition-colors hover:bg-amber-500/25 hover:text-amber-200"
475
+ title="Discard unsaved editor text and load the external file version"
476
+ >
477
+ Reload disk
478
+ </button>
479
+ )}
480
+ {view === 'file' && projectFile && !projectFile.isBinary && !projectFile.isTooLarge && (
481
+ <button
482
+ type="button"
483
+ onClick={() => void saveFile()}
484
+ disabled={!isFileDirty || fileSaveState === 'saving'}
485
+ className="cursor-pointer rounded bg-blue-500 px-2 py-1 text-[10px] font-medium text-white transition-colors hover:bg-blue-400 disabled:cursor-default disabled:bg-white/[0.06] disabled:text-gray-500"
486
+ >
487
+ {fileSaveState === 'saving'
488
+ ? 'Saving...'
489
+ : fileSaveState === 'saved'
490
+ ? 'Saved'
491
+ : 'Save file'}
492
+ </button>
493
+ )}
494
+ <button
495
+ type="button"
496
+ onClick={copyCode}
497
+ className="px-2 py-1 text-[10px] text-gray-300 bg-white/[0.06] hover:bg-white/[0.1] rounded transition-colors cursor-pointer select-none"
498
+ >
499
+ {copied ? 'Copied' : 'Copy'}
500
+ </button>
501
+ </div>
502
+ </div>
503
+
504
+ <div className="px-2 py-2 border-b border-white/[0.06] flex flex-wrap gap-1">
505
+ <CodeTab active={view === 'page'} onClick={() => onViewChange('page')}>
506
+ Page
507
+ </CodeTab>
508
+ <CodeTab active={view === 'app'} onClick={() => onViewChange('app')}>
509
+ App
510
+ </CodeTab>
511
+ {(appShell.header || appShell.footer) && (
512
+ <CodeTab
513
+ active={view === 'layout'}
514
+ onClick={() => onViewChange('layout')}
515
+ >
516
+ Layout
517
+ </CodeTab>
518
+ )}
519
+ <CodeTab
520
+ active={view === 'schema'}
521
+ onClick={() => onViewChange('schema')}
522
+ >
523
+ JSON
524
+ </CodeTab>
525
+ {filePath && (
526
+ <CodeTab active={view === 'file'} onClick={() => onViewChange('file')}>
527
+ File{isFileDirty ? ' *' : ''}
528
+ </CodeTab>
529
+ )}
530
+ </div>
531
+
532
+ {view === 'file' ? (
533
+ <FileEditor
534
+ file={projectFile}
535
+ content={fileContent}
536
+ loading={fileLoading}
537
+ error={fileError}
538
+ onChange={setFileContent}
539
+ onSave={() => void saveFile()}
540
+ />
541
+ ) : (
542
+ <pre className="flex-1 overflow-auto p-4 text-[11px] leading-5 font-mono text-gray-200 bg-[#0b0d11]">
543
+ <code>{generated.code}</code>
544
+ </pre>
545
+ )}
546
+ {registeringPage && (
547
+ <PageRegistrationDialog
548
+ name={registerName}
549
+ route={registerRoute}
550
+ error={registerError}
551
+ busy={registerBusy}
552
+ onNameChange={setRegisterName}
553
+ onRouteChange={setRegisterRoute}
554
+ onCancel={() => setRegisteringPage(false)}
555
+ onConfirm={() => void registerFileAsPage()}
556
+ />
557
+ )}
558
+ </>
559
+ )
560
+
561
+ const compactPanel = (
562
+ <aside
563
+ className={`${embedded ? 'h-full w-full' : 'w-[460px] border-l'} relative flex shrink-0 flex-col overflow-hidden border-white/[0.06] bg-[#101318]`}
564
+ onClick={() => setSelectedComponent(null)}
565
+ >
566
+ {panelContent}
567
+ </aside>
568
+ )
569
+
570
+ if (!workspaceOpen) {
571
+ return compactPanel
572
+ }
573
+
574
+ return createPortal(
575
+ <div
576
+ className="fixed inset-0 z-[140] flex bg-black/70 p-3 backdrop-blur-sm"
577
+ role="dialog"
578
+ aria-modal="true"
579
+ aria-label="VisualBuild code workspace"
580
+ >
581
+ <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-md border border-white/10 bg-[#0b0d11] shadow-2xl">
582
+ <header className="flex h-12 shrink-0 items-center justify-between border-b border-white/[0.08] bg-[#15181e] px-4">
583
+ <div className="min-w-0">
584
+ <p className="text-xs font-semibold text-white">
585
+ VisualBuild Code Editor
586
+ </p>
587
+ <p className="truncate text-[10px] text-gray-500">
588
+ {generated.filename}
589
+ </p>
590
+ </div>
591
+ <button
592
+ type="button"
593
+ onClick={() => setWorkspaceOpen(false)}
594
+ className="flex h-8 w-8 cursor-pointer items-center justify-center rounded text-lg text-gray-400 transition-colors hover:bg-white/[0.08] hover:text-white"
595
+ aria-label="Close code editor"
596
+ title="Close code editor"
597
+ >
598
+ x
599
+ </button>
600
+ </header>
601
+
602
+ <div className="flex min-h-0 flex-1 flex-col sm:flex-row">
603
+ <aside className="flex h-[38%] w-full shrink-0 flex-col overflow-hidden border-b border-white/[0.08] bg-[#101318] sm:h-auto sm:w-[300px] sm:border-r sm:border-b-0">
604
+ <div className="flex h-10 shrink-0 items-center border-b border-white/[0.06] px-3">
605
+ <p className="text-[10px] font-semibold uppercase tracking-widest text-gray-500">
606
+ Project files
607
+ </p>
608
+ </div>
609
+ <div className="min-h-0 flex-1 overflow-hidden">
610
+ <ProjectExplorer
611
+ selectedPath={filePath ?? null}
612
+ onSelectFile={(path) => {
613
+ if (path) onWorkspaceSelectFile?.(path)
614
+ }}
615
+ />
616
+ </div>
617
+ </aside>
618
+
619
+ <section
620
+ className="relative flex min-w-0 flex-1 flex-col overflow-hidden bg-[#101318]"
621
+ onClick={() => setSelectedComponent(null)}
622
+ >
623
+ {panelContent}
624
+ </section>
625
+ </div>
626
+ </div>
627
+ </div>,
628
+ document.body
629
+ )
630
+ }
631
+
632
+ function PageRegistrationDialog({
633
+ name,
634
+ route,
635
+ error,
636
+ busy,
637
+ onNameChange,
638
+ onRouteChange,
639
+ onCancel,
640
+ onConfirm,
641
+ }: {
642
+ name: string
643
+ route: string
644
+ error: string | null
645
+ busy: boolean
646
+ onNameChange: (value: string) => void
647
+ onRouteChange: (value: string) => void
648
+ onCancel: () => void
649
+ onConfirm: () => void
650
+ }) {
651
+ return (
652
+ <div className="absolute inset-0 z-20 flex items-center justify-center bg-black/55 p-5 backdrop-blur-sm">
653
+ <div className="w-full max-w-sm rounded-lg border border-white/10 bg-[#191d24] p-4 shadow-2xl">
654
+ <h2 className="text-sm font-semibold text-white">Register as page</h2>
655
+ <p className="mt-1 text-[11px] leading-4 text-gray-400">
656
+ VisualBuild will manage this file from the canvas after registration.
657
+ </p>
658
+ <label className="mt-4 block text-[10px] font-semibold uppercase tracking-wider text-gray-500">
659
+ Page name
660
+ <input
661
+ autoFocus
662
+ value={name}
663
+ onChange={(event) => onNameChange(event.target.value)}
664
+ className="mt-1 w-full rounded border border-white/10 bg-black/20 px-2.5 py-2 text-xs normal-case tracking-normal text-white outline-none focus:border-blue-400"
665
+ />
666
+ </label>
667
+ <label className="mt-3 block text-[10px] font-semibold uppercase tracking-wider text-gray-500">
668
+ Route
669
+ <input
670
+ value={route}
671
+ onChange={(event) => onRouteChange(event.target.value)}
672
+ onKeyDown={(event) => {
673
+ if (event.key === 'Enter') onConfirm()
674
+ }}
675
+ className="mt-1 w-full rounded border border-white/10 bg-black/20 px-2.5 py-2 text-xs normal-case tracking-normal text-white outline-none focus:border-blue-400"
676
+ />
677
+ </label>
678
+ {error && <p className="mt-3 text-xs text-red-300">{error}</p>}
679
+ <div className="mt-4 flex justify-end gap-2">
680
+ <button
681
+ type="button"
682
+ onClick={onCancel}
683
+ disabled={busy}
684
+ className="cursor-pointer rounded px-3 py-1.5 text-xs text-gray-400 hover:bg-white/[0.06] hover:text-white disabled:cursor-default"
685
+ >
686
+ Keep as file
687
+ </button>
688
+ <button
689
+ type="button"
690
+ onClick={onConfirm}
691
+ disabled={busy}
692
+ className="cursor-pointer rounded bg-blue-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-400 disabled:cursor-default disabled:bg-blue-500/40"
693
+ >
694
+ {busy ? 'Registering...' : 'Register page'}
695
+ </button>
696
+ </div>
697
+ </div>
698
+ </div>
699
+ )
700
+ }
701
+
702
+ function FileEditor({
703
+ file,
704
+ content,
705
+ loading,
706
+ error,
707
+ onChange,
708
+ onSave,
709
+ }: {
710
+ file: ProjectFile | null
711
+ content: string
712
+ loading: boolean
713
+ error: string | null
714
+ onChange: (content: string) => void
715
+ onSave: () => void
716
+ }) {
717
+ if (loading) {
718
+ return (
719
+ <div className="flex flex-1 items-center justify-center bg-[#0b0d11] text-xs text-gray-500">
720
+ Opening file...
721
+ </div>
722
+ )
723
+ }
724
+
725
+ if (error) {
726
+ return (
727
+ <div className="flex flex-1 items-center justify-center bg-[#0b0d11] p-6 text-center text-xs leading-5 text-red-300">
728
+ {error}
729
+ </div>
730
+ )
731
+ }
732
+
733
+ if (file?.isBinary || file?.isTooLarge) {
734
+ return (
735
+ <div className="flex flex-1 items-center justify-center bg-[#0b0d11] p-6 text-center text-xs leading-5 text-gray-400">
736
+ {file.isBinary
737
+ ? 'Binary files cannot be edited in the code panel.'
738
+ : 'This file is larger than the 2 MB editor limit.'}
739
+ </div>
740
+ )
741
+ }
742
+
743
+ return (
744
+ <textarea
745
+ value={content}
746
+ onChange={(event) => onChange(event.target.value)}
747
+ onKeyDown={(event) => {
748
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
749
+ event.preventDefault()
750
+ onSave()
751
+ }
752
+ }}
753
+ spellCheck={false}
754
+ className="min-h-0 flex-1 resize-none overflow-auto bg-[#0b0d11] p-4 font-mono text-[11px] leading-5 text-gray-200 outline-none"
755
+ aria-label="Project file editor"
756
+ />
757
+ )
758
+ }
759
+
760
+ function CodeTab({
761
+ active,
762
+ children,
763
+ onClick,
764
+ }: {
765
+ active: boolean
766
+ children: React.ReactNode
767
+ onClick: () => void
768
+ }) {
769
+ return (
770
+ <button
771
+ type="button"
772
+ onClick={onClick}
773
+ className={`px-2 py-1 text-[10px] rounded transition-colors cursor-pointer select-none ${
774
+ active
775
+ ? 'bg-accent text-white'
776
+ : 'text-gray-500 hover:text-gray-200 hover:bg-white/[0.06]'
777
+ }`}
778
+ >
779
+ {children}
780
+ </button>
781
+ )
782
+ }
783
+
784
+ function getErrorMessage(error: unknown) {
785
+ return error instanceof Error ? error.message : 'Project file operation failed'
786
+ }
787
+
788
+ function derivePageName(filePath: string) {
789
+ const filename = filePath.split('/').pop() ?? 'Page'
790
+ const baseName = filename
791
+ .replace(/\.[^.]+$/, '')
792
+ .replace(/Page$/i, '')
793
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
794
+ .replace(/[-_]+/g, ' ')
795
+ .trim()
796
+
797
+ return baseName
798
+ .split(/\s+/)
799
+ .filter(Boolean)
800
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
801
+ .join(' ') || 'Page'
802
+ }
803
+
804
+ function derivePageRoute(name: string) {
805
+ const slug = name
806
+ .trim()
807
+ .toLowerCase()
808
+ .replace(/[^a-z0-9]+/g, '-')
809
+ .replace(/^-+|-+$/g, '')
810
+
811
+ return slug === 'home' ? '/' : `/${slug || 'page'}`
812
+ }