@nuasite/cms 0.18.0 → 0.19.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 (62) hide show
  1. package/dist/editor.js +44697 -26834
  2. package/package.json +23 -21
  3. package/src/build-processor.ts +4 -1
  4. package/src/collection-scanner.ts +425 -48
  5. package/src/dev-middleware.ts +26 -203
  6. package/src/editor/api.ts +1 -22
  7. package/src/editor/components/ai-chat.tsx +3 -3
  8. package/src/editor/components/ai-tooltip.tsx +2 -1
  9. package/src/editor/components/block-editor.tsx +13 -108
  10. package/src/editor/components/collections-browser.tsx +168 -205
  11. package/src/editor/components/component-card.tsx +49 -0
  12. package/src/editor/components/confirm-dialog.tsx +34 -47
  13. package/src/editor/components/create-page-modal.tsx +529 -101
  14. package/src/editor/components/delete-page-dialog.tsx +100 -0
  15. package/src/editor/components/fields.tsx +175 -0
  16. package/src/editor/components/frontmatter-fields.tsx +281 -70
  17. package/src/editor/components/frontmatter-sidebar.tsx +223 -0
  18. package/src/editor/components/highlight-overlay.ts +3 -2
  19. package/src/editor/components/markdown-editor-overlay.tsx +131 -85
  20. package/src/editor/components/markdown-inline-editor.tsx +74 -5
  21. package/src/editor/components/mdx-block-view.tsx +102 -0
  22. package/src/editor/components/mdx-component-picker.tsx +123 -0
  23. package/src/editor/components/mdx-props-editor.tsx +94 -0
  24. package/src/editor/components/media-library.tsx +373 -100
  25. package/src/editor/components/modal-shell.tsx +87 -0
  26. package/src/editor/components/prop-editor.tsx +52 -0
  27. package/src/editor/components/redirect-countdown.tsx +3 -1
  28. package/src/editor/components/redirects-manager.tsx +269 -0
  29. package/src/editor/components/reference-picker.tsx +203 -0
  30. package/src/editor/components/seo-editor.tsx +285 -303
  31. package/src/editor/components/toast/toast-container.tsx +2 -1
  32. package/src/editor/components/toolbar.tsx +177 -46
  33. package/src/editor/constants.ts +26 -0
  34. package/src/editor/editor.ts +112 -0
  35. package/src/editor/fetch.ts +62 -0
  36. package/src/editor/index.tsx +19 -1
  37. package/src/editor/markdown-api.ts +105 -156
  38. package/src/editor/milkdown-mdx-plugin.tsx +269 -0
  39. package/src/editor/signals.ts +206 -13
  40. package/src/editor/types.ts +52 -1
  41. package/src/handlers/api-routes.ts +251 -0
  42. package/src/handlers/component-ops.ts +2 -18
  43. package/src/handlers/markdown-ops.ts +202 -47
  44. package/src/handlers/page-ops.ts +229 -0
  45. package/src/handlers/redirect-ops.ts +163 -0
  46. package/src/handlers/source-writer.ts +157 -1
  47. package/src/html-processor.ts +14 -2
  48. package/src/index.ts +76 -2
  49. package/src/manifest-writer.ts +19 -1
  50. package/src/media/contember.ts +2 -1
  51. package/src/media/local.ts +66 -28
  52. package/src/media/project-images.ts +81 -0
  53. package/src/media/s3.ts +32 -11
  54. package/src/media/types.ts +24 -2
  55. package/src/shared.ts +27 -0
  56. package/src/source-finder/collection-finder.ts +219 -41
  57. package/src/source-finder/index.ts +7 -1
  58. package/src/source-finder/search-index.ts +178 -36
  59. package/src/source-finder/snippet-utils.ts +423 -3
  60. package/src/tsconfig.json +0 -2
  61. package/src/types.ts +111 -2
  62. package/src/utils.ts +40 -4
