@nuasite/cms 0.29.0 → 0.31.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 (42) hide show
  1. package/dist/editor.js +11397 -11351
  2. package/package.json +1 -1
  3. package/src/collection-scanner.ts +87 -25
  4. package/src/editor/components/attribute-editor.tsx +2 -10
  5. package/src/editor/components/bg-image-overlay.tsx +2 -10
  6. package/src/editor/components/collections-browser.tsx +5 -13
  7. package/src/editor/components/color-toolbar.tsx +2 -9
  8. package/src/editor/components/confirm-dialog.tsx +4 -12
  9. package/src/editor/components/create-page-modal.tsx +1 -9
  10. package/src/editor/components/fields.tsx +134 -116
  11. package/src/editor/components/image-overlay.tsx +3 -14
  12. package/src/editor/components/link-edit-popover.tsx +3 -6
  13. package/src/editor/components/markdown-editor-overlay.tsx +31 -37
  14. package/src/editor/components/markdown-inline-editor.tsx +2 -1
  15. package/src/editor/components/mdx-component-picker.tsx +3 -6
  16. package/src/editor/components/media-library.tsx +15 -37
  17. package/src/editor/components/modal-shell.tsx +34 -5
  18. package/src/editor/components/plain-text-chip-utils.ts +14 -0
  19. package/src/editor/components/plain-text-chip.tsx +61 -0
  20. package/src/editor/components/prop-editor.tsx +67 -68
  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/text-style-toolbar.tsx +2 -15
  25. package/src/editor/components/toolbar.tsx +2 -1
  26. package/src/editor/constants.ts +33 -0
  27. package/src/editor/dom.ts +37 -0
  28. package/src/editor/editor.ts +90 -5
  29. package/src/editor/hooks/index.ts +4 -0
  30. package/src/editor/hooks/useClickOutsideEscape.ts +43 -0
  31. package/src/editor/hooks/useSearchFilter.ts +21 -0
  32. package/src/editor/index.tsx +9 -0
  33. package/src/handlers/source-writer.ts +75 -21
  34. package/src/html-processor.ts +75 -94
  35. package/src/index.ts +5 -0
  36. package/src/rehype-cms-marker.ts +15 -0
  37. package/src/source-finder/ast-extractors.ts +37 -0
  38. package/src/source-finder/cache.ts +23 -0
  39. package/src/source-finder/search-index.ts +304 -13
  40. package/src/source-finder/snippet-utils.ts +179 -2
  41. package/src/source-finder/types.ts +3 -0
  42. package/src/source-finder/variable-extraction.ts +8 -1
@@ -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
  // ============================================================================
@@ -334,6 +338,48 @@ export function HighlightMatch({ text, query }: { text: string; query: string })
334
338
  )
335
339
  }
336
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
+
337
383
  // ============================================================================
338
384
  // ComboBox Field (searchable dropdown with free-text input)
339
385
  // ============================================================================
@@ -356,14 +402,7 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
356
402
  const inputRef = useRef<HTMLInputElement>(null)
357
403
  const listRef = useRef<HTMLDivElement>(null)
358
404
 
359
- // Filter options based on query
360
- const filtered = useMemo(() => {
361
- if (!query) return options
362
- const q = query.toLowerCase()
363
- return options.filter(
364
- o => o.value.toLowerCase().includes(q) || o.label.toLowerCase().includes(q),
365
- )
366
- }, [query, options])
405
+ const filtered = useSearchFilter(options, query, o => `${o.label} ${o.value}`)
367
406
 
368
407
  const handleInput = useCallback((e: Event) => {
369
408
  const v = (e.target as HTMLInputElement).value
@@ -373,21 +412,14 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
373
412
  setHighlightedIndex(-1)
374
413
  }, [onChange])
375
414
 
