@nuasite/cms 0.28.0 → 0.30.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 (37) hide show
  1. package/dist/editor.js +12447 -12473
  2. package/package.json +1 -1
  3. package/src/collection-scanner.ts +69 -35
  4. package/src/dev-middleware.ts +86 -45
  5. package/src/editor/components/attribute-editor.tsx +2 -10
  6. package/src/editor/components/bg-image-overlay.tsx +2 -10
  7. package/src/editor/components/collections-browser.tsx +8 -24
  8. package/src/editor/components/color-toolbar.tsx +2 -9
  9. package/src/editor/components/confirm-dialog.tsx +4 -12
  10. package/src/editor/components/create-page-modal.tsx +23 -19
  11. package/src/editor/components/fields.tsx +158 -124
  12. package/src/editor/components/frontmatter-fields.tsx +9 -1
  13. package/src/editor/components/link-edit-popover.tsx +3 -6
  14. package/src/editor/components/markdown-editor-overlay.tsx +44 -46
  15. package/src/editor/components/markdown-inline-editor.tsx +2 -1
  16. package/src/editor/components/mdx-block-view.tsx +1 -0
  17. package/src/editor/components/mdx-component-picker.tsx +3 -6
  18. package/src/editor/components/media-library.tsx +15 -37
  19. package/src/editor/components/modal-shell.tsx +34 -5
  20. package/src/editor/components/prop-editor.tsx +77 -73
  21. package/src/editor/components/reference-picker.tsx +6 -24
  22. package/src/editor/components/seo-editor.tsx +4 -10
  23. package/src/editor/components/spinner.tsx +17 -0
  24. package/src/editor/components/toolbar.tsx +2 -1
  25. package/src/editor/constants.ts +33 -0
  26. package/src/editor/hooks/index.ts +4 -0
  27. package/src/editor/hooks/useClickOutsideEscape.ts +43 -0
  28. package/src/editor/hooks/useSearchFilter.ts +21 -0
  29. package/src/field-types.ts +2 -0
  30. package/src/handlers/api-routes.ts +10 -16
  31. package/src/html-processor.ts +75 -94
  32. package/src/index.ts +5 -0
  33. package/src/manifest-writer.ts +15 -0
  34. package/src/rehype-cms-marker.ts +15 -0
  35. package/src/types.ts +1 -0
  36. package/src/vite-plugin.ts +18 -72
  37. package/src/content-invalidator.ts +0 -134
@@ -1,4 +1,8 @@
1
+ import type { ComponentChildren } from 'preact'
1
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
3
+ import { getDropdownPosition } from '../constants'
4
+ import { useClickOutsideEscape } from '../hooks/useClickOutsideEscape'
5
+ import { useSearchFilter } from '../hooks/useSearchFilter'
2
6
  import { cn } from '../lib/cn'
3
7
 
4
8
  // ============================================================================
@@ -45,9 +49,12 @@ export interface TextFieldProps {
45
49
  isDirty?: boolean
46
50
  onReset?: () => void
47
51
  inputType?: string
52
+ required?: boolean
48
53
  }
49
54
 