@@ -1,31 +1,396 @@
1
- import { useEffect, useMemo } from 'preact/hooks'
2
- import { isCreatePageOpen, manifest, openMarkdownEditorForNewPage, resetCreatePageState } from '../signals'
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
2
+ import { slugify } from '../../shared'
3
+ import { checkSlugExists, createPage, duplicatePage, getLayouts } from '../markdown-api'
4
+ import {
5
+ config,
6
+ createPageMode,
7
+ isCreatePageOpen,
8
+ manifest,
9
+ openMarkdownEditorForNewPage,
10
+ resetCreatePageState,
11
+ setCreatePageMode,
12
+ setCreatingPage,
13
+ showToast,
14
+ } from '../signals'
15
+ import type { LayoutInfo } from '../types'
16
+ import { CancelButton, ModalBackdrop, ModalFooter, ModalHeader } from './modal-shell'
3
17
 
4
18
  export function CreatePageModal() {
5
19
  const visible = isCreatePageOpen.value
20
+ const mode = createPageMode.value
6
21
 
7
- // Get collection definitions from manifest (read signal directly for reactivity)
22
+ if (!visible) return null
23
+
24
+ return (
25
+ <ModalBackdrop onClose={() => resetCreatePageState()}>
26
+ {mode === 'pick' && <ModePicker />}
27
+ {mode === 'new' && <NewPageForm />}
28
+ {mode === 'duplicate' && <DuplicatePageForm />}
29
+ {mode === 'collection' && <CollectionPicker />}
30
+ </ModalBackdrop>
31
+ )
32
+ }
33
+
34
+ function ModePicker() {
8
35
  const collectionDefinitions = manifest.value.collectionDefinitions ?? {}
36
+ const hasCollections = Object.keys(collectionDefinitions).length > 0
37
+
38
+ return (
39
+ <>
40
+ <ModalHeader title="Create New Page" onClose={() => resetCreatePageState()} />
41
+ <div class="p-5 space-y-2">
42
+ <ModeCard
43
+ icon={<PageIcon />}
44
+ title="Blank Page"
45
+ description="Start with an empty page template"
46
+ onClick={() => setCreatePageMode('new')}
47
+ />
48
+ <ModeCard
49
+ icon={<DuplicateIcon />}
50
+ title="Duplicate Page"
51
+ description="Copy an existing page to a new URL"
52
+ onClick={() => setCreatePageMode('duplicate')}
53
+ />
54
+ {hasCollections && (
55
+ <ModeCard
56
+ icon={<CollectionIcon />}
57
+ title="Collection Entry"
58
+ description="Create a new content entry"
59
+ onClick={() => setCreatePageMode('collection')}
60
+ />
61
+ )}
62
+ </div>
63
+ </>
64
+ )
65
+ }
66
+
67
+ function ModeCard({ icon, title, description, onClick }: {
68
+ icon: preact.ComponentChildren
69
+ title: string
70
+ description: string
71
+ onClick: () => void
72
+ }) {
73
+ return (
74
+ <button
75
+ type="button"
76
+ onClick={onClick}
77
+ class="w-full flex items-center gap-4 p-4 bg-white/5 hover:bg-white/10 rounded-cms-lg border border-white/10 hover:border-white/20 transition-colors text-left cursor-pointer"
78
+ data-cms-ui
79
+ >
80
+ <div class="shrink-0 w-10 h-10 bg-cms-primary/20 rounded-cms-md flex items-center justify-center">
81
+ {icon}
82
+ </div>
83
+ <div class="flex-1 min-w-0">
84
+ <div class="text-white font-medium">{title}</div>
85
+ <div class="text-white/50 text-sm">{description}</div>
86
+ </div>
87
+ <ChevronRightIcon />
88
+ </button>
89
+ )
90
+ }
91
+
92
+ function useSlugForm() {
93
+ const [title, setTitle] = useState('')
94
+ const [slug, setSlug] = useState('')
95
+ const [slugManual, setSlugManual] = useState(false)
96
+ const [slugError, setSlugError] = useState<string | null>(null)
97
+ const [slugChecking, setSlugChecking] = useState(false)
98
+ const [isSubmitting, setIsSubmitting] = useState(false)
99
+ const [submitPhase, setSubmitPhase] = useState<'creating' | 'preparing' | null>(null)
100
+ const checkTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
101
+ const abortRef = useRef<AbortController | null>(null)
102
+
103
+ useEffect(() => {
104
+ return () => {
105
+ if (checkTimeoutRef.current) clearTimeout(checkTimeoutRef.current)
106
+ abortRef.current?.abort()
107
+ }
108
+ }, [])
109
+
110
+ const triggerSlugCheck = useCallback((value: string) => {
111
+ debouncedSlugCheck(value, checkTimeoutRef, abortRef, setSlugChecking, setSlugError)
112
+ }, [])
113
+
114
+ const handleTitleChange = useCallback((value: string) => {
115
+ setTitle(value)
116
+ if (!slugManual) {
117
+ const autoSlug = slugify(value)
118
+ setSlug(autoSlug)
119
+ triggerSlugCheck(autoSlug)
120
+ }
121
+ }, [slugManual, triggerSlugCheck])
122
+
123
+ const handleSlugChange = useCallback((value: string) => {
124
+ setSlugManual(true)
125
+ setSlug(value)
126
+ triggerSlugCheck(value)
127
+ }, [triggerSlugCheck])
128
+
129
+ const submitPage = useCallback(async (
130
+ apiCall: () => Promise<{ success: boolean; url?: string; error?: string }>,
131
+ errorLabel: string,
132
+ ) => {
133
+ setIsSubmitting(true)
134
+ setSubmitPhase('creating')
135
+ setCreatingPage(true)
136
+
137
+ const result = await apiCall()
138
+
139
+ if (result.success && result.url) {
140
+ setSubmitPhase('preparing')
141
+ await waitForPageReady(result.url)
142
+ setCreatingPage(false)
143
+ window.location.href = result.url
144
+ } else {
145
+ setIsSubmitting(false)
146
+ setSubmitPhase(null)
147
+ setCreatingPage(false)
148
+ showToast(result.error || errorLabel, 'error')
149
+ }
150
+ }, [])
151
+
152
+ const resetSlugManual = useCallback(() => setSlugManual(false), [])
153
+
154
+ const prefillFromTitle = useCallback((pageTitle: string) => {
155
+ setTitle(pageTitle)
156
+ const autoSlug = slugify(pageTitle)
157
+ setSlug(autoSlug)
158
+ triggerSlugCheck(autoSlug)
159
+ }, [triggerSlugCheck])
160
+
161
+ return {
162
+ title,
163
+ setTitle,
164
+ slug,
165
+ setSlug,
166
+ slugManual,
167
+ resetSlugManual,
168
+ slugError,
169
+ slugChecking,
170
+ isSubmitting,
171
+ submitPhase,
172
+ handleTitleChange,
173
+ handleSlugChange,
174
+ submitPage,
175
+ prefillFromTitle,
176
+ }
177
+ }
9
178
 