376
- const handleFocus = useCallback(() => {
377
- setIsOpen(true)
378
- }, [])
379
-
380
- const handleBlur = useCallback(() => {
381
- // Delay to allow click on option to register
382
- setTimeout(() => setIsOpen(false), 150)
383
- }, [])
384
-
385
415
  const selectOption = useCallback((optValue: string) => {
386
416
  onChange(optValue)
387
417
  setQuery('')
388
418
  setIsOpen(false)
389
419
  }, [onChange])
390
420
 
421
+ const closeDropdown = useCallback(() => setIsOpen(false), [])
422
+
391
423
  const handleKeyDown = useCallback((e: KeyboardEvent) => {
392
424
  if (e.key === 'Enter') {
393
425
  e.preventDefault()
@@ -403,8 +435,6 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
403
435
  } else if (e.key === 'ArrowUp') {
404
436
  e.preventDefault()
405
437
  setHighlightedIndex(i => Math.max(i - 1, 0))
406
- } else if (e.key === 'Escape') {
407
- setIsOpen(false)
408
438
  }
409
439
  }, [isOpen, filtered, highlightedIndex, selectOption])
410
440
 
@@ -419,7 +449,7 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
419
449
  const showDropdown = isOpen && filtered.length > 0
420
450
 
421
451
  return (
422
- <div class="space-y-1.5 relative">
452
+ <div class="space-y-1.5">
423
453
  <FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
424
454
  <input
425
455
  ref={inputRef}
@@ -428,8 +458,8 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
428
458
  placeholder={placeholder}
429
459
  required={required}
430
460
  onInput={handleInput}
431
- onFocus={handleFocus}
432
- onBlur={handleBlur}
461
+ onFocus={() => setIsOpen(true)}
462
+ onBlur={() => setTimeout(closeDropdown, 150)}
433
463
  onKeyDown={handleKeyDown}
434
464
  autocomplete="off"
435
465
  class={cn(
@@ -440,40 +470,41 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
440
470
  )}
441
471
  data-cms-ui
442
472
  />
443
- {showDropdown && (
444
- <div
445
- ref={listRef}
446
- 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"
447
- data-cms-ui
448
- >
449
- {filtered.map((opt, i) => (
450
- <button
451
- key={opt.value}
452
- type="button"
453
- onMouseDown={(e) => {
454
- e.preventDefault()
455
- selectOption(opt.value)
456
- }}
457
- class={cn(
458
- 'w-full text-left px-3 py-2 text-xs transition-colors cursor-pointer',
459
- i === highlightedIndex
460
- ? 'bg-white/15 text-white'
461
- : 'text-white/70 hover:bg-white/10 hover:text-white',
462
- )}
463
- data-cms-ui
464
- >
465
- <span class="block truncate font-medium">
466
- <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} />
467
503
  </span>
468
- {opt.description && (
469
- <span class="block truncate text-white/40">
470
- <HighlightMatch text={opt.description} query={query} />
471
- </span>
472
- )}
473
- </button>
474
- ))}
475
- </div>
476
- )}
504
+ )}
505
+ </button>
506
+ ))}
507
+ </DropdownPanel>
477
508
  </div>
478
509
  )
479
510
  }
@@ -510,11 +541,7 @@ export function MultiSelectField({ label, selected, options, onChange, isDirty,
510
541
  return map
511
542
  }, [normalizedOptions])
512
543
 
513
- const filtered = useMemo(() => {
514
- if (!query) return normalizedOptions
515
- const q = query.toLowerCase()
516
- return normalizedOptions.filter(o => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q))
517
- }, [query, normalizedOptions])
544
+ const filtered = useSearchFilter(normalizedOptions, query, o => `${o.label} ${o.value}`)
518
545
 
