@nuasite/cms 0.18.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +76 -2
  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
@@ -0,0 +1,100 @@
1
+ import { useCallback } from 'preact/hooks'
2
+ import { deletePage } from '../markdown-api'
3
+ import {
4
+ config,
5
+ deletePageState,
6
+ isDeletePageOpen,
7
+ resetDeletePageState,
8
+ setDeletePageCreateRedirect,
9
+ setDeletePageRedirectTo,
10
+ setDeletingPage,
11
+ showToast,
12
+ } from '../signals'
13
+ import { CancelButton, ModalBackdrop, ModalFooter, ModalHeader } from './modal-shell'
14
+
15
+ export function DeletePageDialog() {
16
+ const visible = isDeletePageOpen.value
17
+ const state = deletePageState.value
18
+
19
+ if (!visible || !state.targetPage) return null
20
+
21
+ const handleDelete = useCallback(async () => {
22
+ const cfg = config.value
23
+ const currentState = deletePageState.value
24
+ if (!cfg || !currentState.targetPage) return
25
+
26
+ setDeletingPage(true)
27
+
28
+ const result = await deletePage(cfg, {
29
+ pagePath: currentState.targetPage.pathname,
30
+ createRedirect: currentState.createRedirect,
31
+ redirectTo: currentState.createRedirect ? currentState.redirectTo : undefined,
32
+ })
33
+
34
+ setDeletingPage(false)
35
+
36
+ if (result.success) {
37
+ resetDeletePageState()
38
+ showToast('Page deleted', 'success')
39
+ window.location.href = currentState.createRedirect && currentState.redirectTo ? currentState.redirectTo : '/'
40
+ } else {
41
+ showToast(result.error || 'Failed to delete page', 'error')
42
+ }
43
+ }, [])
44
+
45
+ return (
46
+ <ModalBackdrop onClose={() => resetDeletePageState()} maxWidth="max-w-md">
47
+ <ModalHeader title="Delete Page" onClose={() => resetDeletePageState()} />
48
+
49
+ <div class="p-5 space-y-4">
50
+ <p class="text-white/80">
51
+ Are you sure you want to delete <strong class="text-white">{state.targetPage.title || state.targetPage.pathname}</strong>?
52
+ </p>
53
+ <p class="text-sm text-white/50">
54
+ This will remove the file at{' '}
55
+ <code class="bg-white/10 px-1.5 py-0.5 rounded text-white/70">{state.targetPage.pathname}</code>. This action cannot be undone.
56
+ </p>
57
+
58
+ <div class="space-y-3 pt-2">
59
+ <label class="flex items-center gap-2.5 cursor-pointer" data-cms-ui>
60
+ <input
61
+ type="checkbox"
62
+ checked={state.createRedirect}
63
+ onChange={(e) => setDeletePageCreateRedirect((e.target as HTMLInputElement).checked)}
64
+ class="w-4 h-4 rounded accent-cms-primary"
65
+ data-cms-ui
66
+ />
67
+ <span class="text-sm text-white/70">Create redirect (307) to preserve SEO</span>
68
+ </label>
69
+
70
+ {state.createRedirect && (
71
+ <div class="space-y-1.5 pl-6.5">
72
+ <label class="text-sm font-medium text-white/50" data-cms-ui>Redirect to</label>
73
+ <input
74
+ type="text"
75
+ value={state.redirectTo}
76
+ onInput={(e) => setDeletePageRedirectTo((e.target as HTMLInputElement).value)}
77
+ placeholder="/"
78
+ class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md text-white placeholder:text-white/30 focus:outline-none focus:border-cms-primary/50"
79
+ data-cms-ui
80
+ />
81
+ </div>
82
+ )}
83
+ </div>
84
+ </div>
85
+
86
+ <ModalFooter>
87
+ <CancelButton onClick={() => resetDeletePageState()} />
88
+ <button
89
+ type="button"
90
+ onClick={handleDelete}
91
+ disabled={state.isDeleting}
92
+ class="px-5 py-2.5 text-sm font-medium rounded-cms-pill transition-colors cursor-pointer bg-red-600 text-white hover:bg-red-700 disabled:opacity-40 disabled:cursor-not-allowed"
93
+ data-cms-ui
94
+ >
95
+ {state.isDeleting ? 'Deleting...' : 'Delete Page'}
96
+ </button>
97
+ </ModalFooter>
98
+ </ModalBackdrop>
99
+ )
100
+ }
@@ -80,9 +80,30 @@ export interface ImageFieldProps {
80
80
  }
81
81
 
82
82
  export function ImageField({ label, value, placeholder, onChange, onBrowse, isDirty, onReset }: ImageFieldProps) {
83
+ const hasImage = !!value && value.length > 0
84
+
83
85
  return (
84
86
  <div class="space-y-1.5">
85
87
  <FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
88
+ {hasImage && (
89
+ <div
90
+ class="relative w-full h-24 rounded-cms-sm overflow-hidden bg-white/5 border border-white/10 cursor-pointer group"
91
+ onClick={onBrowse}
92
+ data-cms-ui
93
+ >
94
+ <img
95
+ src={value}
96
+ alt={label}
97
+ class="w-full h-full object-cover"
98
+ onError={(e) => {
99
+ ;(e.target as HTMLImageElement).style.display = 'none'
100
+ }}
101
+ />
102
+ <div class="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
103
+ <span class="text-white text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity">Change</span>
104
+ </div>
105
+ </div>
106
+ )}
86
107
  <div class="flex gap-2">
87
108
  <input
88
109
  type="text"
@@ -433,3 +454,157 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
433
454
  </div>
434
455
  )
435
456
  }
