@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.
- package/dist/editor.js +11397 -11351
- package/package.json +1 -1
- package/src/collection-scanner.ts +87 -25
- 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 +5 -13
- 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 +1 -9
- package/src/editor/components/fields.tsx +134 -116
- package/src/editor/components/image-overlay.tsx +3 -14
- package/src/editor/components/link-edit-popover.tsx +3 -6
- package/src/editor/components/markdown-editor-overlay.tsx +31 -37
- package/src/editor/components/markdown-inline-editor.tsx +2 -1
- 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/plain-text-chip-utils.ts +14 -0
- package/src/editor/components/plain-text-chip.tsx +61 -0
- package/src/editor/components/prop-editor.tsx +67 -68
- 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/text-style-toolbar.tsx +2 -15
- package/src/editor/components/toolbar.tsx +2 -1
- package/src/editor/constants.ts +33 -0
- package/src/editor/dom.ts +37 -0
- package/src/editor/editor.ts +90 -5
- 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/editor/index.tsx +9 -0
- package/src/handlers/source-writer.ts +75 -21
- package/src/html-processor.ts +75 -94
- package/src/index.ts +5 -0
- package/src/rehype-cms-marker.ts +15 -0
- package/src/source-finder/ast-extractors.ts +37 -0
- package/src/source-finder/cache.ts +23 -0
- package/src/source-finder/search-index.ts +304 -13
- package/src/source-finder/snippet-utils.ts +179 -2
- package/src/source-finder/types.ts +3 -0
- 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
|
-
|
|
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
|
|
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={
|
|
432
|
-
onBlur={
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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-
|
|
630
|
+
'w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors',
|
|
603
631
|
isSelected
|
|
604
|
-
? 'bg-cms-primary
|
|
605
|
-
: '
|
|
632
|
+
? 'bg-cms-primary border-cms-primary'
|
|
633
|
+
: 'border-white/30 bg-white/5',
|
|
606
634
|
)}
|
|
607
|
-
data-cms-ui
|
|
608
635
|
>
|
|
609
|
-
|
|
610
|
-
class=
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
<
|
|
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
|
}
|
|
@@ -495,37 +513,13 @@ export function MarkdownEditorOverlay() {
|
|
|
495
513
|
Preview
|
|
496
514
|
</button>
|
|
497
515
|
)}
|
|
498
|
-
<
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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:
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
<
|
|
395
|
+
<Spinner size="xl" className="text-cms-primary" />
|
|
418
396
|
</div>
|
|
419
397
|
)
|
|
420
398
|
: folders.length === 0 && filteredItems.length === 0
|