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
@@ -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
+ }