457
+
458
+ // ============================================================================
459
+ // MultiSelect Field (searchable checkbox list with selected items as pills)
460
+ // ============================================================================
461
+
462
+ export interface MultiSelectFieldProps {
463
+ label: string
464
+ selected: string[]
465
+ options: string[] | Array<{ value: string; label: string }>
466
+ onChange: (selected: string[]) => void
467
+ isDirty?: boolean
468
+ onReset?: () => void
469
+ }
470
+
471
+ interface NormalizedOption {
472
+ value: string
473
+ label: string
474
+ }
475
+
476
+ export function MultiSelectField({ label, selected, options, onChange, isDirty, onReset }: MultiSelectFieldProps) {
477
+ const [query, setQuery] = useState('')
478
+ const [isOpen, setIsOpen] = useState(false)
479
+ const inputRef = useRef<HTMLInputElement>(null)
480
+ const containerRef = useRef<HTMLDivElement>(null)
481
+
482
+ const normalizedOptions = useMemo<NormalizedOption[]>(() => options.map(o => typeof o === 'string' ? { value: o, label: o } : o), [options])
483
+
484
+ const labelMap = useMemo(() => {
485
+ const map = new Map<string, string>()
486
+ for (const o of normalizedOptions) map.set(o.value, o.label)
487
+ return map
488
+ }, [normalizedOptions])
489
+
490
+ const filtered = useMemo(() => {
491
+ if (!query) return normalizedOptions
492
+ const q = query.toLowerCase()
493
+ return normalizedOptions.filter(o => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q))
494
+ }, [query, normalizedOptions])
495
+
496
+ const toggleOption = useCallback((value: string) => {
497
+ if (selected.includes(value)) {
498
+ onChange(selected.filter(s => s !== value))
499
+ } else {
500
+ onChange([...selected, value])
501
+ }
502
+ }, [selected, onChange])
503
+
504
+ // Close on outside click
505
+ useEffect(() => {
506
+ if (!isOpen) return
507
+ const handler = (e: MouseEvent) => {
508
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
509
+ setIsOpen(false)
510
+ }
511
+ }
512
+ document.addEventListener('mousedown', handler)
513
+ return () => document.removeEventListener('mousedown', handler)
514
+ }, [isOpen])
515
+
516
+ return (
517
+ <div class="space-y-1.5 relative" ref={containerRef} data-cms-ui>
518
+ <FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
519
+
520
+ {/* Selected pills */}
521
+ {selected.length > 0 && (
522
+ <div class="flex flex-wrap gap-1.5">
523
+ {selected.map(val => (
524
+ <span
525
+ key={val}
526
+ class="inline-flex items-center gap-1 px-2 py-0.5 bg-cms-primary/20 text-cms-primary text-xs rounded-full"
527
+ >
528
+ {labelMap.get(val) ?? val}
529
+ <button
530
+ type="button"
531
+ onClick={() => toggleOption(val)}
532
+ class="text-cms-primary/60 hover:text-cms-primary transition-colors cursor-pointer"
533
+ data-cms-ui
534
+ >
535
+ <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
536
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
537
+ </svg>
538
+ </button>
539
+ </span>
540
+ ))}
541
+ </div>
542
+ )}
543
+
544
+ {/* Search input */}
545
+ <input
546
+ ref={inputRef}
547
+ type="text"
548
+ value={query}
549
+ placeholder={selected.length > 0 ? 'Search to add more...' : 'Search options...'}
550
+ onInput={(e) => {
551
+ setQuery((e.target as HTMLInputElement).value)
552
+ setIsOpen(true)
553
+ }}
554
+ onFocus={() => setIsOpen(true)}
555
+ autocomplete="off"
556
+ class="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-colors"
557
+ data-cms-ui
558
+ />
559
+
560
+ {/* Dropdown */}
561
+ {isOpen && (
562
+ <div
563
+ 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"
564
+ data-cms-ui
565
+ >
566
+ {filtered.length === 0
567
+ ? <div class="px-3 py-2 text-xs text-white/40">No options found</div>
568
+ : filtered.map(opt => {
569
+ const isSelected = selected.includes(opt.value)
570
+ return (
571
+ <button
572
+ key={opt.value}
573
+ type="button"
574
+ onMouseDown={(e) => {
575
+ e.preventDefault()
576
+ toggleOption(opt.value)
577
+ }}
578
+ class={cn(
579
+ 'w-full text-left px-3 py-2 text-xs transition-colors cursor-pointer flex items-center gap-2',
580
+ isSelected
581
+ ? 'bg-cms-primary/10 text-white'
582
+ : 'text-white/70 hover:bg-white/10 hover:text-white',
583
+ )}
584
+ data-cms-ui
585
+ >
586
+ <span
587
+ class={cn(
588
+ 'w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors',
589
+ isSelected
590
+ ? 'bg-cms-primary border-cms-primary'
591
+ : 'border-white/30 bg-white/5',
592
+ )}
593
+ >
594
+ {isSelected && (
595
+ <svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
596
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
597
+ </svg>
598
+ )}
599
+ </span>
600
+ <span class="truncate font-medium">
601
+ {query ? <HighlightMatch text={opt.label} query={query} /> : opt.label}
602
+ </span>
603
+ </button>
604
+ )
605
+ })}
606
+ </div>
607
+ )}
608
+ </div>
609
+ )
610
+ }