50
- export function TextField({ label, value, placeholder, maxLength, minLength, onChange, isDirty, onReset, inputType = 'text' }: TextFieldProps) {
55
+ export function TextField(
56
+ { label, value, placeholder, maxLength, minLength, onChange, isDirty, onReset, inputType = 'text', required }: TextFieldProps,
57
+ ) {
51
58
  return (
52
59
  <div class="space-y-1.5">
53
60
  <FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
@@ -57,6 +64,7 @@ export function TextField({ label, value, placeholder, maxLength, minLength, onC
57
64
  placeholder={placeholder}
58
65
  maxLength={maxLength}
59
66
  minLength={minLength}
67
+ required={required}
60
68
  onInput={(e) => onChange((e.target as HTMLInputElement).value)}
61
69
  class={cn(
62
70
  'w-full px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
@@ -82,9 +90,10 @@ export interface ImageFieldProps {
82
90
  onBrowse: () => void
83
91
  isDirty?: boolean
84
92
  onReset?: () => void
93
+ required?: boolean
85
94
  }
86
95
 
87
- export function ImageField({ label, value, placeholder, onChange, onBrowse, isDirty, onReset }: ImageFieldProps) {
96
+ export function ImageField({ label, value, placeholder, onChange, onBrowse, isDirty, onReset, required }: ImageFieldProps) {
88
97
  const hasImage = !!value && value.length > 0
89
98
 
90
99
  return (
@@ -114,6 +123,7 @@ export function ImageField({ label, value, placeholder, onChange, onBrowse, isDi
114
123
  type="text"
115
124
  value={value ?? ''}
116
125
  placeholder={placeholder}
126
+ required={required}
117
127
  onInput={(e) => onChange((e.target as HTMLInputElement).value)}
118
128
  class={cn(
119
129
  'flex-1 min-w-0 px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
@@ -147,9 +157,10 @@ export interface ColorFieldProps {
147
157
  onChange: (value: string) => void
148
158
  isDirty?: boolean
149
159
  onReset?: () => void
160
+ required?: boolean
150
161
  }
151
162
 
152
- export function ColorField({ label, value, placeholder, onChange, isDirty, onReset }: ColorFieldProps) {
163
+ export function ColorField({ label, value, placeholder, onChange, isDirty, onReset, required }: ColorFieldProps) {
153
164
  const colorValue = value || '#000000'
154
165
  // Validate hex for the native picker (must be #rrggbb)
155
166
  const isValidHex = /^#[0-9a-fA-F]{6}$/.test(colorValue)
@@ -170,6 +181,7 @@ export function ColorField({ label, value, placeholder, onChange, isDirty, onRes
170
181
  type="text"
171
182
  value={value ?? ''}
172
183
  placeholder={placeholder ?? '#000000'}
184
+ required={required}
173
185
  onInput={(e) => onChange((e.target as HTMLInputElement).value)}
174
186
  class={cn(
175
187
  'flex-1 px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
@@ -278,9 +290,10 @@ export interface NumberFieldProps {
278
290
  onChange: (value: number | undefined) => void
279
291
  isDirty?: boolean
280
292
  onReset?: () => void
293
+ required?: boolean
281
294
  }
282
295
 
283
- export function NumberField({ label, value, placeholder, min, max, step, onChange, isDirty, onReset }: NumberFieldProps) {
296
+ export function NumberField({ label, value, placeholder, min, max, step, onChange, isDirty, onReset, required }: NumberFieldProps) {
284
297
  return (
285
298
  <div class="space-y-1.5">
286
299
  <FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
@@ -291,6 +304,7 @@ export function NumberField({ label, value, placeholder, min, max, step, onChang
291
304
  min={min}
292
305
  max={max}
293
306
  step={step}
307
+ required={required}
294
308
  onInput={(e) => {
295
309
  const val = (e.target as HTMLInputElement).value
296
310
  onChange(val === '' ? undefined : Number(val))
@@ -324,6 +338,48 @@ export function HighlightMatch({ text, query }: { text: string; query: string })
324
338
  )
325
339
  }
326
340
 
341
+ // ============================================================================
342
+ // Dropdown Panel (fixed-position container for select/combobox dropdowns)
343
+ // ============================================================================
344
+
345
+ export interface DropdownPanelProps {
346
+ /** Ref to the trigger element — used for positioning and outside-click detection */
347
+ triggerRef: { readonly current: HTMLElement | null }
348
+ isOpen: boolean
349
+ onClose: () => void
350
+ maxHeight?: number
351
+ children: ComponentChildren
352
+ className?: string
353
+ /** Forward a ref to the panel div (e.g. for keyboard-nav scroll) */
354
+ panelRef?: { current: HTMLDivElement | null }
355
+ /** Additional refs to exempt from outside-click detection (e.g. a wrapper containing related UI like selected tags) */
356
+ exemptRefs?: ReadonlyArray<{ readonly current: HTMLElement | null }>
357
+ }
358
+
359
+ /**
360
+ * Fixed-position dropdown container that escapes parent overflow clipping.
361
+ * Handles outside-click and Escape-key dismissal.
362
+ */
363
+ export function DropdownPanel({ triggerRef, isOpen, onClose, maxHeight = 192, children, className, panelRef, exemptRefs }: DropdownPanelProps) {
364
+ const internalRef = useRef<HTMLDivElement>(null)
365
+ const ref = panelRef ?? internalRef
366
+
367
+ useClickOutsideEscape([ref, triggerRef, ...(exemptRefs ?? [])], isOpen, onClose)
368
+
369
+ if (!isOpen) return null
370
+
371
+ return (
372
+ <div
373
+ ref={ref}
374
+ class={cn('overflow-y-auto bg-cms-dark shadow-lg', className)}
375
+ style={getDropdownPosition(triggerRef.current, maxHeight)}
376
+ data-cms-ui
377
+ >
378
+ {children}
379
+ </div>
380
+ )
381
+ }
382
+
327
383
  // ============================================================================
328
384
  // ComboBox Field (searchable dropdown with free-text input)
329
385
  // ============================================================================
@@ -336,23 +392,17 @@ export interface ComboBoxFieldProps {
336
392
  onChange: (value: string) => void
337
393
  isDirty?: boolean
338
394
  onReset?: () => void
395
+ required?: boolean
339
396
  }
340
397
 
341
- export function ComboBoxField({ label, value, placeholder, options, onChange, isDirty, onReset }: ComboBoxFieldProps) {
398
+ export function ComboBoxField({ label, value, placeholder, options, onChange, isDirty, onReset, required }: ComboBoxFieldProps) {
342
399
  const [query, setQuery] = useState('')
343
400
  const [isOpen, setIsOpen] = useState(false)
344
401
  const [highlightedIndex, setHighlightedIndex] = useState(-1)
345
402
  const inputRef = useRef<HTMLInputElement>(null)
346
403
  const listRef = useRef<HTMLDivElement>(null)
347
404
 
348
- // Filter options based on query
349
- const filtered = useMemo(() => {
350
- if (!query) return options
351
- const q = query.toLowerCase()
352
- return options.filter(
353
- o => o.value.toLowerCase().includes(q) || o.label.toLowerCase().includes(q),
354
- )
355
- }, [query, options])
405
+ const filtered = useSearchFilter(options, query, o => `${o.label} ${o.value}`)
356
406
 
357
407
  const handleInput = useCallback((e: Event) => {
358
408
  const v = (e.target as HTMLInputElement).value
@@ -362,22 +412,22 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
362
412
  setHighlightedIndex(-1)
363
413
  }, [onChange])
364
414
 
365
- const handleFocus = useCallback(() => {
366
- setIsOpen(true)
367
- }, [])
368
-
369
- const handleBlur = useCallback(() => {
370
- // Delay to allow click on option to register
371
- setTimeout(() => setIsOpen(false), 150)
372
- }, [])
373
-
374
415
  const selectOption = useCallback((optValue: string) => {
375
416
  onChange(optValue)
376
417
  setQuery('')
377
418
  setIsOpen(false)
378
419
  }, [onChange])
379
420
 
421
+ const closeDropdown = useCallback(() => setIsOpen(false), [])
422
+
380
423
  const handleKeyDown = useCallback((e: KeyboardEvent) => {
424
+ if (e.key === 'Enter') {
425
+ e.preventDefault()
426
+ if (isOpen && highlightedIndex >= 0 && filtered[highlightedIndex]) {
427
+ selectOption(filtered[highlightedIndex]!.value)
428
+ }
429
+ return
430
+ }
381
431
  if (!isOpen || filtered.length === 0) return
382
432
  if (e.key === 'ArrowDown') {
383
433
  e.preventDefault()
@@ -385,11 +435,6 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
385
435
  } else if (e.key === 'ArrowUp') {
386
436
  e.preventDefault()
387
437
  setHighlightedIndex(i => Math.max(i - 1, 0))
388
- } else if (e.key === 'Enter' && highlightedIndex >= 0) {
389
- e.preventDefault()
390
- selectOption(filtered[highlightedIndex]!.value)
391
- } else if (e.key === 'Escape') {
392
- setIsOpen(false)
393
438
  }
394
439
  }, [isOpen, filtered, highlightedIndex, selectOption])
395
440
 
@@ -404,16 +449,17 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
404
449
  const showDropdown = isOpen && filtered.length > 0
405
450
 
406
451
  return (
407
- <div class="space-y-1.5 relative">
452
+ <div class="space-y-1.5">
408
453
  <FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
409
454
  <input
410
455
  ref={inputRef}
411
456
  type="text"
412
457
  value={value ?? ''}
413
458
  placeholder={placeholder}
459
+ required={required}
414
460
  onInput={handleInput}
415
- onFocus={handleFocus}
416
- onBlur={handleBlur}
461
+ onFocus={() => setIsOpen(true)}
462
+ onBlur={() => setTimeout(closeDropdown, 150)}
417
463
  onKeyDown={handleKeyDown}
418
464
  autocomplete="off"
419
465
  class={cn(
@@ -424,40 +470,41 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
424
470
  )}
425
471
  data-cms-ui
426
472
  />
427
- {showDropdown && (
428
- <div
429
- ref={listRef}
430
- class="absolute z-50 left-0 right-0 mt-1 max-h-40 overflow-y-auto bg-cms-dark border border-white/15 rounded-cms-sm shadow-lg"
431
- data-cms-ui
432
- >
433
- {filtered.map((opt, i) => (
434
- <button
435
- key={opt.value}
436
- type="button"
437
- onMouseDown={(e) => {
438
- e.preventDefault()
439
- selectOption(opt.value)
440
- }}
441
- class={cn(
442
- 'w-full text-left px-3 py-2 text-xs transition-colors cursor-pointer',
443
- i === highlightedIndex
444
- ? 'bg-white/15 text-white'
445
- : 'text-white/70 hover:bg-white/10 hover:text-white',
446
- )}
447
- data-cms-ui
448
- >
449
- <span class="block truncate font-medium">
450
- <HighlightMatch text={opt.label} query={query} />
473
+ <DropdownPanel
474
+ triggerRef={inputRef}
475
+ isOpen={showDropdown}
476
+ onClose={closeDropdown}
477
+ maxHeight={160}
478
+ panelRef={listRef}
479
+ className="border border-white/15 rounded-cms-sm"
480
+ >
481
+ {filtered.map((opt, i) => (
482
+ <button
483
+ key={opt.value}
484
+ type="button"
485
+ onMouseDown={(e) => {
486
+ e.preventDefault()
487
+ selectOption(opt.value)
488
+ }}
489
+ class={cn(
490
+ 'w-full text-left px-3 py-2 text-xs transition-colors cursor-pointer',
491
+ i === highlightedIndex
492
+ ? 'bg-white/15 text-white'
493
+ : 'text-white/70 hover:bg-white/10 hover:text-white',
494
+ )}
495
+ data-cms-ui
496
+ >
497
+ <span class="block truncate font-medium">
498
+ <HighlightMatch text={opt.label} query={query} />
499
+ </span>
500
+ {opt.description && (
501
+ <span class="block truncate text-white/40">
502
+ <HighlightMatch text={opt.description} query={query} />
451
503
  </span>
452
- {opt.description && (
453
- <span class="block truncate text-white/40">
454
- <HighlightMatch text={opt.description} query={query} />
455
- </span>
456
- )}
457
- </button>
458
- ))}
459
- </div>
460
- )}
504
+ )}
505
+ </button>
506
+ ))}
507
+ </DropdownPanel>
461
508
  </div>
462
509
  )
463
510
  }
@@ -494,11 +541,7 @@ export function MultiSelectField({ label, selected, options, onChange, isDirty,
494
541
  return map
495
542
  }, [normalizedOptions])
496
543
 
497
- const filtered = useMemo(() => {
498
- if (!query) return normalizedOptions
499
- const q = query.toLowerCase()
500
- return normalizedOptions.filter(o => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q))
501
- }, [query, normalizedOptions])
544
+ const filtered = useSearchFilter(normalizedOptions, query, o => `${o.label} ${o.value}`)
502
545
 
503
546
  const toggleOption = useCallback((value: string) => {
504
547
  if (selected.includes(value)) {
@@ -508,20 +551,10 @@ export function MultiSelectField({ label, selected, options, onChange, isDirty,
508
551
  }
509
552
  }, [selected, onChange])
510
553
 
511
- // Close on outside click
512
- useEffect(() => {
513
- if (!isOpen) return
514
- const handler = (e: MouseEvent) => {
515
- if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
516
- setIsOpen(false)
517
- }
518
- }
519
- document.addEventListener('mousedown', handler)
520
- return () => document.removeEventListener('mousedown', handler)
521
- }, [isOpen])
554
+ const closeDropdown = useCallback(() => setIsOpen(false), [])
522
555
 
523
556
  return (
524
- <div class="space-y-1.5 relative" ref={containerRef} data-cms-ui>
557
+ <div class="space-y-1.5" ref={containerRef} data-cms-ui>
525
558
  <FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
526
559
 
527
560
  {/* Selected pills */}
@@ -564,54 +597,55 @@ export function MultiSelectField({ label, selected, options, onChange, isDirty,
564
597
  data-cms-ui
565
598
  />
566
599
 
567
- {/* Dropdown */}
568
- {isOpen && (
569
- <div
570
- class="absolute z-50 left-0 right-0 mt-1 max-h-48 overflow-y-auto bg-cms-dark border border-white/15 rounded-cms-sm shadow-lg"
571
- data-cms-ui
572
- >
573
- {filtered.length === 0
574
- ? <div class="px-3 py-2 text-xs text-white/40">No options found</div>
575
- : filtered.map(opt => {
576
- const isSelected = selected.includes(opt.value)
577
- return (
578
- <button
579
- key={opt.value}
580
- type="button"
581
- onMouseDown={(e) => {
582
- e.preventDefault()
583
- toggleOption(opt.value)
584
- }}
600
+ <DropdownPanel
601
+ triggerRef={inputRef}
602
+ isOpen={isOpen}
603
+ onClose={closeDropdown}
604
+ maxHeight={192}
605
+ className="border border-white/15 rounded-cms-sm"
606
+ exemptRefs={[containerRef]}
607
+ >
608
+ {filtered.length === 0
609
+ ? <div class="px-3 py-2 text-xs text-white/40">No options found</div>
610
+ : filtered.map(opt => {
611
+ const isSelected = selected.includes(opt.value)
612
+ return (
613
+ <button
614
+ key={opt.value}
615
+ type="button"
616
+ onMouseDown={(e) => {
617
+ e.preventDefault()
618
+ toggleOption(opt.value)
619
+ }}
620
+ class={cn(
621
+ 'w-full text-left px-3 py-2 text-xs transition-colors cursor-pointer flex items-center gap-2',
622
+ isSelected
623
+ ? 'bg-cms-primary/10 text-white'
624
+ : 'text-white/70 hover:bg-white/10 hover:text-white',
625
+ )}
626
+ data-cms-ui
627
+ >
628
+ <span
585
629
  class={cn(
586
- 'w-full text-left px-3 py-2 text-xs transition-colors cursor-pointer flex items-center gap-2',
630
+ 'w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors',
587
631
  isSelected
588
- ? 'bg-cms-primary/10 text-white'
589
- : 'text-white/70 hover:bg-white/10 hover:text-white',
632
+ ? 'bg-cms-primary border-cms-primary'
633
+ : 'border-white/30 bg-white/5',
590
634
  )}
591
- data-cms-ui
592
635
  >
593
- <span
594
- class={cn(
595
- 'w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors',
596
- isSelected
597
- ? 'bg-cms-primary border-cms-primary'
598
- : 'border-white/30 bg-white/5',
599
- )}
600
- >
601
- {isSelected && (
602
- <svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
603
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
604
- </svg>
605
- )}
606
- </span>
607
- <span class="truncate font-medium">
608
- {query ? <HighlightMatch text={opt.label} query={query} /> : opt.label}
609
- </span>
610
- </button>
611
- )
612
- })}
613
- </div>
614
- )}
636
+ {isSelected && (
637
+ <svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
638
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
639
+ </svg>
640
+ )}
641
+ </span>
642
+ <span class="truncate font-medium">
643
+ {query ? <HighlightMatch text={opt.label} query={query} /> : opt.label}
644
+ </span>
645
+ </button>
646
+ )
647
+ })}
648
+ </DropdownPanel>
615
649
  </div>
616
650
  )
617
651
  }
@@ -394,7 +394,7 @@ export function SchemaFrontmatterField({
394
394
  value,
395
395
  onChange,
396
396
  }: SchemaFrontmatterFieldProps) {
397
- const label = formatFieldLabel(field.name)
397
+ const label = field.required ? `${formatFieldLabel(field.name)} *` : formatFieldLabel(field.name)
398
398
  const hints = field.hints
399
399
 
400
400
  switch (field.type) {
@@ -410,6 +410,7 @@ export function SchemaFrontmatterField({
410
410
  minLength={hints?.minLength as number | undefined}
411
411
  onChange={(v) => onChange(v)}
412
412
  inputType={field.type === 'text' ? undefined : field.type}
413
+ required={field.required}
413
414
  />
414
415
  )
415
416
 
@@ -425,6 +426,7 @@ export function SchemaFrontmatterField({
425
426
  onChange(url)
426
427
  })
427
428
  }}
429
+ required={field.required}
428
430
  />
429
431
  )
430
432
 
@@ -435,6 +437,7 @@ export function SchemaFrontmatterField({
435
437
  value={(value as string) ?? ''}
436
438
  placeholder={getPlaceholder(field)}
437
439
  onChange={(v) => onChange(v)}
440
+ required={field.required}
438
441
  />
439
442
  )
440
443
 
@@ -448,6 +451,7 @@ export function SchemaFrontmatterField({
448
451
  placeholder={hints?.placeholder ?? getPlaceholder(field)}
449
452
  rows={hints?.rows ?? 3}
450
453
  maxLength={hints?.maxLength as number | undefined}
454
+ required={field.required}
451
455
  class="px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-cms-sm text-white placeholder-white/30 focus:outline-none focus:border-white/40 resize-none"
452
456
  data-cms-ui
453
457
  />
@@ -465,6 +469,7 @@ export function SchemaFrontmatterField({
465
469
  value={(value as string) ?? ''}
466
470
  min={hints?.min != null ? String(hints.min) : undefined}
467
471
  max={hints?.max != null ? String(hints.max) : undefined}
472
+ required={field.required}
468
473
  onInput={(e) => onChange((e.target as HTMLInputElement).value)}
469
474
  class="px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-cms-sm text-white focus:outline-none focus:border-white/40"
470
475
  data-cms-ui
@@ -482,6 +487,7 @@ export function SchemaFrontmatterField({
482
487
  max={typeof hints?.max === 'number' ? hints.max : undefined}
483
488
  step={hints?.step}
484
489
  onChange={(v) => onChange(v ?? 0)}
490
+ required={field.required}
485
491
  />
486
492
  )
487
493
 
@@ -505,6 +511,7 @@ export function SchemaFrontmatterField({
505
511
  label: opt,
506
512
  }))}
507
513
  onChange={(v) => onChange(v)}
514
+ required={field.required}
508
515
  />
509
516
  )
510
517
 
@@ -517,6 +524,7 @@ export function SchemaFrontmatterField({
517
524
  placeholder={`Select ${label.toLowerCase()}...`}
518
525
  options={refOptions}
519
526
  onChange={(v) => onChange(v)}
527
+ required={field.required}
520
528
  />
521
529
  )
522
530
  }
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
2
2
  import { HighlightMatch } from './fields'
3
+ import { PrimaryButton } from './modal-shell'
3
4
 
4
5
  export interface LinkSuggestion {
5
6
  value: string
@@ -189,13 +190,9 @@ export function LinkEditPopover({ initialUrl, suggestions, onApply, onRemove, on
189
190
  )}
190
191
  </div>
191
192
 
192
- <button
193
- type="submit"
194
- class="px-3 py-1.5 bg-cms-primary text-cms-primary-text text-[12px] font-medium rounded-cms-sm hover:bg-cms-primary-hover transition-colors shrink-0"
195
- data-cms-ui
196
- >
193
+ <PrimaryButton type="submit" className="px-3 py-1.5 text-[12px] rounded-cms-sm shrink-0">
197
194
  Apply
198
- </button>
195
+ </PrimaryButton>
199
196
 
200
197
  {onRemove && (
201
198
  <button
@@ -20,6 +20,8 @@ import {
20
20
  import { CreateModeFrontmatter, EditModeFrontmatter } from './frontmatter-fields'
21
21
  import { FrontmatterSidebar, partitionFields } from './frontmatter-sidebar'
22
22
  import { MarkdownInlineEditor } from './markdown-inline-editor'
23
+ import { CancelButton, PrimaryButton } from './modal-shell'
24
+ import { Spinner } from './spinner'
23
25
 
24
26
  /**
25
27
  * Wrapper component that renders the editor in place of markdown content.
@@ -55,6 +57,17 @@ export function MarkdownEditorOverlay() {
55
57
  const previewTargetRef = useRef<HTMLElement | null>(null)
56
58
  const editorInstanceRef = useRef<Editor | null>(null)
57
59
 
60
+ // Lock page scroll while the modal overlay is visible (not during preview)
61
+ useEffect(() => {
62
+ if (!page || isPreview) return
63
+ const html = document.documentElement
64
+ const prevOverflow = html.style.overflow
65
+ html.style.overflow = 'hidden'
66
+ return () => {
67
+ html.style.overflow = prevOverflow
68
+ }
69
+ }, [!!page, isPreview])
70
+
58
71
  useEffect(() => {
59
72
  if (isCreateMode || isDataCollection) {
60
73
  setShowFrontmatter(true)
@@ -76,6 +89,13 @@ export function MarkdownEditorOverlay() {
76
89
 
77
90
  /** Find the [data-cms-markdown] wrapper element on the actual page (not CMS UI). */
78
91
  const findMarkdownWrapper = useCallback((): HTMLElement | null => {
92
+ // Use the active element ID to target the correct wrapper directly
93
+ const activeId = markdownEditorState.value.activeElementId
94
+ if (activeId) {
95
+ const el = document.querySelector(`[data-cms-id="${activeId}"]`) as HTMLElement | null
96
+ if (el) return el
97
+ }
98
+ // Fallback: find any markdown wrapper
79
99
  const SKIP_TAGS = new Set(['BODY', 'HTML', 'BUTTON', 'SPAN', 'A'])
80
100
  const candidates = document.querySelectorAll('[data-cms-markdown]:not([data-cms-ui])')
81
101
  for (const c of candidates) {
@@ -348,8 +368,7 @@ export function MarkdownEditorOverlay() {
348
368
  >
349
369
  Back to Editor
350
370
  </button>
351
- <button
352
- type="button"
371
+ <PrimaryButton
353
372
  onClick={() => {
354
373
  const currentContent = currentMarkdownPage.value?.content
355
374
  if (currentContent !== undefined) {
@@ -357,12 +376,11 @@ export function MarkdownEditorOverlay() {
357
376
  }
358
377
  }}
359
378
  disabled={isSaving}
360
- class="px-3 py-1.5 text-sm bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover rounded-cms-pill transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
361
- data-cms-ui
379
+ className="px-3 py-1.5 flex items-center gap-1.5"
362
380
  >
363
- {isSaving && <div class="animate-spin rounded-full h-3 w-3 border-2 border-cms-primary-text/30 border-t-cms-primary-text" />}
381
+ {isSaving && <Spinner size="xs" className="text-cms-primary-text" />}
364
382
  {isSaving ? 'Saving...' : 'Save'}
365
- </button>
383
+ </PrimaryButton>
366
384
  </div>
367
385
  )
368
386
  }
@@ -375,11 +393,22 @@ export function MarkdownEditorOverlay() {
375
393
  onMouseDown={stopPropagation}
376
394
  onClick={stopPropagation}
377
395
  >
378
- <div
396
+ <form
379
397
  class={`bg-cms-dark rounded-cms-xl shadow-[0_8px_32px_rgba(0,0,0,0.4)] border border-white/10 w-full max-h-[90vh] flex flex-col ${
380
398
  hasSidebar ? 'max-w-6xl' : 'max-w-4xl'
381
399
  }`}
382
400
  data-cms-ui
401
+ onSubmit={(e) => {
402
+ e.preventDefault()
403
+ if (isCreateMode) {
404
+ handleCreate()
405
+ } else {
406
+ const currentContent = currentMarkdownPage.value?.content
407
+ if (currentContent !== undefined) {
408
+ handleSave(currentContent)
409
+ }
410
+ }
411
+ }}
383
412
  >
384
413
  {/* Header */}
385
414
  <div class="flex items-center justify-between px-5 py-4 border-b border-white/10">
@@ -484,44 +513,13 @@ export function MarkdownEditorOverlay() {
484
513
  Preview
485
514
  </button>
486
515
  )}
487
- <button
488
- type="button"
489
- onClick={handleCancel}
490
- class="px-4 py-2 text-sm text-white/70 hover:text-white hover:bg-white/10 rounded-cms-pill transition-colors"
491
- data-cms-ui
492
- >
493
- Cancel
494
- </button>
495
- {isCreateMode
496
- ? (
497
- <button
498
- type="button"
499
- onClick={handleCreate}
500
- disabled={isSaving || !(page.frontmatter.title as string)?.trim()}
501
- class="px-4 py-2 text-sm bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover rounded-cms-pill transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
502
- data-cms-ui
503
- >
504
- {isSaving && <div class="animate-spin rounded-full h-3.5 w-3.5 border-2 border-cms-primary-text/30 border-t-cms-primary-text" />}
505
- {isSaving ? 'Creating...' : `Create ${collectionLabel}`}
506
- </button>
507
- )
508
- : (
509
- <button
510
- type="button"
511
- onClick={() => {
512
- const currentContent = currentMarkdownPage.value?.content
513
- if (currentContent !== undefined) {
514
- handleSave(currentContent)
515
- }
516
- }}
517
- disabled={isSaving}
518
- class="px-4 py-2 text-sm bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover rounded-cms-pill transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
519
- data-cms-ui
520
- >
521
- {isSaving && <div class="animate-spin rounded-full h-3.5 w-3.5 border-2 border-cms-primary-text/30 border-t-cms-primary-text" />}
522
- {isSaving ? 'Saving...' : 'Save'}
523
- </button>
524
- )}
516
+ <CancelButton onClick={handleCancel} />
517
+ <PrimaryButton type="submit" disabled={isSaving} className="px-4 py-2 flex items-center gap-2">
518
+ {isSaving && <Spinner size="sm" className="text-cms-primary-text" />}
519
+ {isSaving
520
+ ? (isCreateMode ? 'Creating...' : 'Saving...')
521
+ : (isCreateMode ? `Create ${collectionLabel}` : 'Save')}
522
+ </PrimaryButton>
525
523
  </div>
526
524
  </div>
527
525
 
@@ -577,7 +575,7 @@ export function MarkdownEditorOverlay() {
577
575
  />
578
576
  )}
579
577
  </div>
580
- </div>
578
+ </form>
581
579
  </div>
582
580
  )
583
581
  }