@nuasite/cms 0.18.1 → 0.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/editor.js +52746 -36711
- package/package.json +16 -14
- 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 +78 -14
- 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/types.ts +111 -2
- package/src/utils.ts +40 -4
|
@@ -1,7 +1,36 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import type { ComponentChildren } from 'preact'
|
|
2
|
+
import { useEffect, useState } from 'preact/hooks'
|
|
3
|
+
import { renameMarkdownPage } from '../markdown-api'
|
|
4
|
+
import {
|
|
5
|
+
config,
|
|
6
|
+
manifest,
|
|
7
|
+
markdownEditorState,
|
|
8
|
+
openMediaLibraryWithCallback,
|
|
9
|
+
showToast,
|
|
10
|
+
updateMarkdownFrontmatter,
|
|
11
|
+
updateMarkdownPageMeta,
|
|
12
|
+
} from '../signals'
|
|
3
13
|
import type { CollectionDefinition, FieldDefinition, MarkdownPageEntry } from '../types'
|
|
4
|
-
import { ComboBoxField, ImageField, NumberField, TextField, ToggleField } from './fields'
|
|
14
|
+
import { ComboBoxField, ImageField, MultiSelectField, NumberField, TextField, ToggleField } from './fields'
|
|
15
|
+
import { groupFields } from './frontmatter-sidebar'
|
|
16
|
+
|
|
17
|
+
function isArrayOfObjects(value: unknown[]): value is Record<string, unknown>[] {
|
|
18
|
+
return value.length > 0 && typeof value[0] === 'object' && value[0] !== null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function FieldGroupHeader({ group, children }: { group: string | null; children: ComponentChildren }) {
|
|
22
|
+
return (
|
|
23
|
+
<>
|
|
24
|
+
{group && (
|
|
25
|
+
<div class="col-span-2 pt-2" data-cms-ui>
|
|
26
|
+
<h4 class="text-xs uppercase tracking-wider text-white/40 font-medium">{group}</h4>
|
|
27
|
+
<div class="border-t border-white/10 mt-1.5" />
|
|
28
|
+
</div>
|
|
29
|
+
)}
|
|
30
|
+
{children}
|
|
31
|
+
</>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
5
34
|
|
|
6
35
|
// ============================================================================
|
|
7
36
|
// Generic Frontmatter Field (auto-detect by value type)
|
|
@@ -18,11 +47,7 @@ export function FrontmatterField({
|
|
|
18
47
|
value,
|
|
19
48
|
onChange,
|
|
20
49
|
}: FrontmatterFieldProps) {
|
|
21
|
-
|
|
22
|
-
const label = fieldKey
|
|
23
|
-
.replace(/([A-Z])/g, ' $1')
|
|
24
|
-
.replace(/^./, (str) => str.toUpperCase())
|
|
25
|
-
.trim()
|
|
50
|
+
const label = formatFieldLabel(fieldKey)
|
|
26
51
|
|
|
27
52
|
// Detect field type based on value
|
|
28
53
|
const isBoolean = typeof value === 'boolean'
|
|
@@ -64,14 +89,26 @@ export function FrontmatterField({
|
|
|
64
89
|
)
|
|
65
90
|
}
|
|
66
91
|
|
|
67
|
-
// Array field (e.g., categories)
|
|
92
|
+
// Array field (e.g., categories)
|
|
68
93
|
if (isArray) {
|
|
94
|
+
const items = value as unknown[]
|
|
95
|
+
if (isArrayOfObjects(items)) {
|
|
96
|
+
return (
|
|
97
|
+
<ArrayOfObjectsField
|
|
98
|
+
label={label}
|
|
99
|
+
items={items as Record<string, unknown>[]}
|
|
100
|
+
onChange={onChange}
|
|
101
|
+
/>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
// Array of primitives — comma-separated input
|
|
105
|
+
const stringItems = items.map(v => typeof v === 'string' ? v : String(v))
|
|
69
106
|
return (
|
|
70
107
|
<div class="flex flex-col gap-1 col-span-2" data-cms-ui>
|
|
71
108
|
<label class="text-xs text-white/60 font-medium">{label}</label>
|
|
72
109
|
<input
|
|
73
110
|
type="text"
|
|
74
|
-
value={
|
|
111
|
+
value={stringItems.join(', ')}
|
|
75
112
|
onChange={(e) => {
|
|
76
113
|
const inputValue = (e.target as HTMLInputElement).value
|
|
77
114
|
const arrayValue = inputValue
|
|
@@ -141,14 +178,19 @@ export function FrontmatterField({
|
|
|
141
178
|
interface CreateModeFrontmatterProps {
|
|
142
179
|
page: MarkdownPageEntry
|
|
143
180
|
collectionDefinition: CollectionDefinition
|
|
181
|
+
fields?: FieldDefinition[]
|
|
144
182
|
onSlugManualEdit: () => void
|
|
145
183
|
}
|
|
146
184
|
|
|
147
185
|
export function CreateModeFrontmatter({
|
|
148
186
|
page,
|
|
149
187
|
collectionDefinition,
|
|
188
|
+
fields,
|
|
150
189
|
onSlugManualEdit,
|
|
151
190
|
}: CreateModeFrontmatterProps) {
|
|
191
|
+
const displayFields = fields ?? collectionDefinition.fields
|
|
192
|
+
const groups = groupFields(displayFields)
|
|
193
|
+
|
|
152
194
|
return (
|
|
153
195
|
<div class="space-y-4">
|
|
154
196
|
{/* Slug field */}
|
|
@@ -181,13 +223,17 @@ export function CreateModeFrontmatter({
|
|
|
181
223
|
|
|
182
224
|
{/* Schema fields */}
|
|
183
225
|
<div class="grid grid-cols-2 gap-4">
|
|
184
|
-
{
|
|
185
|
-
<
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
226
|
+
{groups.map((group, gi) => (
|
|
227
|
+
<FieldGroupHeader key={gi} group={group.group}>
|
|
228
|
+
{group.fields.map((field) => (
|
|
229
|
+
<SchemaFrontmatterField
|
|
230
|
+
key={field.name}
|
|
231
|
+
field={field}
|
|
232
|
+
value={page.frontmatter[field.name]}
|
|
233
|
+
onChange={(newValue) => updateMarkdownFrontmatter({ [field.name]: newValue })}
|
|
234
|
+
/>
|
|
235
|
+
))}
|
|
236
|
+
</FieldGroupHeader>
|
|
191
237
|
))}
|
|
192
238
|
</div>
|
|
193
239
|
</div>
|
|
@@ -198,15 +244,81 @@ export function CreateModeFrontmatter({
|
|
|
198
244
|
// Edit Mode Frontmatter — uses schema fields when available, falls back to generic
|
|
199
245
|
// ============================================================================
|
|
200
246
|
|
|
247
|
+
function SlugField({ page }: { page: MarkdownPageEntry }) {
|
|
248
|
+
const [localSlug, setLocalSlug] = useState(page.slug)
|
|
249
|
+
const [isRenaming, setIsRenaming] = useState(false)
|
|
250
|
+
const isDirty = localSlug !== page.slug
|
|
251
|
+
|
|
252
|
+
useEffect(() => {
|
|
253
|
+
setLocalSlug(page.slug)
|
|
254
|
+
}, [page.slug])
|
|
255
|
+
|
|
256
|
+
const handleRename = async () => {
|
|
257
|
+
if (!isDirty || isRenaming) return
|
|
258
|
+
const trimmed = localSlug.trim()
|
|
259
|
+
if (!trimmed) {
|
|
260
|
+
setLocalSlug(page.slug)
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
setIsRenaming(true)
|
|
264
|
+
try {
|
|
265
|
+
const result = await renameMarkdownPage(config.value, page.filePath, trimmed)
|
|
266
|
+
if (result.success && result.newSlug && result.newFilePath) {
|
|
267
|
+
updateMarkdownPageMeta({ slug: result.newSlug, filePath: result.newFilePath })
|
|
268
|
+
setLocalSlug(result.newSlug)
|
|
269
|
+
showToast('Slug updated', 'success')
|
|
270
|
+
} else {
|
|
271
|
+
showToast(result.error || 'Failed to rename', 'error')
|
|
272
|
+
setLocalSlug(page.slug)
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
showToast('Failed to rename', 'error')
|
|
276
|
+
setLocalSlug(page.slug)
|
|
277
|
+
} finally {
|
|
278
|
+
setIsRenaming(false)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
<div>
|
|
284
|
+
<label class="block text-xs font-medium text-white/70 mb-1.5">
|
|
285
|
+
URL Slug
|
|
286
|
+
</label>
|
|
287
|
+
<div class="flex gap-2">
|
|
288
|
+
<input
|
|
289
|
+
type="text"
|
|
290
|
+
value={localSlug}
|
|
291
|
+
onInput={(e) => setLocalSlug((e.target as HTMLInputElement).value)}
|
|
292
|
+
onBlur={handleRename}
|
|
293
|
+
onKeyDown={(e) => {
|
|
294
|
+
if (e.key === 'Enter') {
|
|
295
|
+
e.preventDefault()
|
|
296
|
+
;(e.target as HTMLInputElement).blur()
|
|
297
|
+
}
|
|
298
|
+
}}
|
|
299
|
+
class={`flex-1 px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white focus:outline-none focus:border-white/40 ${
|
|
300
|
+
isDirty ? 'border-cms-primary' : 'border-white/20'
|
|
301
|
+
}`}
|
|
302
|
+
disabled={isRenaming}
|
|
303
|
+
data-cms-ui
|
|
304
|
+
/>
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
|
|
201
310
|
interface EditModeFrontmatterProps {
|
|
202
311
|
page: MarkdownPageEntry
|
|
203
312
|
collectionDefinition?: CollectionDefinition
|
|
313
|
+
fields?: FieldDefinition[]
|
|
204
314
|
}
|
|
205
315
|
|
|
206
316
|
export function EditModeFrontmatter({
|
|
207
317
|
page,
|
|
208
318
|
collectionDefinition,
|
|
319
|
+
fields,
|
|
209
320
|
}: EditModeFrontmatterProps) {
|
|
321
|
+
const displayFields = fields ?? collectionDefinition?.fields ?? []
|
|
210
322
|
// Collect schema field names for filtering extra keys
|
|
211
323
|
const schemaFieldNames = new Set(
|
|
212
324
|
collectionDefinition?.fields.map((f) => f.name) ?? [],
|
|
@@ -215,34 +327,27 @@ export function EditModeFrontmatter({
|
|
|
215
327
|
const extraKeys = Object.keys(page.frontmatter).filter(
|
|
216
328
|
(key) => !schemaFieldNames.has(key),
|
|
217
329
|
)
|
|
330
|
+
const groups = groupFields(displayFields)
|
|
218
331
|
|
|
219
332
|
return (
|
|
220
333
|
<div class="space-y-4">
|
|
221
|
-
{/* Slug field
|
|
222
|
-
<
|
|
223
|
-
<label class="block text-xs font-medium text-white/70 mb-1.5">
|
|
224
|
-
URL Slug
|
|
225
|
-
</label>
|
|
226
|
-
<input
|
|
227
|
-
type="text"
|
|
228
|
-
value={page.slug}
|
|
229
|
-
class="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-cms-sm text-sm text-white/50 focus:outline-none cursor-not-allowed"
|
|
230
|
-
disabled
|
|
231
|
-
data-cms-ui
|
|
232
|
-
/>
|
|
233
|
-
</div>
|
|
334
|
+
{/* Slug field */}
|
|
335
|
+
<SlugField page={page} />
|
|
234
336
|
<div class="grid grid-cols-2 gap-4">
|
|
235
337
|
{collectionDefinition
|
|
236
338
|
? (
|
|
237
339
|
<>
|
|
238
|
-
{
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
340
|
+
{groups.map((group, gi) => (
|
|
341
|
+
<FieldGroupHeader key={gi} group={group.group}>
|
|
342
|
+
{group.fields.map((field) => (
|
|
343
|
+
<SchemaFrontmatterField
|
|
344
|
+
key={field.name}
|
|
345
|
+
field={field}
|
|
346
|
+
value={page.frontmatter[field.name]}
|
|
347
|
+
onChange={(newValue) => updateMarkdownFrontmatter({ [field.name]: newValue })}
|
|
348
|
+
/>
|
|
349
|
+
))}
|
|
350
|
+
</FieldGroupHeader>
|
|
246
351
|
))}
|
|
247
352
|
{/* Extra fields not in schema */}
|
|
248
353
|
{extraKeys.map((key) => (
|
|
@@ -271,6 +376,20 @@ export function EditModeFrontmatter({
|
|
|
271
376
|
)
|
|
272
377
|
}
|
|
273
378
|
|
|
379
|
+
// ============================================================================
|
|
380
|
+
// Collection Reference Helpers
|
|
381
|
+
// ============================================================================
|
|
382
|
+
|
|
383
|
+
function getCollectionEntryOptions(collectionName?: string): Array<{ value: string; label: string }> {
|
|
384
|
+
if (!collectionName) return []
|
|
385
|
+
const def = manifest.value.collectionDefinitions?.[collectionName]
|
|
386
|
+
if (!def?.entries) return []
|
|
387
|
+
return def.entries.map(e => ({
|
|
388
|
+
value: e.slug,
|
|
389
|
+
label: e.title ?? e.slug,
|
|
390
|
+
}))
|
|
391
|
+
}
|
|
392
|
+
|
|
274
393
|
// ============================================================================
|
|
275
394
|
// Schema-aware Frontmatter Field
|
|
276
395
|
// ============================================================================
|
|
@@ -376,41 +495,63 @@ export function SchemaFrontmatterField({
|
|
|
376
495
|
/>
|
|
377
496
|
)
|
|
378
497
|
|
|
498
|
+
case 'reference': {
|
|
499
|
+
const refOptions = getCollectionEntryOptions(field.collection)
|
|
500
|
+
return (
|
|
501
|
+
<ComboBoxField
|
|
502
|
+
label={label}
|
|
503
|
+
value={(value as string) ?? ''}
|
|
504
|
+
placeholder={`Select ${label.toLowerCase()}...`}
|
|
505
|
+
options={refOptions}
|
|
506
|
+
onChange={(v) => onChange(v)}
|
|
507
|
+
/>
|
|
508
|
+
)
|
|
509
|
+
}
|
|
510
|
+
|
|
379
511
|
case 'array': {
|
|
380
512
|
const items = Array.isArray(value) ? value : []
|
|
513
|
+
// Array of references — show multiselect with collection entries
|
|
514
|
+
if (field.itemType === 'reference' && field.collection) {
|
|
515
|
+
const refEntries = getCollectionEntryOptions(field.collection)
|
|
516
|
+
return (
|
|
517
|
+
<div class="col-span-2" data-cms-ui>
|
|
518
|
+
<MultiSelectField
|
|
519
|
+
label={label}
|
|
520
|
+
selected={items.map(String)}
|
|
521
|
+
options={refEntries}
|
|
522
|
+
onChange={(v) => onChange(v)}
|
|
523
|
+
/>
|
|
524
|
+
</div>
|
|
525
|
+
)
|
|
526
|
+
}
|
|
381
527
|
if (field.options && field.options.length > 0) {
|
|
382
528
|
return (
|
|
383
|
-
<div class="col-span-2
|
|
384
|
-
<
|
|
385
|
-
|
|
386
|
-
{
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
checked={items.includes(opt)}
|
|
391
|
-
onChange={(e) => {
|
|
392
|
-
if ((e.target as HTMLInputElement).checked) {
|
|
393
|
-
onChange([...items, opt])
|
|
394
|
-
} else {
|
|
395
|
-
onChange(items.filter((i: unknown) => i !== opt))
|
|
396
|
-
}
|
|
397
|
-
}}
|
|
398
|
-
class="rounded border-white/20 bg-white/10 text-cms-primary focus:ring-cms-primary"
|
|
399
|
-
data-cms-ui
|
|
400
|
-
/>
|
|
401
|
-
<span class="text-sm text-white/80">{opt}</span>
|
|
402
|
-
</label>
|
|
403
|
-
))}
|
|
404
|
-
</div>
|
|
529
|
+
<div class="col-span-2" data-cms-ui>
|
|
530
|
+
<MultiSelectField
|
|
531
|
+
label={label}
|
|
532
|
+
selected={items.map(String)}
|
|
533
|
+
options={field.options}
|
|
534
|
+
onChange={(v) => onChange(v)}
|
|
535
|
+
/>
|
|
405
536
|
</div>
|
|
406
537
|
)
|
|
407
538
|
}
|
|
539
|
+
if (isArrayOfObjects(items)) {
|
|
540
|
+
return (
|
|
541
|
+
<ArrayOfObjectsField
|
|
542
|
+
label={label}
|
|
543
|
+
items={items as Record<string, unknown>[]}
|
|
544
|
+
onChange={onChange}
|
|
545
|
+
itemFields={field.fields}
|
|
546
|
+
/>
|
|
547
|
+
)
|
|
548
|
+
}
|
|
408
549
|
return (
|
|
409
550
|
<div class="col-span-2 flex flex-col gap-1" data-cms-ui>
|
|
410
551
|
<label class="text-xs text-white/60 font-medium">{label}</label>
|
|
411
552
|
<input
|
|
412
553
|
type="text"
|
|
413
|
-
value={(
|
|
554
|
+
value={items.map(v => typeof v === 'string' ? v : String(v)).join(', ')}
|
|
414
555
|
onInput={(e) => {
|
|
415
556
|
const inputValue = (e.target as HTMLInputElement).value
|
|
416
557
|
const arrayValue = inputValue
|
|
@@ -469,6 +610,85 @@ export function SchemaFrontmatterField({
|
|
|
469
610
|
}
|
|
470
611
|
}
|
|
471
612
|
|
|
613
|
+
// ============================================================================
|
|
614
|
+
// Array of Objects Field — renders each item as nested key/value fields
|
|
615
|
+
// ============================================================================
|
|
616
|
+
|
|
617
|
+
interface ArrayOfObjectsFieldProps {
|
|
618
|
+
label: string
|
|
619
|
+
items: Record<string, unknown>[]
|
|
620
|
+
onChange: (value: unknown) => void
|
|
621
|
+
itemFields?: FieldDefinition[]
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function ArrayOfObjectsField({ label, items, onChange, itemFields }: ArrayOfObjectsFieldProps) {
|
|
625
|
+
const handleItemChange = (index: number, newItem: Record<string, unknown>) => {
|
|
626
|
+
const updated = [...items]
|
|
627
|
+
updated[index] = newItem
|
|
628
|
+
onChange(updated)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const handleRemoveItem = (index: number) => {
|
|
632
|
+
onChange(items.filter((_, i) => i !== index))
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const handleAddItem = () => {
|
|
636
|
+
// Use the first item's keys as template
|
|
637
|
+
const template = items.length > 0
|
|
638
|
+
? Object.fromEntries(Object.keys(items[0]!).map(k => [k, '']))
|
|
639
|
+
: { name: '' }
|
|
640
|
+
onChange([...items, template])
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
return (
|
|
644
|
+
<div class="flex flex-col gap-2 col-span-2" data-cms-ui>
|
|
645
|
+
<label class="text-xs text-white/60 font-medium">{label}</label>
|
|
646
|
+
<div class="space-y-2">
|
|
647
|
+
{items.map((item, index) => (
|
|
648
|
+
<div key={index} class="flex items-start gap-2 pl-3 border-l-2 border-white/10">
|
|
649
|
+
<div class="flex-1 min-w-0 space-y-1.5">
|
|
650
|
+
{itemFields
|
|
651
|
+
? itemFields.map((subField) => (
|
|
652
|
+
<SchemaFrontmatterField
|
|
653
|
+
key={subField.name}
|
|
654
|
+
field={subField}
|
|
655
|
+
value={item[subField.name]}
|
|
656
|
+
onChange={(newValue) => handleItemChange(index, { ...item, [subField.name]: newValue })}
|
|
657
|
+
/>
|
|
658
|
+
))
|
|
659
|
+
: Object.entries(item).map(([key, val]) => (
|
|
660
|
+
<FrontmatterField
|
|
661
|
+
key={key}
|
|
662
|
+
fieldKey={key}
|
|
663
|
+
value={val}
|
|
664
|
+
onChange={(newValue) => handleItemChange(index, { ...item, [key]: newValue })}
|
|
665
|
+
/>
|
|
666
|
+
))}
|
|
667
|
+
</div>
|
|
668
|
+
<button
|
|
669
|
+
type="button"
|
|
670
|
+
onClick={() => handleRemoveItem(index)}
|
|
671
|
+
class="p-1 mt-1 text-white/30 hover:text-red-400 transition-colors shrink-0"
|
|
672
|
+
title="Remove item"
|
|
673
|
+
data-cms-ui
|
|
674
|
+
>
|
|
675
|
+
<RemoveIcon />
|
|
676
|
+
</button>
|
|
677
|
+
</div>
|
|
678
|
+
))}
|
|
679
|
+
</div>
|
|
680
|
+
<button
|
|
681
|
+
type="button"
|
|
682
|
+
onClick={handleAddItem}
|
|
683
|
+
class="self-start px-3 py-1 text-xs text-white/50 hover:text-white border border-white/10 hover:border-white/20 rounded-cms-sm transition-colors"
|
|
684
|
+
data-cms-ui
|
|
685
|
+
>
|
|
686
|
+
+ Add {label.toLowerCase()}
|
|
687
|
+
</button>
|
|
688
|
+
</div>
|
|
689
|
+
)
|
|
690
|
+
}
|
|
691
|
+
|
|
472
692
|
// ============================================================================
|
|
473
693
|
// Object Fields — renders nested fields with add/remove key support
|
|
474
694
|
// ============================================================================
|
|
@@ -623,12 +843,3 @@ export function getPlaceholder(field: FieldDefinition): string {
|
|
|
623
843
|
return `Enter ${formatFieldLabel(field.name).toLowerCase()}...`
|
|
624
844
|
}
|
|
625
845
|
}
|
|
626
|
-
|
|
627
|
-
export function slugify(text: string): string {
|
|
628
|
-
return text
|
|
629
|
-
.toLowerCase()
|
|
630
|
-
.trim()
|
|
631
|
-
.replace(/[^\w\s-]/g, '')
|
|
632
|
-
.replace(/[\s_-]+/g, '-')
|
|
633
|
-
.replace(/^-+|-+$/g, '')
|
|
634
|
-
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
|
2
|
+
import { cn } from '../lib/cn'
|
|
3
|
+
import { updateMarkdownFrontmatter } from '../signals'
|
|
4
|
+
import type { CollectionDefinition, FieldDefinition, MarkdownPageEntry } from '../types'
|
|
5
|
+
import { formatFieldLabel, FrontmatterField, SchemaFrontmatterField } from './frontmatter-fields'
|
|
6
|
+
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// Field Utilities
|
|
9
|
+
// ============================================================================
|
|
10
|
+
|
|
11
|
+
export function partitionFields(fields: FieldDefinition[]): { sidebar: FieldDefinition[]; header: FieldDefinition[] } {
|
|
12
|
+
const sidebar: FieldDefinition[] = []
|
|
13
|
+
const header: FieldDefinition[] = []
|
|
14
|
+
for (const field of fields) {
|
|
15
|
+
if (field.hidden) continue
|
|
16
|
+
if (field.position === 'sidebar') {
|
|
17
|
+
sidebar.push(field)
|
|
18
|
+
} else {
|
|
19
|
+
header.push(field)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return { sidebar, header }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface FieldGroup {
|
|
26
|
+
group: string | null
|
|
27
|
+
fields: FieldDefinition[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function groupFields(fields: FieldDefinition[]): FieldGroup[] {
|
|
31
|
+
const groups: FieldGroup[] = []
|
|
32
|
+
const groupMap = new Map<string | null, FieldDefinition[]>()
|
|
33
|
+
const order: (string | null)[] = []
|
|
34
|
+
|
|
35
|
+
for (const field of fields) {
|
|
36
|
+
const key = field.group ?? null
|
|
37
|
+
if (!groupMap.has(key)) {
|
|
38
|
+
groupMap.set(key, [])
|
|
39
|
+
order.push(key)
|
|
40
|
+
}
|
|
41
|
+
groupMap.get(key)!.push(field)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const key of order) {
|
|
45
|
+
groups.push({ group: key, fields: groupMap.get(key)! })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return groups
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ============================================================================
|
|
52
|
+
// Group Header
|
|
53
|
+
// ============================================================================
|
|
54
|
+
|
|
55
|
+
function GroupHeader({ label }: { label: string }) {
|
|
56
|
+
return (
|
|
57
|
+
<div class="pt-3 pb-1" data-cms-ui>
|
|
58
|
+
<h4 class="text-xs uppercase tracking-wider text-white/40 font-medium">{label}</h4>
|
|
59
|
+
<div class="border-t border-white/10 mt-1.5" />
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ============================================================================
|
|
65
|
+
// Sidebar Component
|
|
66
|
+
// ============================================================================
|
|
67
|
+
|
|
68
|
+
const SIDEBAR_STORAGE_KEY = 'nuacms-sidebar'
|
|
69
|
+
const MIN_WIDTH = 200
|
|
70
|
+
const MAX_WIDTH = 400
|
|
71
|
+
const DEFAULT_WIDTH = 280
|
|
72
|
+
|
|
73
|
+
function loadSidebarState(): { width: number; collapsed: boolean } {
|
|
74
|
+
try {
|
|
75
|
+
const stored = localStorage.getItem(SIDEBAR_STORAGE_KEY)
|
|
76
|
+
if (stored) return JSON.parse(stored)
|
|
77
|
+
} catch {}
|
|
78
|
+
return { width: DEFAULT_WIDTH, collapsed: false }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function saveSidebarState(state: { width: number; collapsed: boolean }) {
|
|
82
|
+
try {
|
|
83
|
+
localStorage.setItem(SIDEBAR_STORAGE_KEY, JSON.stringify(state))
|
|
84
|
+
} catch (e) {
|
|
85
|
+
console.warn('[CMS] Failed to save sidebar state:', e)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface FrontmatterSidebarProps {
|
|
90
|
+
fields: FieldDefinition[]
|
|
91
|
+
page: MarkdownPageEntry
|
|
92
|
+
collectionDefinition?: CollectionDefinition
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function FrontmatterSidebar({ fields, page, collectionDefinition }: FrontmatterSidebarProps) {
|
|
96
|
+
const [state, setState] = useState(loadSidebarState)
|
|
97
|
+
const isResizing = useRef(false)
|
|
98
|
+
const startX = useRef(0)
|
|
99
|
+
const startWidth = useRef(0)
|
|
100
|
+
|
|
101
|
+
const { width, collapsed } = state
|
|
102
|
+
|
|
103
|
+
const updateState = useCallback((update: Partial<typeof state>, persist = true) => {
|
|
104
|
+
setState((prev) => {
|
|
105
|
+
const next = { ...prev, ...update }
|
|
106
|
+
if (persist) saveSidebarState(next)
|
|
107
|
+
return next
|
|
108
|
+
})
|
|
109
|
+
}, [])
|
|
110
|
+
|
|
111
|
+
const handleMouseDown = useCallback((e: MouseEvent) => {
|
|
112
|
+
e.preventDefault()
|
|
113
|
+
isResizing.current = true
|
|
114
|
+
startX.current = e.clientX
|
|
115
|
+
startWidth.current = width
|
|
116
|
+
document.body.style.cursor = 'col-resize'
|
|
117
|
+
document.body.style.userSelect = 'none'
|
|
118
|
+
}, [width])
|
|
119
|
+
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
122
|
+
if (!isResizing.current) return
|
|
123
|
+
const delta = startX.current - e.clientX
|
|
124
|
+
const newWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth.current + delta))
|
|
125
|
+
updateState({ width: newWidth }, false)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const handleMouseUp = () => {
|
|
129
|
+
if (!isResizing.current) return
|
|
130
|
+
isResizing.current = false
|
|
131
|
+
document.body.style.cursor = ''
|
|
132
|
+
document.body.style.userSelect = ''
|
|
133
|
+
setState((current) => {
|
|
134
|
+
saveSidebarState(current)
|
|
135
|
+
return current
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
document.addEventListener('mousemove', handleMouseMove)
|
|
140
|
+
document.addEventListener('mouseup', handleMouseUp)
|
|
141
|
+
return () => {
|
|
142
|
+
document.removeEventListener('mousemove', handleMouseMove)
|
|
143
|
+
document.removeEventListener('mouseup', handleMouseUp)
|
|
144
|
+
}
|
|
145
|
+
}, [updateState])
|
|
146
|
+
|
|
147
|
+
if (fields.length === 0) return null
|
|
148
|
+
|
|
149
|
+
const groups = groupFields(fields)
|
|
150
|
+
const schemaFieldNames = new Set(collectionDefinition?.fields.map((f) => f.name) ?? [])
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<div
|
|
154
|
+
class={cn('relative shrink-0 border-l border-white/10 bg-white/5 flex', collapsed && 'w-8')}
|
|
155
|
+
style={collapsed ? undefined : { width: `${width}px` }}
|
|
156
|
+
data-cms-ui
|
|
157
|
+
>
|
|
158
|
+
{/* Drag handle */}
|
|
159
|
+
{!collapsed && (
|
|
160
|
+
<div
|
|
161
|
+
class="absolute left-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-cms-primary/30 transition-colors z-10"
|
|
162
|
+
onMouseDown={handleMouseDown}
|
|
163
|
+
/>
|
|
164
|
+
)}
|
|
165
|
+
|
|
166
|
+
{/* Collapse toggle */}
|
|
167
|
+
<button
|
|
168
|
+
type="button"
|
|
169
|
+
onClick={() => updateState({ collapsed: !collapsed })}
|
|
170
|
+
class="absolute top-2 left-0 -translate-x-1/2 z-20 w-5 h-5 rounded-full bg-cms-dark border border-white/20 flex items-center justify-center text-white/50 hover:text-white hover:border-white/40 transition-colors"
|
|
171
|
+
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
|
172
|
+
data-cms-ui
|
|
173
|
+
>
|
|
174
|
+
<svg
|
|
175
|
+
class={cn('w-3 h-3 transition-transform', collapsed && 'rotate-180')}
|
|
176
|
+
viewBox="0 0 24 24"
|
|
177
|
+
fill="none"
|
|
178
|
+
stroke="currentColor"
|
|
179
|
+
stroke-width="2"
|
|
180
|
+
stroke-linecap="round"
|
|
181
|
+
stroke-linejoin="round"
|
|
182
|
+
>
|
|
183
|
+
<path d="m15 18-6-6 6-6" />
|
|
184
|
+
</svg>
|
|
185
|
+
</button>
|
|
186
|
+
|
|
187
|
+
{/* Sidebar content */}
|
|
188
|
+
{!collapsed && (
|
|
189
|
+
<div class="flex-1 overflow-y-auto p-4 space-y-3 min-w-0">
|
|
190
|
+
{groups.map((group, gi) => (
|
|
191
|
+
<div key={gi} data-cms-ui>
|
|
192
|
+
{group.group && <GroupHeader label={group.group} />}
|
|
193
|
+
<div class="space-y-3">
|
|
194
|
+
{group.fields.map((field) => {
|
|
195
|
+
const isSchema = schemaFieldNames.has(field.name)
|
|
196
|
+
return (
|
|
197
|
+
<div key={field.name} data-cms-ui>
|
|
198
|
+
{isSchema
|
|
199
|
+
? (
|
|
200
|
+
<SchemaFrontmatterField
|
|
201
|
+
field={field}
|
|
202
|
+
value={page.frontmatter[field.name]}
|
|
203
|
+
onChange={(newValue) => updateMarkdownFrontmatter({ [field.name]: newValue })}
|
|
204
|
+
/>
|
|
205
|
+
)
|
|
206
|
+
: (
|
|
207
|
+
<FrontmatterField
|
|
208
|
+
fieldKey={field.name}
|
|
209
|
+
value={page.frontmatter[field.name]}
|
|
210
|
+
onChange={(newValue) => updateMarkdownFrontmatter({ [field.name]: newValue })}
|
|
211
|
+
/>
|
|
212
|
+
)}
|
|
213
|
+
</div>
|
|
214
|
+
)
|
|
215
|
+
})}
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
))}
|
|
219
|
+
</div>
|
|
220
|
+
)}
|
|
221
|
+
</div>
|
|
222
|
+
)
|
|
223
|
+
}
|