@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.
- package/dist/editor.js +52746 -36711
- package/package.json +16 -14
- package/src/build-processor.ts +4 -1
- package/src/collection-scanner.ts +425 -48
- package/src/dev-middleware.ts +26 -203
- package/src/editor/api.ts +1 -22
- package/src/editor/components/ai-chat.tsx +3 -3
- package/src/editor/components/ai-tooltip.tsx +2 -1
- package/src/editor/components/block-editor.tsx +13 -108
- package/src/editor/components/collections-browser.tsx +168 -205
- package/src/editor/components/component-card.tsx +49 -0
- package/src/editor/components/confirm-dialog.tsx +34 -47
- package/src/editor/components/create-page-modal.tsx +529 -101
- package/src/editor/components/delete-page-dialog.tsx +100 -0
- package/src/editor/components/fields.tsx +175 -0
- package/src/editor/components/frontmatter-fields.tsx +281 -70
- package/src/editor/components/frontmatter-sidebar.tsx +223 -0
- package/src/editor/components/highlight-overlay.ts +3 -2
- package/src/editor/components/markdown-editor-overlay.tsx +131 -85
- package/src/editor/components/markdown-inline-editor.tsx +74 -5
- package/src/editor/components/mdx-block-view.tsx +102 -0
- package/src/editor/components/mdx-component-picker.tsx +123 -0
- package/src/editor/components/mdx-props-editor.tsx +94 -0
- package/src/editor/components/media-library.tsx +373 -100
- package/src/editor/components/modal-shell.tsx +87 -0
- package/src/editor/components/prop-editor.tsx +52 -0
- package/src/editor/components/redirect-countdown.tsx +3 -1
- package/src/editor/components/redirects-manager.tsx +269 -0
- package/src/editor/components/reference-picker.tsx +203 -0
- package/src/editor/components/seo-editor.tsx +285 -303
- package/src/editor/components/toast/toast-container.tsx +2 -1
- package/src/editor/components/toolbar.tsx +177 -46
- package/src/editor/constants.ts +26 -0
- package/src/editor/editor.ts +112 -0
- package/src/editor/fetch.ts +62 -0
- package/src/editor/index.tsx +19 -1
- package/src/editor/markdown-api.ts +105 -156
- package/src/editor/milkdown-mdx-plugin.tsx +269 -0
- package/src/editor/signals.ts +206 -13
- package/src/editor/types.ts +52 -1
- package/src/handlers/api-routes.ts +251 -0
- package/src/handlers/component-ops.ts +2 -18
- package/src/handlers/markdown-ops.ts +202 -47
- package/src/handlers/page-ops.ts +229 -0
- package/src/handlers/redirect-ops.ts +163 -0
- package/src/handlers/source-writer.ts +157 -1
- package/src/html-processor.ts +14 -2
- package/src/index.ts +76 -2
- package/src/manifest-writer.ts +19 -1
- package/src/media/contember.ts +2 -1
- package/src/media/local.ts +66 -28
- package/src/media/project-images.ts +81 -0
- package/src/media/s3.ts +32 -11
- package/src/media/types.ts +24 -2
- package/src/shared.ts +27 -0
- package/src/source-finder/collection-finder.ts +219 -41
- package/src/source-finder/index.ts +7 -1
- package/src/source-finder/search-index.ts +178 -36
- package/src/source-finder/snippet-utils.ts +423 -3
- package/src/types.ts +111 -2
- 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
|
+
}
|