519
546
  const toggleOption = useCallback((value: string) => {
520
547
  if (selected.includes(value)) {
@@ -524,20 +551,10 @@ export function MultiSelectField({ label, selected, options, onChange, isDirty,
524
551
  }
525
552
  }, [selected, onChange])
526
553
 
527
- // Close on outside click
528
- useEffect(() => {
529
- if (!isOpen) return
530
- const handler = (e: MouseEvent) => {
531
- if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
532
- setIsOpen(false)
533
- }
534
- }
535
- document.addEventListener('mousedown', handler)
536
- return () => document.removeEventListener('mousedown', handler)
537
- }, [isOpen])
554
+ const closeDropdown = useCallback(() => setIsOpen(false), [])
538
555
 
539
556
  return (
540
- <div class="space-y-1.5 relative" ref={containerRef} data-cms-ui>
557
+ <div class="space-y-1.5" ref={containerRef} data-cms-ui>
541
558
  <FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
542
559
 
543
560
  {/* Selected pills */}
@@ -580,54 +597,55 @@ export function MultiSelectField({ label, selected, options, onChange, isDirty,
580
597
  data-cms-ui
581
598
  />
582
599
 
583
- {/* Dropdown */}
584
- {isOpen && (
585
- <div
586
- 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"
587
- data-cms-ui
588
- >
589
- {filtered.length === 0
590
- ? <div class="px-3 py-2 text-xs text-white/40">No options found</div>
591
- : filtered.map(opt => {
592
- const isSelected = selected.includes(opt.value)
593
- return (
594
- <button
595
- key={opt.value}
596
- type="button"
597
- onMouseDown={(e) => {
598
- e.preventDefault()
599
- toggleOption(opt.value)
600
- }}
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
601
629
  class={cn(
602
- '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',
603
631
  isSelected
604
- ? 'bg-cms-primary/10 text-white'
605
- : 'text-white/70 hover:bg-white/10 hover:text-white',
632
+ ? 'bg-cms-primary border-cms-primary'
633
+ : 'border-white/30 bg-white/5',
606
634
  )}
607
- data-cms-ui
608
635
  >
609
- <span
610
- class={cn(
611
- 'w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors',
612
- isSelected
613
- ? 'bg-cms-primary border-cms-primary'
614
- : 'border-white/30 bg-white/5',
615
- )}
616
- >
617
- {isSelected && (
618
- <svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
619
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
620
- </svg>
621
- )}
622
- </span>
623
- <span class="truncate font-medium">
624
- {query ? <HighlightMatch text={opt.label} query={query} /> : opt.label}
625
- </span>
626
- </button>
627
- )
628
- })}
629
- </div>
630
- )}
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>
631
649
  </div>
632
650
  )
633
651
  }
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useRef } from 'preact/hooks'
2
2
  import { Z_INDEX } from '../constants'
3
+ import { getCaretRangeFromPoint } from '../dom'
3
4
  import * as signals from '../signals'
4
5
 
5
6
  export interface ImageOverlayProps {
@@ -122,24 +123,12 @@ export function ImageOverlay({ visible, rect, element, cmsId }: ImageOverlayProp
122
123
  if (el instanceof HTMLElement && el.contentEditable === 'true') {
123
124
  e.preventDefault()
124
125
  e.stopPropagation()
125
- // Focus the element and place cursor at click position
126
126
  el.focus()
127
- // Try to place cursor at the click position using caretPositionFromPoint
128
- const caretPos = document.caretPositionFromPoint?.(e.clientX, e.clientY)
129
- ?? (document as { caretRangeFromPoint?: (x: number, y: number) => Range | null }).caretRangeFromPoint?.(e.clientX, e.clientY)
130
- if (caretPos) {
127
+ const range = getCaretRangeFromPoint(e.clientX, e.clientY)
128
+ if (range) {
131
129
  const selection = window.getSelection()
132
130
  if (selection) {
133
131
  selection.removeAllRanges()
134
- const range = document.createRange()
135
- if ('offsetNode' in caretPos) {
136
- // caretPositionFromPoint result
137
- range.setStart(caretPos.offsetNode, caretPos.offset)
138
- } else {
139
- // caretRangeFromPoint result (Range)
140
- range.setStart(caretPos.startContainer, caretPos.startOffset)
141
- }
142
- range.collapse(true)
143
132
  selection.addRange(range)
144
133
  }
145
134
  }
@@ -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
  }
@@ -495,37 +513,13 @@ export function MarkdownEditorOverlay() {
495
513
  Preview
496
514
  </button>
497
515
  )}
498
- <button
499
- type="button"
500
- onClick={handleCancel}
501
- class="px-4 py-2 text-sm text-white/70 hover:text-white hover:bg-white/10 rounded-cms-pill transition-colors"
502
- data-cms-ui
503
- >
504
- Cancel
505
- </button>
506
- {isCreateMode
507
- ? (
508
- <button
509
- type="submit"
510
- disabled={isSaving}
511
- 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"
512
- data-cms-ui
513
- >
514
- {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" />}
515
- {isSaving ? 'Creating...' : `Create ${collectionLabel}`}
516
- </button>
517
- )
518
- : (
519
- <button
520
- type="submit"
521
- disabled={isSaving}
522
- 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"
523
- data-cms-ui
524
- >
525
- {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" />}
526
- {isSaving ? 'Saving...' : 'Save'}
527
- </button>
528
- )}
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>
529
523
  </div>
530
524
  </div>
531
525
 
@@ -20,6 +20,7 @@ import { config, mdxComponentPickerOpen, openMediaLibraryWithCallback, resetMark
20
20
  import { LinkEditPopover } from './link-edit-popover'
21
21
  import { MdxComponentIcon } from './mdx-block-view'
22
22
  import { MdxComponentPicker } from './mdx-component-picker'
23
+ import { Spinner } from './spinner'
23
24
 
24
25
  export interface MarkdownInlineEditorProps {
25
26
  elementId: string
@@ -649,7 +650,7 @@ export function MarkdownInlineEditor({
649
650
  {/* Loading state */}
650
651
  {!isReady && (
651
652
  <div class="absolute inset-0 flex items-center justify-center bg-cms-dark/80">
652
- <div class="animate-spin rounded-full h-6 w-6 border-2 border-white/30 border-t-cms-primary" />
653
+ <Spinner size="lg" className="text-cms-primary" />
653
654
  </div>
654
655
  )}
655
656
  </div>
@@ -2,7 +2,7 @@ import { useState } from 'preact/hooks'
2
2
  import { getComponentDefinitions } from '../manifest'
3
3
  import { manifest, mdxComponentPickerOpen } from '../signals'
4
4
  import { ComponentCard, getDefaultProps } from './component-card'
5
- import { CancelButton, ModalBackdrop, ModalHeader } from './modal-shell'
5
+ import { CancelButton, ModalBackdrop, ModalHeader, PrimaryButton } from './modal-shell'
6
6
  import { PropEditor } from './prop-editor'
7
7
 
8
8
  export interface MdxComponentPickerProps {
@@ -106,12 +106,9 @@ export function MdxComponentPicker({ onInsert }: MdxComponentPickerProps) {
106
106
  </div>
107
107
  <div class="px-5 py-4 border-t border-white/10 flex gap-2 justify-end">
108
108
  <CancelButton onClick={resetSelection} label="Back" />
109
- <button
110
- onClick={handleConfirmInsert}
111
- class="px-4 py-2.5 bg-cms-primary text-cms-primary-text rounded-cms-pill cursor-pointer hover:bg-cms-primary-hover transition-all font-medium"
112
- >
109
+ <PrimaryButton onClick={handleConfirmInsert} className="px-4">
113
110
  Insert
114
- </button>
111
+ </PrimaryButton>
115
112
  </div>
116
113
  </>
117
114
  )
@@ -1,8 +1,11 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
2
2
  import { Z_INDEX } from '../constants'
3
+ import { useSearchFilter } from '../hooks/useSearchFilter'
3
4
  import { createMediaFolder, fetchMediaLibrary, fetchProjectImages, uploadMedia } from '../markdown-api'
4
5
  import { config, isMediaLibraryOpen, mediaLibraryState, resetMediaLibraryState, showToast } from '../signals'
5
6
  import type { MediaFolderItem, MediaItem, MediaTypeFilter } from '../types'
7
+ import { CloseButton, PrimaryButton } from './modal-shell'
8
+ import { Spinner } from './spinner'
6
9
 
7
10
  const VECTOR_TYPES = new Set(['image/svg+xml', 'image/x-icon'])
8
11
 
@@ -187,18 +190,12 @@ export function MediaLibrary() {
187
190
  setShowNewFolderInput(false)
188
191
  }
189
192
 
190
- // Client-side filtering: both search query AND type filter
191
- const filteredItems = useMemo(() => {
192
- let items = allItems
193
- if (typeFilter !== 'all') {
194
- items = items.filter((item) => matchesTypeFilter(item.contentType, typeFilter))
195
- }
196
- if (searchQuery) {
197
- const q = searchQuery.toLowerCase()
198
- items = items.filter((item) => item.filename.toLowerCase().includes(q))
199
- }
200
- return items
201
- }, [searchQuery, typeFilter, allItems])
193
+ // Client-side filtering: type filter, then search query
194
+ const typeFiltered = useMemo(
195
+ () => typeFilter === 'all' ? allItems : allItems.filter(item => matchesTypeFilter(item.contentType, typeFilter)),
196
+ [typeFilter, allItems],
197
+ )
198
+ const filteredItems = useSearchFilter(typeFiltered, searchQuery, item => item.filename)
202
199
 
203
200
  // Build breadcrumb segments
204
201
  const breadcrumbs = useMemo(() => {
@@ -230,16 +227,7 @@ export function MediaLibrary() {
230
227
  {/* Header */}
231
228
  <div class="flex items-center justify-between p-5 border-b border-white/10">
232
229
  <h2 class="text-lg font-semibold text-white">Media Library</h2>
233
- <button
234
- type="button"
235
- onClick={handleClose}
236
- class="text-white/50 hover:text-white p-1.5 hover:bg-white/10 rounded-full transition-colors"
237
- data-cms-ui
238
- >
239
- <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
240
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
241
- </svg>
242
- </button>
230
+ <CloseButton onClick={handleClose} />
243
231
  </div>
244
232
 
245
233
  {/* Breadcrumbs */}
@@ -307,14 +295,9 @@ export function MediaLibrary() {
307
295
  />
308
296
  </svg>
309
297
  </button>
310
- <button
311
- type="button"
312
- onClick={handleUploadClick}
313
- class="px-5 py-2.5 bg-cms-primary text-cms-primary-text rounded-cms-pill text-sm font-medium hover:bg-cms-primary-hover transition-colors"
314
- data-cms-ui
315
- >
298
+ <PrimaryButton onClick={handleUploadClick}>
316
299
  Upload
317
- </button>
300
+ </PrimaryButton>
318
301
  <input
319
302
  ref={fileInputRef}
320
303
  type="file"
@@ -372,14 +355,9 @@ export function MediaLibrary() {
372
355
  class="flex-1 px-3 py-1.5 bg-white/10 border border-white/20 rounded-cms-md text-sm text-white placeholder:text-white/40 focus:outline-none focus:border-white/40"
373
356
  data-cms-ui
374
357
  />
375
- <button
376
- type="button"
377
- onClick={handleCreateFolder}
378
- class="px-3 py-1.5 bg-cms-primary text-cms-primary-text rounded-cms-md text-xs font-medium hover:bg-cms-primary-hover transition-colors"
379
- data-cms-ui
380
- >
358
+ <PrimaryButton onClick={handleCreateFolder} className="px-3 py-1.5 rounded-cms-md text-xs">
381
359
  Create
382
- </button>
360
+ </PrimaryButton>
383
361
  <button
384
362
  type="button"
385
363
  onClick={() => {
@@ -414,7 +392,7 @@ export function MediaLibrary() {
414
392
  {isLoading
415
393
  ? (
416
394
  <div class="flex items-center justify-center h-48">
417
- <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-cms-primary" />
395
+ <Spinner size="xl" className="text-cms-primary" />
418
396
  </div>
419
397
  )
420
398
  : folders.length === 0 && filteredItems.length === 0