@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.
- package/dist/editor.js +12447 -12473
- package/package.json +1 -1
- package/src/collection-scanner.ts +69 -35
- package/src/dev-middleware.ts +86 -45
- package/src/editor/components/attribute-editor.tsx +2 -10
- package/src/editor/components/bg-image-overlay.tsx +2 -10
- package/src/editor/components/collections-browser.tsx +8 -24
- package/src/editor/components/color-toolbar.tsx +2 -9
- package/src/editor/components/confirm-dialog.tsx +4 -12
- package/src/editor/components/create-page-modal.tsx +23 -19
- package/src/editor/components/fields.tsx +158 -124
- package/src/editor/components/frontmatter-fields.tsx +9 -1
- package/src/editor/components/link-edit-popover.tsx +3 -6
- package/src/editor/components/markdown-editor-overlay.tsx +44 -46
- package/src/editor/components/markdown-inline-editor.tsx +2 -1
- package/src/editor/components/mdx-block-view.tsx +1 -0
- package/src/editor/components/mdx-component-picker.tsx +3 -6
- package/src/editor/components/media-library.tsx +15 -37
- package/src/editor/components/modal-shell.tsx +34 -5
- package/src/editor/components/prop-editor.tsx +77 -73
- package/src/editor/components/reference-picker.tsx +6 -24
- package/src/editor/components/seo-editor.tsx +4 -10
- package/src/editor/components/spinner.tsx +17 -0
- package/src/editor/components/toolbar.tsx +2 -1
- package/src/editor/constants.ts +33 -0
- package/src/editor/hooks/index.ts +4 -0
- package/src/editor/hooks/useClickOutsideEscape.ts +43 -0
- package/src/editor/hooks/useSearchFilter.ts +21 -0
- package/src/field-types.ts +2 -0
- package/src/handlers/api-routes.ts +10 -16
- package/src/html-processor.ts +75 -94
- package/src/index.ts +5 -0
- package/src/manifest-writer.ts +15 -0
- package/src/rehype-cms-marker.ts +15 -0
- package/src/types.ts +1 -0
- package/src/vite-plugin.ts +18 -72
- 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(
|
|
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
|
-
|
|
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
|
|
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={
|
|
416
|
-
onBlur={
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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-
|
|
630
|
+
'w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors',
|
|
587
631
|
isSelected
|
|
588
|
-
? 'bg-cms-primary
|
|
589
|
-
: '
|
|
632
|
+
? 'bg-cms-primary border-cms-primary'
|
|
633
|
+
: 'border-white/30 bg-white/5',
|
|
590
634
|
)}
|
|
591
|
-
data-cms-ui
|
|
592
635
|
>
|
|
593
|
-
|
|
594
|
-
class=
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
<
|
|
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
|
-
|
|
361
|
-
data-cms-ui
|
|
379
|
+
className="px-3 py-1.5 flex items-center gap-1.5"
|
|
362
380
|
>
|
|
363
|
-
{isSaving && <
|
|
381
|
+
{isSaving && <Spinner size="xs" className="text-cms-primary-text" />}
|
|
364
382
|
{isSaving ? 'Saving...' : 'Save'}
|
|
365
|
-
</
|
|
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
|
-
<
|
|
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
|
-
<
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
</
|
|
578
|
+
</form>
|
|
581
579
|
</div>
|
|
582
580
|
)
|
|
583
581
|
}
|