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.
- package/README.md +123 -0
- package/package.json +85 -0
- package/scripts/create-builder-app.mjs +501 -0
- package/scripts/vb-dev.mjs +32 -0
- package/scripts/vb-generate.mjs +224 -0
- package/templates/default/app/README.md +21 -0
- package/templates/default/app/eslint.config.js +25 -0
- package/templates/default/app/index.html +15 -0
- package/templates/default/app/src/index.css +23 -0
- package/templates/default/app/src/main.tsx +10 -0
- package/templates/default/app/tsconfig.app.json +21 -0
- package/templates/default/app/tsconfig.json +7 -0
- package/templates/default/app/tsconfig.node.json +19 -0
- package/templates/default/app/visualbuild/generated-files.json +3 -0
- package/templates/default/app/visualbuild/pages.json +12 -0
- package/templates/default/app/vite.config.ts +7 -0
- package/templates/default/builder/README.md +21 -0
- package/templates/default/builder/eslint.config.js +25 -0
- package/templates/default/builder/tsconfig.app.json +21 -0
- package/templates/default/builder/tsconfig.json +7 -0
- package/templates/default/builder/tsconfig.node.json +22 -0
- package/visual-app-builder/index.html +15 -0
- package/visual-app-builder/server/parseReactPage.ts +571 -0
- package/visual-app-builder/shared/generateReactSource.d.mts +37 -0
- package/visual-app-builder/shared/generateReactSource.mjs +443 -0
- package/visual-app-builder/src/App.tsx +874 -0
- package/visual-app-builder/src/components/Canvas.tsx +1059 -0
- package/visual-app-builder/src/components/CodePanel.tsx +812 -0
- package/visual-app-builder/src/components/ComponentSidebar.tsx +302 -0
- package/visual-app-builder/src/components/PageSwitcher.tsx +161 -0
- package/visual-app-builder/src/components/ProjectExplorer.tsx +1054 -0
- package/visual-app-builder/src/components/PropertiesPanel.tsx +692 -0
- package/visual-app-builder/src/components/Topbar.tsx +128 -0
- package/visual-app-builder/src/data/componentRegistry.tsx +292 -0
- package/visual-app-builder/src/data/primitiveRegistry.ts +368 -0
- package/visual-app-builder/src/index.css +111 -0
- package/visual-app-builder/src/main.tsx +10 -0
- package/visual-app-builder/src/stores/useAppStore.ts +1265 -0
- package/visual-app-builder/src/tailwindGeneratedSafelist.ts +36 -0
- package/visual-app-builder/src/types/index.ts +261 -0
- package/visual-app-builder/src/utils/codegen.ts +66 -0
- package/visual-app-builder/src/utils/projectFiles.ts +146 -0
- package/visual-app-builder/src/utils/projectPersistence.ts +177 -0
- 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
|
+
}
|