create-visualbuild-app 0.1.0 → 1.0.1
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 +919 -58
- package/docs/user-guide.md +759 -0
- package/package.json +92 -85
- package/scripts/create-builder-app.mjs +513 -501
- package/scripts/vb-dev.mjs +41 -32
- package/scripts/vb-generate.mjs +332 -224
- package/templates/default/app/.vercelignore +6 -0
- package/templates/default/app/README.md +56 -21
- package/templates/default/app/visualbuild/components.json +3 -0
- package/templates/default/app/visualbuild/config.d.ts +48 -0
- package/templates/default/app/visualbuild.config.ts +38 -0
- package/templates/default/builder/README.md +37 -21
- package/visual-app-builder/server/parseReactPage.ts +776 -571
- package/visual-app-builder/shared/createReactComponentSource.d.mts +2 -0
- package/visual-app-builder/shared/createReactComponentSource.mjs +45 -0
- package/visual-app-builder/shared/generateReactSource.d.mts +57 -37
- package/visual-app-builder/shared/generateReactSource.mjs +608 -443
- package/visual-app-builder/shared/npmBuildCommand.d.mts +17 -0
- package/visual-app-builder/shared/npmBuildCommand.mjs +26 -0
- package/visual-app-builder/shared/syncCustomComponentSource.d.mts +13 -0
- package/visual-app-builder/shared/syncCustomComponentSource.mjs +345 -0
- package/visual-app-builder/shared/vercelDeployment.d.mts +31 -0
- package/visual-app-builder/shared/vercelDeployment.mjs +266 -0
- package/visual-app-builder/shared/visualbuildConfig.d.mts +144 -0
- package/visual-app-builder/shared/visualbuildConfig.mjs +578 -0
- package/visual-app-builder/src/App.tsx +1090 -874
- package/visual-app-builder/src/components/Canvas.tsx +1116 -1059
- package/visual-app-builder/src/components/CodePanel.tsx +1147 -812
- package/visual-app-builder/src/components/ComponentSidebar.tsx +365 -302
- package/visual-app-builder/src/components/DeploymentModal.tsx +295 -0
- package/visual-app-builder/src/components/PageSwitcher.tsx +133 -133
- package/visual-app-builder/src/components/ProjectExplorer.tsx +1054 -1054
- package/visual-app-builder/src/components/PropertiesPanel.tsx +792 -692
- package/visual-app-builder/src/components/Topbar.tsx +257 -128
- package/visual-app-builder/src/data/componentRegistry.tsx +613 -292
- package/visual-app-builder/src/data/tailwindClassCatalog.ts +95 -0
- package/visual-app-builder/src/index.css +383 -111
- package/visual-app-builder/src/stores/useAppStore.ts +2385 -1265
- package/visual-app-builder/src/theme.ts +71 -0
- package/visual-app-builder/src/types/index.ts +350 -261
- package/visual-app-builder/src/utils/codegen.ts +90 -66
- package/visual-app-builder/src/utils/deployment.ts +54 -0
- package/visual-app-builder/src/utils/projectPersistence.ts +218 -177
- package/visual-app-builder/vite.config.ts +1946 -1479
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
deployProjectToVercel,
|
|
4
|
+
loadDeploymentConfiguration,
|
|
5
|
+
type DeploymentConfiguration,
|
|
6
|
+
type DeploymentResult,
|
|
7
|
+
} from '../utils/deployment'
|
|
8
|
+
|
|
9
|
+
type DeploymentState = 'loading' | 'ready' | 'deploying' | 'success' | 'error'
|
|
10
|
+
|
|
11
|
+
export default function DeploymentModal({
|
|
12
|
+
open,
|
|
13
|
+
onClose,
|
|
14
|
+
onBeforeDeploy,
|
|
15
|
+
}: {
|
|
16
|
+
open: boolean
|
|
17
|
+
onClose: () => void
|
|
18
|
+
onBeforeDeploy: () => Promise<boolean>
|
|
19
|
+
}) {
|
|
20
|
+
if (!open) return null
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<DeploymentDialog onClose={onClose} onBeforeDeploy={onBeforeDeploy} />
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function DeploymentDialog({
|
|
28
|
+
onClose,
|
|
29
|
+
onBeforeDeploy,
|
|
30
|
+
}: {
|
|
31
|
+
onClose: () => void
|
|
32
|
+
onBeforeDeploy: () => Promise<boolean>
|
|
33
|
+
}) {
|
|
34
|
+
const [configuration, setConfiguration] =
|
|
35
|
+
useState<DeploymentConfiguration | null>(null)
|
|
36
|
+
const [projectName, setProjectName] = useState('')
|
|
37
|
+
const [teamId, setTeamId] = useState('')
|
|
38
|
+
const [state, setState] = useState<DeploymentState>('loading')
|
|
39
|
+
const [error, setError] = useState('')
|
|
40
|
+
const [result, setResult] = useState<DeploymentResult | null>(null)
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
let active = true
|
|
44
|
+
|
|
45
|
+
void loadDeploymentConfiguration()
|
|
46
|
+
.then((nextConfiguration) => {
|
|
47
|
+
if (!active) return
|
|
48
|
+
setConfiguration(nextConfiguration)
|
|
49
|
+
setProjectName(nextConfiguration.projectName)
|
|
50
|
+
setTeamId(nextConfiguration.teamId)
|
|
51
|
+
setState('ready')
|
|
52
|
+
})
|
|
53
|
+
.catch((loadError) => {
|
|
54
|
+
if (!active) return
|
|
55
|
+
setError(
|
|
56
|
+
loadError instanceof Error
|
|
57
|
+
? loadError.message
|
|
58
|
+
: 'Could not load deployment settings'
|
|
59
|
+
)
|
|
60
|
+
setState('error')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
return () => {
|
|
64
|
+
active = false
|
|
65
|
+
}
|
|
66
|
+
}, [])
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
70
|
+
if (event.key === 'Escape' && state !== 'deploying') onClose()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
window.addEventListener('keydown', handleKeyDown)
|
|
74
|
+
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
75
|
+
}, [onClose, state])
|
|
76
|
+
|
|
77
|
+
const handleDeploy = async () => {
|
|
78
|
+
if (!configuration?.tokenConfigured || state === 'deploying') return
|
|
79
|
+
|
|
80
|
+
setState('deploying')
|
|
81
|
+
setError('')
|
|
82
|
+
setResult(null)
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const saved = await onBeforeDeploy()
|
|
86
|
+
if (!saved) {
|
|
87
|
+
throw new Error('Save the project successfully before deploying')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const deployment = await deployProjectToVercel(projectName, teamId)
|
|
91
|
+
setResult(deployment)
|
|
92
|
+
setState('success')
|
|
93
|
+
} catch (deploymentError) {
|
|
94
|
+
setError(
|
|
95
|
+
deploymentError instanceof Error
|
|
96
|
+
? deploymentError.message
|
|
97
|
+
: 'Vercel deployment failed'
|
|
98
|
+
)
|
|
99
|
+
setState('error')
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div
|
|
105
|
+
data-vb-editor-chrome
|
|
106
|
+
className="fixed inset-0 z-[120] flex items-center justify-center bg-gray-950/70 px-4 backdrop-blur-sm"
|
|
107
|
+
role="dialog"
|
|
108
|
+
aria-modal="true"
|
|
109
|
+
aria-labelledby="deployment-title"
|
|
110
|
+
onMouseDown={(event) => {
|
|
111
|
+
if (event.target === event.currentTarget && state !== 'deploying') {
|
|
112
|
+
onClose()
|
|
113
|
+
}
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
<div className="w-full max-w-lg overflow-hidden rounded-xl border border-white/10 bg-[#171a20] text-gray-100 shadow-2xl">
|
|
117
|
+
<div className="flex items-start justify-between border-b border-white/[0.08] px-5 py-4">
|
|
118
|
+
<div>
|
|
119
|
+
<div className="mb-1 flex items-center gap-2">
|
|
120
|
+
<VercelIcon />
|
|
121
|
+
<h2 id="deployment-title" className="text-base font-semibold">
|
|
122
|
+
Deploy to Vercel
|
|
123
|
+
</h2>
|
|
124
|
+
</div>
|
|
125
|
+
<p className="text-xs leading-5 text-gray-400">
|
|
126
|
+
Publish a production build of the developer-owned application.
|
|
127
|
+
</p>
|
|
128
|
+
</div>
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
onClick={onClose}
|
|
132
|
+
disabled={state === 'deploying'}
|
|
133
|
+
aria-label="Close deployment dialog"
|
|
134
|
+
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded text-gray-500 hover:bg-white/[0.08] hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
|
|
135
|
+
>
|
|
136
|
+
×
|
|
137
|
+
</button>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
<div className="space-y-4 px-5 py-5">
|
|
141
|
+
{state === 'loading' ? (
|
|
142
|
+
<StatusRow label="Loading deployment settings..." />
|
|
143
|
+
) : (
|
|
144
|
+
<>
|
|
145
|
+
<label className="block">
|
|
146
|
+
<span className="mb-1.5 block text-[10px] font-semibold uppercase tracking-[0.12em] text-gray-500">
|
|
147
|
+
Vercel project
|
|
148
|
+
</span>
|
|
149
|
+
<input
|
|
150
|
+
value={projectName}
|
|
151
|
+
onChange={(event) => setProjectName(event.target.value)}
|
|
152
|
+
disabled={state === 'deploying' || state === 'success'}
|
|
153
|
+
autoComplete="off"
|
|
154
|
+
spellCheck={false}
|
|
155
|
+
className="h-10 w-full rounded-md border border-white/10 bg-black/20 px-3 text-sm text-white outline-none transition-colors focus:border-accent disabled:opacity-60"
|
|
156
|
+
/>
|
|
157
|
+
</label>
|
|
158
|
+
|
|
159
|
+
<label className="block">
|
|
160
|
+
<span className="mb-1.5 block text-[10px] font-semibold uppercase tracking-[0.12em] text-gray-500">
|
|
161
|
+
Team ID <span className="normal-case tracking-normal">(optional)</span>
|
|
162
|
+
</span>
|
|
163
|
+
<input
|
|
164
|
+
value={teamId}
|
|
165
|
+
onChange={(event) => setTeamId(event.target.value)}
|
|
166
|
+
disabled={state === 'deploying' || state === 'success'}
|
|
167
|
+
placeholder="team_..."
|
|
168
|
+
autoComplete="off"
|
|
169
|
+
spellCheck={false}
|
|
170
|
+
className="h-10 w-full rounded-md border border-white/10 bg-black/20 px-3 text-sm text-white outline-none transition-colors placeholder:text-gray-600 focus:border-accent disabled:opacity-60"
|
|
171
|
+
/>
|
|
172
|
+
</label>
|
|
173
|
+
|
|
174
|
+
<div className="rounded-lg border border-emerald-400/20 bg-emerald-400/[0.07] px-3.5 py-3">
|
|
175
|
+
<p className="vb-deploy-success-text text-xs font-medium text-emerald-300">
|
|
176
|
+
Production boundary protected
|
|
177
|
+
</p>
|
|
178
|
+
<p className="mt-1 text-[11px] leading-5 text-gray-400">
|
|
179
|
+
VisualBuild saves and builds locally, then uploads only{' '}
|
|
180
|
+
<code>{configuration?.outputDirectory ?? 'dist'}</code>. Editor
|
|
181
|
+
source, visualbuild metadata, node_modules, and credentials are
|
|
182
|
+
excluded.
|
|
183
|
+
</p>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
{configuration && !configuration.tokenConfigured && (
|
|
187
|
+
<div className="rounded-lg border border-amber-400/25 bg-amber-400/[0.08] px-3.5 py-3">
|
|
188
|
+
<p className="vb-deploy-warning-text text-xs font-medium text-amber-200">
|
|
189
|
+
Vercel access token required
|
|
190
|
+
</p>
|
|
191
|
+
<p className="mt-1 text-[11px] leading-5 text-gray-400">
|
|
192
|
+
Keep the token out of browser code. Stop the builder, set it
|
|
193
|
+
in the same PowerShell terminal, then restart:
|
|
194
|
+
</p>
|
|
195
|
+
<code className="mt-2 block overflow-x-auto rounded bg-black/25 px-2.5 py-2 text-[10px] text-gray-300">
|
|
196
|
+
$env:VERCEL_TOKEN = 'your-token'
|
|
197
|
+
</code>
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
|
|
201
|
+
{state === 'deploying' && (
|
|
202
|
+
<StatusRow label="Saving, building, and publishing to Vercel..." />
|
|
203
|
+
)}
|
|
204
|
+
|
|
205
|
+
{error && (
|
|
206
|
+
<div
|
|
207
|
+
role="alert"
|
|
208
|
+
className="vb-deploy-error-text rounded-lg border border-red-400/25 bg-red-400/[0.08] px-3.5 py-3 text-xs leading-5 text-red-200"
|
|
209
|
+
>
|
|
210
|
+
{error}
|
|
211
|
+
</div>
|
|
212
|
+
)}
|
|
213
|
+
|
|
214
|
+
{result && (
|
|
215
|
+
<div className="rounded-lg border border-blue-400/25 bg-blue-400/[0.08] px-3.5 py-3">
|
|
216
|
+
<p className="vb-deploy-link-text text-xs font-semibold text-blue-200">
|
|
217
|
+
Production deployment is ready
|
|
218
|
+
</p>
|
|
219
|
+
<a
|
|
220
|
+
href={result.url}
|
|
221
|
+
target="_blank"
|
|
222
|
+
rel="noreferrer"
|
|
223
|
+
className="vb-deploy-link-text mt-1.5 block truncate text-sm text-blue-300 underline underline-offset-2 hover:text-blue-200"
|
|
224
|
+
>
|
|
225
|
+
{result.url}
|
|
226
|
+
</a>
|
|
227
|
+
<p className="mt-2 text-[10px] text-gray-500">
|
|
228
|
+
{result.fileCount} files · {formatBytes(result.totalBytes)} ·{' '}
|
|
229
|
+
{result.id}
|
|
230
|
+
</p>
|
|
231
|
+
</div>
|
|
232
|
+
)}
|
|
233
|
+
</>
|
|
234
|
+
)}
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
<div className="flex items-center justify-between border-t border-white/[0.08] px-5 py-3.5">
|
|
238
|
+
<p className="text-[10px] text-gray-500">
|
|
239
|
+
Token stays in the local builder process.
|
|
240
|
+
</p>
|
|
241
|
+
<div className="flex items-center gap-2">
|
|
242
|
+
<button
|
|
243
|
+
type="button"
|
|
244
|
+
onClick={onClose}
|
|
245
|
+
disabled={state === 'deploying'}
|
|
246
|
+
className="cursor-pointer rounded-md px-3 py-2 text-xs text-gray-400 hover:bg-white/[0.06] hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
|
|
247
|
+
>
|
|
248
|
+
{state === 'success' ? 'Done' : 'Cancel'}
|
|
249
|
+
</button>
|
|
250
|
+
{state !== 'success' && (
|
|
251
|
+
<button
|
|
252
|
+
type="button"
|
|
253
|
+
onClick={() => void handleDeploy()}
|
|
254
|
+
disabled={
|
|
255
|
+
state === 'loading' ||
|
|
256
|
+
state === 'deploying' ||
|
|
257
|
+
!configuration?.tokenConfigured ||
|
|
258
|
+
!projectName.trim()
|
|
259
|
+
}
|
|
260
|
+
className="cursor-pointer rounded-md bg-accent px-4 py-2 text-xs font-semibold text-white hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-45"
|
|
261
|
+
>
|
|
262
|
+
{state === 'deploying' ? 'Deploying...' : 'Deploy production'}
|
|
263
|
+
</button>
|
|
264
|
+
)}
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function StatusRow({ label }: { label: string }) {
|
|
273
|
+
return (
|
|
274
|
+
<div className="flex items-center gap-2.5 rounded-lg border border-white/[0.08] bg-white/[0.03] px-3.5 py-3 text-xs text-gray-300">
|
|
275
|
+
<span className="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-white/15 border-t-accent" />
|
|
276
|
+
{label}
|
|
277
|
+
</div>
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function VercelIcon() {
|
|
282
|
+
return (
|
|
283
|
+
<span className="flex h-6 w-6 items-center justify-center rounded bg-white text-black">
|
|
284
|
+
<svg width="13" height="12" viewBox="0 0 13 12" aria-hidden="true">
|
|
285
|
+
<path d="M6.5 0 13 12H0L6.5 0Z" fill="currentColor" />
|
|
286
|
+
</svg>
|
|
287
|
+
</span>
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function formatBytes(bytes: number) {
|
|
292
|
+
if (bytes < 1024) return `${bytes} B`
|
|
293
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
294
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
295
|
+
}
|
|
@@ -1,89 +1,89 @@
|
|
|
1
|
-
import { useState } from 'react'
|
|
2
|
-
import { useAppStore } from '../stores/useAppStore'
|
|
3
|
-
import type { Page } from '../types'
|
|
4
|
-
|
|
5
|
-
export default function PageSwitcher({
|
|
6
|
-
onSelectPage,
|
|
7
|
-
onDeletePage,
|
|
8
|
-
}: {
|
|
9
|
-
onSelectPage?: (page: Page) => void
|
|
10
|
-
onDeletePage?: (page: Page) => void
|
|
11
|
-
}) {
|
|
12
|
-
const {
|
|
13
|
-
pages,
|
|
14
|
-
activePageId,
|
|
15
|
-
setActivePage,
|
|
16
|
-
addPage,
|
|
17
|
-
deletePage,
|
|
18
|
-
saveProject,
|
|
19
|
-
} = useAppStore()
|
|
20
|
-
const [adding, setAdding] = useState(false)
|
|
21
|
-
const [newName, setNewName] = useState('')
|
|
22
|
-
const [newRoute, setNewRoute] = useState('/')
|
|
23
|
-
const [error, setError] = useState<string | null>(null)
|
|
24
|
-
const [busy, setBusy] = useState(false)
|
|
25
|
-
|
|
26
|
-
const handleAdd = async () => {
|
|
27
|
-
if (!newName.trim()) return
|
|
28
|
-
const route = newRoute.startsWith('/') ? newRoute : `/${newRoute}`
|
|
29
|
-
|
|
30
|
-
if (pages.some((page) => page.route === route)) {
|
|
31
|
-
setError('Route already exists')
|
|
32
|
-
return
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
if (
|
|
36
|
-
pages.some(
|
|
37
|
-
(page) => page.name.toLowerCase() === newName.trim().toLowerCase()
|
|
38
|
-
)
|
|
39
|
-
) {
|
|
40
|
-
setError('Page name already exists')
|
|
41
|
-
return
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
setBusy(true)
|
|
45
|
-
|
|
46
|
-
try {
|
|
47
|
-
const page = addPage(newName.trim(), route)
|
|
48
|
-
setActivePage(page.id)
|
|
49
|
-
await saveProject()
|
|
50
|
-
onSelectPage?.(page)
|
|
51
|
-
setNewName('')
|
|
52
|
-
setNewRoute('/')
|
|
53
|
-
setError(null)
|
|
54
|
-
setAdding(false)
|
|
55
|
-
} catch (saveError) {
|
|
56
|
-
setError(getErrorMessage(saveError))
|
|
57
|
-
} finally {
|
|
58
|
-
setBusy(false)
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const handleDelete = async (page: Page) => {
|
|
63
|
-
if (busy) return
|
|
64
|
-
setBusy(true)
|
|
65
|
-
|
|
66
|
-
try {
|
|
67
|
-
deletePage(page.id)
|
|
68
|
-
await saveProject()
|
|
69
|
-
onDeletePage?.(page)
|
|
70
|
-
} catch (saveError) {
|
|
71
|
-
setError(getErrorMessage(saveError))
|
|
72
|
-
} finally {
|
|
73
|
-
setBusy(false)
|
|
74
|
-
}
|
|
75
|
-
}
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { useAppStore } from '../stores/useAppStore'
|
|
3
|
+
import type { Page } from '../types'
|
|
4
|
+
|
|
5
|
+
export default function PageSwitcher({
|
|
6
|
+
onSelectPage,
|
|
7
|
+
onDeletePage,
|
|
8
|
+
}: {
|
|
9
|
+
onSelectPage?: (page: Page) => void
|
|
10
|
+
onDeletePage?: (page: Page) => void
|
|
11
|
+
}) {
|
|
12
|
+
const {
|
|
13
|
+
pages,
|
|
14
|
+
activePageId,
|
|
15
|
+
setActivePage,
|
|
16
|
+
addPage,
|
|
17
|
+
deletePage,
|
|
18
|
+
saveProject,
|
|
19
|
+
} = useAppStore()
|
|
20
|
+
const [adding, setAdding] = useState(false)
|
|
21
|
+
const [newName, setNewName] = useState('')
|
|
22
|
+
const [newRoute, setNewRoute] = useState('/')
|
|
23
|
+
const [error, setError] = useState<string | null>(null)
|
|
24
|
+
const [busy, setBusy] = useState(false)
|
|
25
|
+
|
|
26
|
+
const handleAdd = async () => {
|
|
27
|
+
if (!newName.trim()) return
|
|
28
|
+
const route = newRoute.startsWith('/') ? newRoute : `/${newRoute}`
|
|
29
|
+
|
|
30
|
+
if (pages.some((page) => page.route === route)) {
|
|
31
|
+
setError('Route already exists')
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (
|
|
36
|
+
pages.some(
|
|
37
|
+
(page) => page.name.toLowerCase() === newName.trim().toLowerCase()
|
|
38
|
+
)
|
|
39
|
+
) {
|
|
40
|
+
setError('Page name already exists')
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
setBusy(true)
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const page = addPage(newName.trim(), route)
|
|
48
|
+
setActivePage(page.id)
|
|
49
|
+
await saveProject()
|
|
50
|
+
onSelectPage?.(page)
|
|
51
|
+
setNewName('')
|
|
52
|
+
setNewRoute('/')
|
|
53
|
+
setError(null)
|
|
54
|
+
setAdding(false)
|
|
55
|
+
} catch (saveError) {
|
|
56
|
+
setError(getErrorMessage(saveError))
|
|
57
|
+
} finally {
|
|
58
|
+
setBusy(false)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const handleDelete = async (page: Page) => {
|
|
63
|
+
if (busy) return
|
|
64
|
+
setBusy(true)
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
deletePage(page.id)
|
|
68
|
+
await saveProject()
|
|
69
|
+
onDeletePage?.(page)
|
|
70
|
+
} catch (saveError) {
|
|
71
|
+
setError(getErrorMessage(saveError))
|
|
72
|
+
} finally {
|
|
73
|
+
setBusy(false)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
76
|
|
|
77
77
|
return (
|
|
78
|
-
<div className="flex items-center gap-1 px-3 h-9 bg-topbar border-t border-white/[0.06] shrink-0 overflow-x-auto">
|
|
78
|
+
<div data-vb-editor-chrome className="flex items-center gap-1 px-3 h-9 bg-topbar border-t border-white/[0.06] shrink-0 overflow-x-auto">
|
|
79
79
|
{pages.map((page) => (
|
|
80
80
|
<div key={page.id} className="flex items-center group shrink-0">
|
|
81
|
-
<button
|
|
82
|
-
onClick={() => {
|
|
83
|
-
setActivePage(page.id)
|
|
84
|
-
onSelectPage?.(page)
|
|
85
|
-
}}
|
|
86
|
-
className={`px-3 py-1 text-xs rounded transition-colors cursor-pointer select-none ${
|
|
81
|
+
<button
|
|
82
|
+
onClick={() => {
|
|
83
|
+
setActivePage(page.id)
|
|
84
|
+
onSelectPage?.(page)
|
|
85
|
+
}}
|
|
86
|
+
className={`px-3 py-1 text-xs rounded transition-colors cursor-pointer select-none ${
|
|
87
87
|
activePageId === page.id
|
|
88
88
|
? 'bg-white/[0.1] text-white'
|
|
89
89
|
: 'text-gray-500 hover:text-gray-300 hover:bg-white/[0.05]'
|
|
@@ -93,10 +93,10 @@ export default function PageSwitcher({
|
|
|
93
93
|
<span className="ml-1 text-gray-600">{page.route}</span>
|
|
94
94
|
</button>
|
|
95
95
|
{pages.length > 1 && (
|
|
96
|
-
<button
|
|
97
|
-
onClick={() => void handleDelete(page)}
|
|
98
|
-
disabled={busy}
|
|
99
|
-
className="ml-0.5 w-4 h-4 text-gray-600 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all text-xs flex items-center justify-center cursor-pointer select-none"
|
|
96
|
+
<button
|
|
97
|
+
onClick={() => void handleDelete(page)}
|
|
98
|
+
disabled={busy}
|
|
99
|
+
className="ml-0.5 w-4 h-4 text-gray-600 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all text-xs flex items-center justify-center cursor-pointer select-none"
|
|
100
100
|
>
|
|
101
101
|
×
|
|
102
102
|
</button>
|
|
@@ -106,56 +106,56 @@ export default function PageSwitcher({
|
|
|
106
106
|
|
|
107
107
|
{adding ? (
|
|
108
108
|
<div className="flex items-center gap-1 shrink-0">
|
|
109
|
-
<input
|
|
110
|
-
autoFocus
|
|
111
|
-
value={newName}
|
|
112
|
-
onChange={(e) => {
|
|
113
|
-
setNewName(e.target.value)
|
|
114
|
-
setError(null)
|
|
115
|
-
}}
|
|
116
|
-
onKeyDown={(e) => e.key === 'Enter' && void handleAdd()}
|
|
117
|
-
placeholder="Page name"
|
|
118
|
-
className="w-24 px-2 py-0.5 text-xs bg-white/[0.08] border border-white/[0.12] rounded text-white placeholder-gray-600 focus:outline-none focus:border-accent/50"
|
|
109
|
+
<input
|
|
110
|
+
autoFocus
|
|
111
|
+
value={newName}
|
|
112
|
+
onChange={(e) => {
|
|
113
|
+
setNewName(e.target.value)
|
|
114
|
+
setError(null)
|
|
115
|
+
}}
|
|
116
|
+
onKeyDown={(e) => e.key === 'Enter' && void handleAdd()}
|
|
117
|
+
placeholder="Page name"
|
|
118
|
+
className="w-24 px-2 py-0.5 text-xs bg-white/[0.08] border border-white/[0.12] rounded text-white placeholder-gray-600 focus:outline-none focus:border-accent/50"
|
|
119
119
|
/>
|
|
120
|
-
<input
|
|
121
|
-
value={newRoute}
|
|
122
|
-
onChange={(e) => {
|
|
123
|
-
setNewRoute(e.target.value)
|
|
124
|
-
setError(null)
|
|
125
|
-
}}
|
|
126
|
-
placeholder="/route"
|
|
127
|
-
className="w-20 px-2 py-0.5 text-xs bg-white/[0.08] border border-white/[0.12] rounded text-white placeholder-gray-600 focus:outline-none focus:border-accent/50"
|
|
128
|
-
/>
|
|
129
|
-
<button
|
|
130
|
-
onClick={() => void handleAdd()}
|
|
131
|
-
disabled={busy}
|
|
132
|
-
className="text-xs text-accent hover:text-white transition-colors cursor-pointer select-none disabled:cursor-default disabled:text-gray-600"
|
|
133
|
-
>
|
|
134
|
-
{busy ? 'Adding...' : 'Add'}
|
|
135
|
-
</button>
|
|
136
|
-
<button
|
|
137
|
-
onClick={() => {
|
|
138
|
-
setAdding(false)
|
|
139
|
-
setError(null)
|
|
140
|
-
}}
|
|
141
|
-
className="text-xs text-gray-600 hover:text-gray-300 transition-colors cursor-pointer select-none"
|
|
142
|
-
>
|
|
143
|
-
Cancel
|
|
144
|
-
</button>
|
|
145
|
-
{error && <span className="text-[10px] text-red-400">{error}</span>}
|
|
146
|
-
</div>
|
|
147
|
-
) : (
|
|
148
|
-
<button
|
|
149
|
-
onClick={() => setAdding(true)}
|
|
150
|
-
className="px-2 py-1 text-xs text-gray-600 hover:text-gray-300 hover:bg-white/[0.05] rounded transition-colors shrink-0 cursor-pointer select-none"
|
|
120
|
+
<input
|
|
121
|
+
value={newRoute}
|
|
122
|
+
onChange={(e) => {
|
|
123
|
+
setNewRoute(e.target.value)
|
|
124
|
+
setError(null)
|
|
125
|
+
}}
|
|
126
|
+
placeholder="/route"
|
|
127
|
+
className="w-20 px-2 py-0.5 text-xs bg-white/[0.08] border border-white/[0.12] rounded text-white placeholder-gray-600 focus:outline-none focus:border-accent/50"
|
|
128
|
+
/>
|
|
129
|
+
<button
|
|
130
|
+
onClick={() => void handleAdd()}
|
|
131
|
+
disabled={busy}
|
|
132
|
+
className="text-xs text-accent hover:text-white transition-colors cursor-pointer select-none disabled:cursor-default disabled:text-gray-600"
|
|
133
|
+
>
|
|
134
|
+
{busy ? 'Adding...' : 'Add'}
|
|
135
|
+
</button>
|
|
136
|
+
<button
|
|
137
|
+
onClick={() => {
|
|
138
|
+
setAdding(false)
|
|
139
|
+
setError(null)
|
|
140
|
+
}}
|
|
141
|
+
className="text-xs text-gray-600 hover:text-gray-300 transition-colors cursor-pointer select-none"
|
|
142
|
+
>
|
|
143
|
+
Cancel
|
|
144
|
+
</button>
|
|
145
|
+
{error && <span className="text-[10px] text-red-400">{error}</span>}
|
|
146
|
+
</div>
|
|
147
|
+
) : (
|
|
148
|
+
<button
|
|
149
|
+
onClick={() => setAdding(true)}
|
|
150
|
+
className="px-2 py-1 text-xs text-gray-600 hover:text-gray-300 hover:bg-white/[0.05] rounded transition-colors shrink-0 cursor-pointer select-none"
|
|
151
151
|
>
|
|
152
152
|
+ Add page
|
|
153
153
|
</button>
|
|
154
154
|
)}
|
|
155
155
|
</div>
|
|
156
|
-
)
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function getErrorMessage(error: unknown) {
|
|
160
|
-
return error instanceof Error ? error.message : 'Page operation failed'
|
|
161
|
-
}
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function getErrorMessage(error: unknown) {
|
|
160
|
+
return error instanceof Error ? error.message : 'Page operation failed'
|
|
161
|
+
}
|