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,302 @@
|
|
|
1
|
+
import { useDraggable } from '@dnd-kit/core'
|
|
2
|
+
import { CSS } from '@dnd-kit/utilities'
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { components } from '../data/componentRegistry'
|
|
5
|
+
import { primitives } from '../data/primitiveRegistry'
|
|
6
|
+
import { useAppStore } from '../stores/useAppStore'
|
|
7
|
+
import type { ComponentDefinition, PrimitiveDefinition } from '../types'
|
|
8
|
+
import ProjectExplorer from './ProjectExplorer'
|
|
9
|
+
|
|
10
|
+
export default function ComponentSidebar({
|
|
11
|
+
view,
|
|
12
|
+
selectedProjectFile,
|
|
13
|
+
onSelectProjectFile,
|
|
14
|
+
}: {
|
|
15
|
+
view: 'files' | 'elements'
|
|
16
|
+
selectedProjectFile: string | null
|
|
17
|
+
onSelectProjectFile: (path: string | null) => void
|
|
18
|
+
}) {
|
|
19
|
+
const {
|
|
20
|
+
activePageId,
|
|
21
|
+
selectedComponentId,
|
|
22
|
+
addComponent,
|
|
23
|
+
addPrimitive,
|
|
24
|
+
addPrimitiveToNode,
|
|
25
|
+
setAppShellComponent,
|
|
26
|
+
} = useAppStore()
|
|
27
|
+
const globalComponents = components.filter((component) =>
|
|
28
|
+
['Navbar', 'Footer'].includes(component.type)
|
|
29
|
+
)
|
|
30
|
+
const pageComponents = components.filter(
|
|
31
|
+
(component) => !['Navbar', 'Footer'].includes(component.type)
|
|
32
|
+
)
|
|
33
|
+
const primitiveGroups = groupPrimitivesByCategory(primitives)
|
|
34
|
+
const [openSections, setOpenSections] = useState<Set<string>>(
|
|
35
|
+
() => new Set(['Global', 'Page Blocks', 'Semantic'])
|
|
36
|
+
)
|
|
37
|
+
const toggleSection = (section: string) => {
|
|
38
|
+
setOpenSections((current) => {
|
|
39
|
+
const next = new Set(current)
|
|
40
|
+
|
|
41
|
+
if (next.has(section)) {
|
|
42
|
+
next.delete(section)
|
|
43
|
+
} else {
|
|
44
|
+
next.add(section)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return next
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
return (
|
|
51
|
+
<aside className="flex h-full w-full flex-col overflow-hidden bg-sidebar">
|
|
52
|
+
<div className="px-3 py-2.5 border-b border-white/[0.06]">
|
|
53
|
+
<p className="text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
|
54
|
+
{view === 'files' ? 'Project files' : 'Elements'}
|
|
55
|
+
</p>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<div
|
|
59
|
+
className={`min-h-0 flex-1 ${
|
|
60
|
+
view === 'files' ? 'overflow-hidden' : 'overflow-y-auto p-2 space-y-1'
|
|
61
|
+
}`}
|
|
62
|
+
>
|
|
63
|
+
{view === 'files' ? (
|
|
64
|
+
<ProjectExplorer
|
|
65
|
+
selectedPath={selectedProjectFile}
|
|
66
|
+
onSelectFile={onSelectProjectFile}
|
|
67
|
+
/>
|
|
68
|
+
) : (
|
|
69
|
+
<>
|
|
70
|
+
<AccordionSection
|
|
71
|
+
title="Global"
|
|
72
|
+
count={globalComponents.length}
|
|
73
|
+
isOpen={openSections.has('Global')}
|
|
74
|
+
onToggle={() => toggleSection('Global')}
|
|
75
|
+
>
|
|
76
|
+
{globalComponents.map((component) => {
|
|
77
|
+
const slot = component.type === 'Footer' ? 'footer' : 'header'
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<ComponentSidebarItem
|
|
81
|
+
key={component.type}
|
|
82
|
+
component={component}
|
|
83
|
+
isDisabled={false}
|
|
84
|
+
onAdd={() => setAppShellComponent(slot, component.type)}
|
|
85
|
+
/>
|
|
86
|
+
)
|
|
87
|
+
})}
|
|
88
|
+
</AccordionSection>
|
|
89
|
+
|
|
90
|
+
<AccordionSection
|
|
91
|
+
title="Page Blocks"
|
|
92
|
+
count={pageComponents.length}
|
|
93
|
+
isOpen={openSections.has('Page Blocks')}
|
|
94
|
+
onToggle={() => toggleSection('Page Blocks')}
|
|
95
|
+
>
|
|
96
|
+
{pageComponents.map((component) => {
|
|
97
|
+
return (
|
|
98
|
+
<ComponentSidebarItem
|
|
99
|
+
key={component.type}
|
|
100
|
+
component={component}
|
|
101
|
+
isDisabled={false}
|
|
102
|
+
onAdd={() => addComponent(activePageId, component.type)}
|
|
103
|
+
/>
|
|
104
|
+
)
|
|
105
|
+
})}
|
|
106
|
+
</AccordionSection>
|
|
107
|
+
|
|
108
|
+
{primitiveGroups.map(([category, items]) => (
|
|
109
|
+
<AccordionSection
|
|
110
|
+
key={category}
|
|
111
|
+
title={category}
|
|
112
|
+
count={items.length}
|
|
113
|
+
isOpen={openSections.has(category)}
|
|
114
|
+
onToggle={() => toggleSection(category)}
|
|
115
|
+
>
|
|
116
|
+
{items.map((primitive) => (
|
|
117
|
+
<PrimitiveSidebarItem
|
|
118
|
+
key={primitive.type}
|
|
119
|
+
primitive={primitive}
|
|
120
|
+
isClickDisabled={selectedComponentId === null}
|
|
121
|
+
onAdd={() => {
|
|
122
|
+
if (selectedComponentId) {
|
|
123
|
+
addPrimitiveToNode(selectedComponentId, primitive.type)
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
addPrimitive(activePageId, primitive.type)
|
|
128
|
+
}}
|
|
129
|
+
/>
|
|
130
|
+
))}
|
|
131
|
+
</AccordionSection>
|
|
132
|
+
))}
|
|
133
|
+
</>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
{view === 'elements' && (
|
|
138
|
+
<div className="px-3 py-2 border-t border-white/[0.06]">
|
|
139
|
+
<p className="text-[10px] text-gray-600">Drag or click to add</p>
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
142
|
+
</aside>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function AccordionSection({
|
|
147
|
+
title,
|
|
148
|
+
count,
|
|
149
|
+
isOpen,
|
|
150
|
+
onToggle,
|
|
151
|
+
children,
|
|
152
|
+
}: {
|
|
153
|
+
title: string
|
|
154
|
+
count: number
|
|
155
|
+
isOpen: boolean
|
|
156
|
+
onToggle: () => void
|
|
157
|
+
children: React.ReactNode
|
|
158
|
+
}) {
|
|
159
|
+
return (
|
|
160
|
+
<section className="border-b border-white/[0.04] pb-1 last:border-b-0">
|
|
161
|
+
<button
|
|
162
|
+
type="button"
|
|
163
|
+
onClick={onToggle}
|
|
164
|
+
className="flex w-full cursor-pointer select-none items-center justify-between rounded px-2 py-1.5 text-left transition-colors hover:bg-white/[0.04]"
|
|
165
|
+
>
|
|
166
|
+
<span className="text-[10px] font-medium uppercase tracking-widest text-gray-500">
|
|
167
|
+
{title}
|
|
168
|
+
</span>
|
|
169
|
+
<span className="flex items-center gap-1 text-[10px] text-gray-600">
|
|
170
|
+
{count}
|
|
171
|
+
<span
|
|
172
|
+
className={`inline-block transition-transform ${
|
|
173
|
+
isOpen ? 'rotate-90' : ''
|
|
174
|
+
}`}
|
|
175
|
+
aria-hidden="true"
|
|
176
|
+
>
|
|
177
|
+
>
|
|
178
|
+
</span>
|
|
179
|
+
</span>
|
|
180
|
+
</button>
|
|
181
|
+
{isOpen && <div className="space-y-0.5 pb-1">{children}</div>}
|
|
182
|
+
</section>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function groupPrimitivesByCategory(items: PrimitiveDefinition[]) {
|
|
187
|
+
const groups = new Map<string, PrimitiveDefinition[]>()
|
|
188
|
+
|
|
189
|
+
for (const item of items) {
|
|
190
|
+
groups.set(item.category, [...(groups.get(item.category) ?? []), item])
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return Array.from(groups.entries())
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function ComponentSidebarItem({
|
|
197
|
+
component,
|
|
198
|
+
isDisabled,
|
|
199
|
+
onAdd,
|
|
200
|
+
}: {
|
|
201
|
+
component: ComponentDefinition
|
|
202
|
+
isDisabled: boolean
|
|
203
|
+
onAdd: () => void
|
|
204
|
+
}) {
|
|
205
|
+
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
|
206
|
+
useDraggable({
|
|
207
|
+
id: `sidebar:${component.type}`,
|
|
208
|
+
disabled: isDisabled,
|
|
209
|
+
data: {
|
|
210
|
+
kind: 'sidebar-component',
|
|
211
|
+
type: component.type,
|
|
212
|
+
},
|
|
213
|
+
})
|
|
214
|
+
const style = {
|
|
215
|
+
transform: CSS.Translate.toString(transform),
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<button
|
|
220
|
+
ref={setNodeRef}
|
|
221
|
+
type="button"
|
|
222
|
+
disabled={isDisabled}
|
|
223
|
+
onClick={onAdd}
|
|
224
|
+
style={style}
|
|
225
|
+
className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md text-left group transition-colors ${
|
|
226
|
+
isDisabled
|
|
227
|
+
? 'opacity-45 cursor-not-allowed'
|
|
228
|
+
: 'cursor-grab select-none hover:bg-white/[0.06] active:cursor-grabbing'
|
|
229
|
+
} ${isDragging ? 'opacity-70 bg-white/[0.08]' : ''}`}
|
|
230
|
+
{...listeners}
|
|
231
|
+
{...attributes}
|
|
232
|
+
>
|
|
233
|
+
<span className="text-base text-gray-500 group-hover:text-accent transition-colors w-5 text-center leading-none">
|
|
234
|
+
{component.icon}
|
|
235
|
+
</span>
|
|
236
|
+
<div className="min-w-0">
|
|
237
|
+
<p className="text-xs font-medium text-gray-300 group-hover:text-white transition-colors leading-tight">
|
|
238
|
+
{component.label}
|
|
239
|
+
</p>
|
|
240
|
+
<p className="text-[10px] text-gray-600 group-hover:text-gray-400 transition-colors leading-tight mt-0.5 truncate">
|
|
241
|
+
{getComponentDescription(component.type, component.description)}
|
|
242
|
+
</p>
|
|
243
|
+
</div>
|
|
244
|
+
</button>
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function PrimitiveSidebarItem({
|
|
249
|
+
primitive,
|
|
250
|
+
isClickDisabled,
|
|
251
|
+
onAdd,
|
|
252
|
+
}: {
|
|
253
|
+
primitive: PrimitiveDefinition
|
|
254
|
+
isClickDisabled: boolean
|
|
255
|
+
onAdd: () => void
|
|
256
|
+
}) {
|
|
257
|
+
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
|
258
|
+
useDraggable({
|
|
259
|
+
id: `primitive:${primitive.type}`,
|
|
260
|
+
data: {
|
|
261
|
+
kind: 'primitive',
|
|
262
|
+
type: primitive.type,
|
|
263
|
+
},
|
|
264
|
+
})
|
|
265
|
+
const style = {
|
|
266
|
+
transform: CSS.Translate.toString(transform),
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return (
|
|
270
|
+
<button
|
|
271
|
+
ref={setNodeRef}
|
|
272
|
+
type="button"
|
|
273
|
+
onClick={onAdd}
|
|
274
|
+
style={style}
|
|
275
|
+
title={
|
|
276
|
+
isClickDisabled
|
|
277
|
+
? `Drag ${primitive.label} onto a component or select a component first`
|
|
278
|
+
: `Add ${primitive.label} to selected component`
|
|
279
|
+
}
|
|
280
|
+
className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md text-left group transition-colors cursor-grab select-none hover:bg-white/[0.06] active:cursor-grabbing ${
|
|
281
|
+
isDragging ? 'opacity-70 bg-white/[0.08]' : ''
|
|
282
|
+
} ${isClickDisabled ? 'opacity-80' : ''}`}
|
|
283
|
+
{...listeners}
|
|
284
|
+
{...attributes}
|
|
285
|
+
>
|
|
286
|
+
<div className="min-w-0">
|
|
287
|
+
<p className="text-xs font-medium text-gray-300 group-hover:text-white transition-colors leading-tight">
|
|
288
|
+
{primitive.label}
|
|
289
|
+
</p>
|
|
290
|
+
<p className="text-[10px] text-gray-600 group-hover:text-gray-400 transition-colors leading-tight mt-0.5 truncate">
|
|
291
|
+
{primitive.description}
|
|
292
|
+
</p>
|
|
293
|
+
</div>
|
|
294
|
+
</button>
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function getComponentDescription(type: string, fallback: string) {
|
|
299
|
+
if (type === 'Navbar') return 'Global header across all pages'
|
|
300
|
+
if (type === 'Footer') return 'Global footer across all pages'
|
|
301
|
+
return fallback
|
|
302
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
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
|
+
|
|
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">
|
|
79
|
+
{pages.map((page) => (
|
|
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 ${
|
|
87
|
+
activePageId === page.id
|
|
88
|
+
? 'bg-white/[0.1] text-white'
|
|
89
|
+
: 'text-gray-500 hover:text-gray-300 hover:bg-white/[0.05]'
|
|
90
|
+
}`}
|
|
91
|
+
>
|
|
92
|
+
{page.name}
|
|
93
|
+
<span className="ml-1 text-gray-600">{page.route}</span>
|
|
94
|
+
</button>
|
|
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"
|
|
100
|
+
>
|
|
101
|
+
×
|
|
102
|
+
</button>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
))}
|
|
106
|
+
|
|
107
|
+
{adding ? (
|
|
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"
|
|
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"
|
|
151
|
+
>
|
|
152
|
+
+ Add page
|
|
153
|
+
</button>
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function getErrorMessage(error: unknown) {
|
|
160
|
+
return error instanceof Error ? error.message : 'Page operation failed'
|
|
161
|
+
}
|