@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.
- package/dist/editor.js +44697 -26834
- package/package.json +23 -21
- package/src/build-processor.ts +4 -1
- package/src/collection-scanner.ts +425 -48
- package/src/dev-middleware.ts +26 -203
- package/src/editor/api.ts +1 -22
- package/src/editor/components/ai-chat.tsx +3 -3
- package/src/editor/components/ai-tooltip.tsx +2 -1
- package/src/editor/components/block-editor.tsx +13 -108
- package/src/editor/components/collections-browser.tsx +168 -205
- package/src/editor/components/component-card.tsx +49 -0
- package/src/editor/components/confirm-dialog.tsx +34 -47
- package/src/editor/components/create-page-modal.tsx +529 -101
- package/src/editor/components/delete-page-dialog.tsx +100 -0
- package/src/editor/components/fields.tsx +175 -0
- package/src/editor/components/frontmatter-fields.tsx +281 -70
- package/src/editor/components/frontmatter-sidebar.tsx +223 -0
- package/src/editor/components/highlight-overlay.ts +3 -2
- package/src/editor/components/markdown-editor-overlay.tsx +131 -85
- package/src/editor/components/markdown-inline-editor.tsx +74 -5
- package/src/editor/components/mdx-block-view.tsx +102 -0
- package/src/editor/components/mdx-component-picker.tsx +123 -0
- package/src/editor/components/mdx-props-editor.tsx +94 -0
- package/src/editor/components/media-library.tsx +373 -100
- package/src/editor/components/modal-shell.tsx +87 -0
- package/src/editor/components/prop-editor.tsx +52 -0
- package/src/editor/components/redirect-countdown.tsx +3 -1
- package/src/editor/components/redirects-manager.tsx +269 -0
- package/src/editor/components/reference-picker.tsx +203 -0
- package/src/editor/components/seo-editor.tsx +285 -303
- package/src/editor/components/toast/toast-container.tsx +2 -1
- package/src/editor/components/toolbar.tsx +177 -46
- package/src/editor/constants.ts +26 -0
- package/src/editor/editor.ts +112 -0
- package/src/editor/fetch.ts +62 -0
- package/src/editor/index.tsx +19 -1
- package/src/editor/markdown-api.ts +105 -156
- package/src/editor/milkdown-mdx-plugin.tsx +269 -0
- package/src/editor/signals.ts +206 -13
- package/src/editor/types.ts +52 -1
- package/src/handlers/api-routes.ts +251 -0
- package/src/handlers/component-ops.ts +2 -18
- package/src/handlers/markdown-ops.ts +202 -47
- package/src/handlers/page-ops.ts +229 -0
- package/src/handlers/redirect-ops.ts +163 -0
- package/src/handlers/source-writer.ts +157 -1
- package/src/html-processor.ts +14 -2
- package/src/index.ts +76 -2
- package/src/manifest-writer.ts +19 -1
- package/src/media/contember.ts +2 -1
- package/src/media/local.ts +66 -28
- package/src/media/project-images.ts +81 -0
- package/src/media/s3.ts +32 -11
- package/src/media/types.ts +24 -2
- package/src/shared.ts +27 -0
- package/src/source-finder/collection-finder.ts +219 -41
- package/src/source-finder/index.ts +7 -1
- package/src/source-finder/search-index.ts +178 -36
- package/src/source-finder/snippet-utils.ts +423 -3
- package/src/tsconfig.json +0 -2
- package/src/types.ts +111 -2
- package/src/utils.ts +40 -4
|
@@ -1,31 +1,396 @@
|
|
|
1
|
-
import { useEffect, useMemo } from 'preact/hooks'
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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 (
|
|
385
|
+
if (collections.length === 1) {
|
|
17
386
|
const col = collections[0]
|
|
18
|
-
resetCreatePageState()
|
|
19
387
|
if (col) {
|
|
20
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
412
|
+
<ModalFooter>
|
|
413
|
+
<CancelButton onClick={() => resetCreatePageState()} label="Close" />
|
|
414
|
+
</ModalFooter>
|
|
415
|
+
</>
|
|
76
416
|
)
|
|
77
417
|
}
|
|
78
418
|
|
|
79
|
-
// Single collection auto-selected via useEffect
|
|
419
|
+
// Single collection auto-selected via useEffect
|
|
80
420
|
if (collections.length === 1) return null
|
|
81
421
|
|
|
82
|
-
// Collection picker
|
|
83
422
|
return (
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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'} · {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'} · {col.fields.length} fields
|
|
115
441
|
</div>
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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" />
|