10
- const collections = useMemo(() => {
11
- return Object.values(collectionDefinitions)
12
- }, [collectionDefinitions])
179
+ function NewPageForm() {
180
+ const form = useSlugForm()
181
+ const [layouts, setLayouts] = useState<LayoutInfo[]>([])
182
+ const [selectedLayout, setSelectedLayout] = useState<string | undefined>(undefined)
183
+
184
+ useEffect(() => {
185
+ const cfg = config.value
186
+ if (!cfg) return
187
+ getLayouts(cfg).then(({ layouts: l }) => {
188
+ setLayouts(l)
189
+ if (l.length > 0) setSelectedLayout(l[0]!.path)
190
+ })
191
+ }, [])
192
+
193
+ const handleSubmit = useCallback(async () => {
194
+ const cfg = config.value
195
+ if (!cfg || !form.title.trim() || !form.slug.trim() || form.slugError) return
196
+ form.submitPage(
197
+ () => createPage(cfg, { title: form.title.trim(), slug: form.slug.trim(), layoutPath: selectedLayout }),
198
+ 'Failed to create page',
199
+ )
200
+ }, [form.title, form.slug, form.slugError, selectedLayout, form.submitPage])
201
+
202
+ const canSubmit = form.title.trim() && form.slug.trim() && !form.slugError && !form.slugChecking && !form.isSubmitting
203
+
204
+ if (form.submitPhase) {
205
+ return <PageCreatingOverlay phase={form.submitPhase} slug={form.slug} />
206
+ }
207
+
208
+ return (
209
+ <>
210
+ <ModalHeader title="New Blank Page" onBack={() => setCreatePageMode('pick')} onClose={() => resetCreatePageState()} />
211
+ <div class="p-5 space-y-4">
212
+ <Field label="Title">
213
+ <input
214
+ type="text"
215
+ value={form.title}
216
+ onInput={(e) => form.handleTitleChange((e.target as HTMLInputElement).value)}
217
+ placeholder="My New Page"
218
+ class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md text-white placeholder:text-white/30 focus:outline-none focus:border-cms-primary/50"
219
+ autoFocus
220
+ data-cms-ui
221
+ />
222
+ </Field>
223
+
224
+ <Field label="URL Path" error={form.slugError} checking={form.slugChecking}>
225
+ <div class="flex items-center gap-1">
226
+ <span class="text-white/40 text-sm">/</span>
227
+ <input
228
+ type="text"
229
+ value={form.slug}
230
+ onInput={(e) => form.handleSlugChange((e.target as HTMLInputElement).value)}
231
+ placeholder="my-new-page"
232
+ class="flex-1 px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md text-white placeholder:text-white/30 focus:outline-none focus:border-cms-primary/50"
233
+ data-cms-ui
234
+ />
235
+ </div>
236
+ </Field>
237
+
238
+ {layouts.length > 0 && (
239
+ <Field label="Layout">
240
+ <select
241
+ value={selectedLayout}
242
+ onChange={(e) => setSelectedLayout((e.target as HTMLSelectElement).value || undefined)}
243
+ class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md text-white focus:outline-none focus:border-cms-primary/50"
244
+ data-cms-ui
245
+ >
246
+ {layouts.map((l) => <option key={l.path} value={l.path}>{l.name}</option>)}
247
+ <option value="">No layout</option>
248
+ </select>
249
+ </Field>
250
+ )}
251
+ </div>
252
+
253
+ <ModalFooter>
254
+ <CancelButton onClick={() => resetCreatePageState()} />
255
+ <button
256
+ type="button"
257
+ onClick={handleSubmit}
258
+ disabled={!canSubmit}
259
+ class="px-5 py-2.5 text-sm font-medium rounded-cms-pill transition-colors cursor-pointer bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover disabled:opacity-40 disabled:cursor-not-allowed"
260
+ data-cms-ui
261
+ >
262
+ Create Page
263
+ </button>
264
+ </ModalFooter>
265
+ </>
266
+ )
267
+ }
268
+
269
+ function DuplicatePageForm() {
270
+ const pages = manifest.value.pages ?? []
271
+ const [sourcePath, setSourcePath] = useState(pages[0]?.pathname ?? '')
272
+ const [createRedirect, setCreateRedirect] = useState(false)
273
+ const form = useSlugForm()
274
+
275
+ // Pre-fill title from selected source page
276
+ useEffect(() => {
277
+ const page = pages.find((p) => p.pathname === sourcePath)
278
+ if (page?.title && !form.title) {
279
+ form.prefillFromTitle(page.title)
280
+ }
281
+ }, [sourcePath])
282
+
283
+ const handleSubmit = useCallback(async () => {
284
+ const cfg = config.value
285
+ if (!cfg || !sourcePath || !form.slug.trim() || form.slugError) return
286
+ form.submitPage(
287
+ () =>
288
+ duplicatePage(cfg, {
289
+ sourcePagePath: sourcePath,
290
+ slug: form.slug.trim(),
291
+ title: form.title.trim() || undefined,
292
+ createRedirect,
293
+ }),
294
+ 'Failed to duplicate page',
295
+ )
296
+ }, [sourcePath, form.title, form.slug, form.slugError, createRedirect, form.submitPage])
297
+
298
+ const canSubmit = sourcePath && form.slug.trim() && !form.slugError && !form.slugChecking && !form.isSubmitting
299
+
300
+ if (form.submitPhase) {
301
+ return <PageCreatingOverlay phase={form.submitPhase} slug={form.slug} />
302
+ }
303
+
304
+ return (
305
+ <>
306
+ <ModalHeader title="Duplicate Page" onBack={() => setCreatePageMode('pick')} onClose={() => resetCreatePageState()} />
307
+ <div class="p-5 space-y-4">
308
+ <Field label="Source Page">
309
+ <select
310
+ value={sourcePath}
311
+ onChange={(e) => {
312
+ setSourcePath((e.target as HTMLSelectElement).value)
313
+ form.resetSlugManual()
314
+ }}
315
+ class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md text-white focus:outline-none focus:border-cms-primary/50"
316
+ data-cms-ui
317
+ >
318
+ {pages.map((p) => (
319
+ <option key={p.pathname} value={p.pathname}>
320
+ {p.title ? `${p.title} (${p.pathname})` : p.pathname}
321
+ </option>
322
+ ))}
323
+ </select>
324
+ </Field>
325
+
326
+ <Field label="New Title">
327
+ <input
328
+ type="text"
329
+ value={form.title}
330
+ onInput={(e) => form.handleTitleChange((e.target as HTMLInputElement).value)}
331
+ placeholder="Page title"
332
+ class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md text-white placeholder:text-white/30 focus:outline-none focus:border-cms-primary/50"
333
+ data-cms-ui
334
+ />
335
+ </Field>
336
+
337
+ <Field label="New URL Path" error={form.slugError} checking={form.slugChecking}>
338
+ <div class="flex items-center gap-1">
339
+ <span class="text-white/40 text-sm">/</span>
340
+ <input
341
+ type="text"
342
+ value={form.slug}
343
+ onInput={(e) => form.handleSlugChange((e.target as HTMLInputElement).value)}
344
+ placeholder="new-page-slug"
345
+ class="flex-1 px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md text-white placeholder:text-white/30 focus:outline-none focus:border-cms-primary/50"
346
+ data-cms-ui
347
+ />
348
+ </div>
349
+ </Field>
350
+
351
+ <label class="flex items-center gap-2.5 cursor-pointer" data-cms-ui>
352
+ <input
353
+ type="checkbox"
354
+ checked={createRedirect}
355
+ onChange={(e) => setCreateRedirect((e.target as HTMLInputElement).checked)}
356
+ class="w-4 h-4 rounded accent-cms-primary"
357
+ data-cms-ui
358
+ />
359
+ <span class="text-sm text-white/70">Create redirect from source URL (307)</span>
360
+ </label>
361
+ </div>
362
+
363
+ <ModalFooter>
364
+ <CancelButton onClick={() => resetCreatePageState()} />
365
+ <button
366
+ type="button"
367
+ onClick={handleSubmit}
368
+ disabled={!canSubmit}
369
+ class="px-5 py-2.5 text-sm font-medium rounded-cms-pill transition-colors cursor-pointer bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover disabled:opacity-40 disabled:cursor-not-allowed"
370
+ data-cms-ui
371
+ >
372
+ Duplicate Page
373
+ </button>
374
+ </ModalFooter>
375
+ </>
376
+ )
377
+ }
378
+
379
+ function CollectionPicker() {
380
+ const collectionDefinitions = manifest.value.collectionDefinitions ?? {}
381
+ const collections = useMemo(() => Object.values(collectionDefinitions), [collectionDefinitions])
13
382
 
14
383
  // Single collection — skip picker and go straight to editor
15
384
  useEffect(() => {
16
- if (visible && collections.length === 1) {
385
+ if (collections.length === 1) {
17
386
  const col = collections[0]
18
- resetCreatePageState()
19
387
  if (col) {
20
- openMarkdownEditorForNewPage(col?.name, col)
388
+ resetCreatePageState()
389
+ openMarkdownEditorForNewPage(col.name, col)
21
390
  }
22
391
  }
23
392
  }, [collections])
24
393
 
25
- const handleClose = () => {
26
- resetCreatePageState()
27
- }
28
-
29
394
  const handleSelectCollection = (name: string) => {
30
395
  const def = collectionDefinitions[name]
31
396
  if (def) {
@@ -34,114 +399,177 @@ export function CreatePageModal() {
34
399
  }
35
400
  }
36
401
 
37
- if (!visible) return null
38
-
39
- // No collections available
40
402
  if (collections.length === 0) {
41
403
  return (
42
- <div
43
- class="fixed inset-0 z-2147483647 flex items-center justify-center bg-black/60 backdrop-blur-sm"
44
- onClick={handleClose}
45
- data-cms-ui
46
- >
47
- <div
48
- class="bg-cms-dark rounded-cms-xl shadow-[0_8px_32px_rgba(0,0,0,0.4)] max-w-md w-full border border-white/10"
49
- onClick={(e) => e.stopPropagation()}
50
- data-cms-ui
51
- >
52
- <div class="flex items-center justify-between p-5 border-b border-white/10">
53
- <h2 class="text-lg font-semibold text-white">Create New Page</h2>
54
- <CloseButton onClick={handleClose} />
55
- </div>
56
- <div class="p-8 text-center">
57
- <div class="text-white/60 mb-4">
58
- No content collections found.
59
- </div>
60
- <p class="text-white/40 text-sm">
61
- Add markdown files to <code class="bg-white/10 px-1.5 py-0.5 rounded">src/content/</code> subdirectories to enable page creation.
62
- </p>
63
- </div>
64
- <div class="flex items-center justify-end p-5 border-t border-white/10 bg-white/5 rounded-b-cms-xl">
65
- <button
66
- type="button"
67
- onClick={handleClose}
68
- class="px-4 py-2.5 text-sm text-white/80 font-medium rounded-cms-pill hover:bg-white/10 hover:text-white transition-colors"
69
- data-cms-ui
70
- >
71
- Close
72
- </button>
73
- </div>
404
+ <>
405
+ <ModalHeader title="Collection Entry" onBack={() => setCreatePageMode('pick')} onClose={() => resetCreatePageState()} />
406
+ <div class="p-8 text-center">
407
+ <div class="text-white/60 mb-4">No content collections found.</div>
408
+ <p class="text-white/40 text-sm">
409
+ Add markdown files to <code class="bg-white/10 px-1.5 py-0.5 rounded">src/content/</code> subdirectories to enable page creation.
410
+ </p>
74
411
  </div>
75
- </div>
412
+ <ModalFooter>
413
+ <CancelButton onClick={() => resetCreatePageState()} label="Close" />
414
+ </ModalFooter>
415
+ </>
76
416
  )
77
417
  }
78
418
 
79
- // Single collection auto-selected via useEffect above
419
+ // Single collection auto-selected via useEffect
80
420
  if (collections.length === 1) return null
81
421
 
82
- // Collection picker
83
422
  return (
84
- <div
85
- class="fixed inset-0 z-2147483647 flex items-center justify-center bg-black/60 backdrop-blur-sm"
86
- onClick={handleClose}
87
- data-cms-ui
88
- >
89
- <div
90
- class="bg-cms-dark rounded-cms-xl shadow-[0_8px_32px_rgba(0,0,0,0.4)] max-w-md w-full border border-white/10"
91
- onClick={(e) => e.stopPropagation()}
92
- data-cms-ui
93
- >
94
- <div class="flex items-center justify-between p-5 border-b border-white/10">
95
- <h2 class="text-lg font-semibold text-white">Choose Collection</h2>
96
- <CloseButton onClick={handleClose} />
97
- </div>
98
- <div class="p-5 space-y-2">
99
- {collections.map((col) => (
100
- <button
101
- key={col.name}
102
- type="button"
103
- onClick={() => handleSelectCollection(col.name)}
104
- class="w-full flex items-center gap-4 p-4 bg-white/5 hover:bg-white/10 rounded-cms-lg border border-white/10 hover:border-white/20 transition-colors text-left"
105
- data-cms-ui
106
- >
107
- <div class="shrink-0 w-10 h-10 bg-cms-primary/20 rounded-cms-md flex items-center justify-center">
108
- <CollectionIcon />
109
- </div>
110
- <div class="flex-1 min-w-0">
111
- <div class="text-white font-medium">{col.label}</div>
112
- <div class="text-white/50 text-sm">
113
- {col.entryCount} {col.entryCount === 1 ? 'entry' : 'entries'} &middot; {col.fields.length} fields
114
- </div>
423
+ <>
424
+ <ModalHeader title="Choose Collection" onBack={() => setCreatePageMode('pick')} onClose={() => resetCreatePageState()} />
425
+ <div class="p-5 space-y-2">
426
+ {collections.map((col) => (
427
+ <button
428
+ key={col.name}
429
+ type="button"
430
+ onClick={() => handleSelectCollection(col.name)}
431
+ class="w-full flex items-center gap-4 p-4 bg-white/5 hover:bg-white/10 rounded-cms-lg border border-white/10 hover:border-white/20 transition-colors text-left cursor-pointer"
432
+ data-cms-ui
433
+ >
434
+ <div class="shrink-0 w-10 h-10 bg-cms-primary/20 rounded-cms-md flex items-center justify-center">
435
+ <CollectionIcon />
436
+ </div>
437
+ <div class="flex-1 min-w-0">
438
+ <div class="text-white font-medium">{col.label}</div>
439
+ <div class="text-white/50 text-sm">
440
+ {col.entryCount} {col.entryCount === 1 ? 'entry' : 'entries'} &middot; {col.fields.length} fields
115
441
  </div>
116
- <ChevronRightIcon />
117
- </button>
118
- ))}
442
+ </div>
443
+ <ChevronRightIcon />
444
+ </button>
445
+ ))}
446
+ </div>
447
+ </>
448
+ )
449
+ }
450
+
451
+ function Field({ label, error, checking, children }: {
452
+ label: string
453
+ error?: string | null
454
+ checking?: boolean
455
+ children: preact.ComponentChildren
456
+ }) {
457
+ return (
458
+ <div class="space-y-1.5">
459
+ <label class="text-sm font-medium text-white/70" data-cms-ui>{label}</label>
460
+ {children}
461
+ {checking && <p class="text-xs text-white/40">Checking availability...</p>}
462
+ {error && <p class="text-xs text-red-400">{error}</p>}
463
+ </div>
464
+ )
465
+ }
466
+
467
+ /**
468
+ * Loading overlay shown inside the modal while a page is being created
469
+ * and the dev server is processing the new file.
470
+ */
471
+ function PageCreatingOverlay({ phase, slug }: { phase: 'creating' | 'preparing'; slug: string }) {
472
+ return (
473
+ <div class="p-10 flex flex-col items-center gap-4" data-cms-ui>
474
+ <Spinner />
475
+ <div class="text-center">
476
+ <div class="text-white font-medium">
477
+ {phase === 'creating' ? 'Creating page...' : 'Preparing page...'}
119
478
  </div>
479
+ <div class="text-white/40 text-sm mt-1">/{slug}</div>
120
480
  </div>
121
481
  </div>
122
482
  )
123
483
  }
124
484
 
125
- // ============================================================================
126
- // Icons
127
- // ============================================================================
485
+ function Spinner() {
486
+ return (
487
+ <svg class="w-8 h-8 animate-spin text-cms-primary" viewBox="0 0 24 24" fill="none" data-cms-ui>
488
+ <circle class="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" />
489
+ <path class="opacity-80" d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
490
+ </svg>
491
+ )
492
+ }
493
+
494
+ /**
495
+ * Poll a URL until the dev server returns a non-404 response,
496
+ * so navigation doesn't land on a 404 while Astro processes the new file.
497
+ */
498
+ async function waitForPageReady(url: string, maxAttempts = 20, intervalMs = 250): Promise<void> {
499
+ for (let i = 0; i < maxAttempts; i++) {
500
+ try {
501
+ const res = await fetch(url, { method: 'HEAD' })
502
+ if (res.status !== 404) return
503
+ } catch {
504
+ // Network error — server might be restarting, keep trying
505
+ }
506
+ await new Promise((r) => setTimeout(r, intervalMs))
507
+ }
508
+ }
509
+
510
+ function debouncedSlugCheck(
511
+ slug: string,
512
+ timeoutRef: preact.RefObject<ReturnType<typeof setTimeout> | null>,
513
+ abortRef: preact.RefObject<AbortController | null>,
514
+ setChecking: (v: boolean) => void,
515
+ setError: (v: string | null) => void,
516
+ ) {
517
+ if (timeoutRef.current) clearTimeout(timeoutRef.current)
518
+ abortRef.current?.abort()
519
+
520
+ if (!slug.trim()) {
521
+ setError(null)
522
+ setChecking(false)
523
+ return
524
+ }
525
+
526
+ setChecking(true)
527
+ timeoutRef.current = setTimeout(async () => {
528
+ const cfg = config.value
529
+ if (!cfg) {
530
+ setChecking(false)
531
+ return
532
+ }
128
533
 
129
- function CloseButton({ onClick }: { onClick: () => void }) {
534
+ const controller = new AbortController()
535
+ abortRef.current = controller
536
+
537
+ const result = await checkSlugExists(cfg, slug, controller.signal)
538
+
539
+ if (controller.signal.aborted) return
540
+
541
+ setChecking(false)
542
+ setError(result.exists ? `Page already exists at /${slug}` : null)
543
+ }, 300)
544
+ }
545
+
546
+ function PageIcon() {
130
547
  return (
131
- <button
132
- type="button"
133
- onClick={onClick}
134
- class="text-white/50 hover:text-white p-1.5 hover:bg-white/10 rounded-full transition-colors"
135
- data-cms-ui
136
- >
137
- <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
138
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
139
- </svg>
140
- </button>
548
+ <svg class="w-5 h-5 text-cms-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
549
+ <path
550
+ stroke-linecap="round"
551
+ stroke-linejoin="round"
552
+ stroke-width="2"
553
+ d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
554
+ />
555
+ </svg>
556
+ )
557
+ }
558
+
559
+ function DuplicateIcon() {
560
+ return (
561
+ <svg class="w-5 h-5 text-cms-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
562
+ <path
563
+ stroke-linecap="round"
564
+ stroke-linejoin="round"
565
+ stroke-width="2"
566
+ d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
567
+ />
568
+ </svg>
141
569
  )
142
570
  }
143
571
 
144
- function CollectionIcon() {
572
+ export function CollectionIcon() {
145
573
  return (
146
574
  <svg class="w-5 h-5 text-cms-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
147
575
  <path
@@ -154,7 +582,7 @@ function CollectionIcon() {
154
582
  )
155
583
  }
156
584
 
157
- function ChevronRightIcon() {
585
+ export function ChevronRightIcon() {
158
586
  return (
159
587
  <svg class="w-5 h-5 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
160
588
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />