@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.
Files changed (61) hide show
  1. package/dist/editor.js +52746 -36711
  2. package/package.json +16 -14
  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 +78 -14
  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/types.ts +111 -2
  61. package/src/utils.ts +40 -4
@@ -1,7 +1,36 @@
1
- import { useState } from 'preact/hooks'
2
- import { markdownEditorState, openMediaLibraryWithCallback, updateMarkdownFrontmatter } from '../signals'
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
- // Format field key as label (e.g., "featuredImage" -> "Featured Image")
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) - comma-separated input
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={(value as unknown[]).join(', ')}
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
- {collectionDefinition.fields.map((field) => (
185
- <SchemaFrontmatterField
186
- key={field.name}
187
- field={field}
188
- value={page.frontmatter[field.name]}
189
- onChange={(newValue) => updateMarkdownFrontmatter({ [field.name]: newValue })}
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 (always disabled in edit mode) */}
222
- <div>
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
- {/* Schema-aware fields */}
239
- {collectionDefinition.fields.map((field) => (
240
- <SchemaFrontmatterField
241
- key={field.name}
242
- field={field}
243
- value={page.frontmatter[field.name]}
244
- onChange={(newValue) => updateMarkdownFrontmatter({ [field.name]: newValue })}
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 space-y-1.5" data-cms-ui>
384
- <label class="text-xs text-white/60 font-medium">{label}</label>
385
- <div class="space-y-2">
386
- {field.options.map((opt) => (
387
- <label key={opt} class="flex items-center gap-2 cursor-pointer">
388
- <input
389
- type="checkbox"
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={(items as unknown[]).join(', ')}
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
+ }