@nuasite/cms 0.18.1 → 0.19.1
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 +78 -14
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ComponentChildren, FunctionComponent } from 'preact'
|
|
2
2
|
import { useRef, useState } from 'preact/hooks'
|
|
3
|
-
import { CMS_VERSION } from '../constants'
|
|
3
|
+
import { CMS_VERSION, Z_INDEX } from '../constants'
|
|
4
4
|
import { cn } from '../lib/cn'
|
|
5
5
|
import * as signals from '../signals'
|
|
6
6
|
import { showConfirmDialog } from '../signals'
|
|
@@ -27,6 +27,18 @@ export interface ToolbarProps {
|
|
|
27
27
|
collectionDefinitions?: Record<string, CollectionDefinition>
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
type MenuItem = { label: string; icon: ComponentChildren; onClick: () => void; isActive?: boolean }
|
|
31
|
+
type MenuSection = { label: string; icon: ComponentChildren; items: MenuItem[] }
|
|
32
|
+
|
|
33
|
+
const GridIcon = () => (
|
|
34
|
+
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
35
|
+
<rect x="3" y="3" width="7" height="7" rx="1" />
|
|
36
|
+
<rect x="14" y="3" width="7" height="7" rx="1" />
|
|
37
|
+
<rect x="3" y="14" width="7" height="7" rx="1" />
|
|
38
|
+
<rect x="14" y="14" width="7" height="7" rx="1" />
|
|
39
|
+
</svg>
|
|
40
|
+
)
|
|
41
|
+
|
|
30
42
|
const DeploymentStatusIndicator = ({ onDismiss }: { onDismiss?: () => void }) => {
|
|
31
43
|
const deploymentStatus = signals.deploymentStatus.value
|
|
32
44
|
const lastDeployedAt = signals.lastDeployedAt.value
|
|
@@ -105,6 +117,7 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
|
|
|
105
117
|
const isPreviewingMarkdown = signals.isMarkdownPreview.value
|
|
106
118
|
const currentPageCollection = signals.currentPageCollection.value
|
|
107
119
|
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
|
120
|
+
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set())
|
|
108
121
|
const [showVersion, setShowVersion] = useState(false)
|
|
109
122
|
const versionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
110
123
|
|
|
@@ -130,11 +143,10 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
|
|
|
130
143
|
const isSelectMode = signals.isSelectMode.value
|
|
131
144
|
const isToolbarOpen = isEditing || isSelectMode
|
|
132
145
|
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
menuItems.push({
|
|
146
|
+
const menuSections: MenuSection[] = []
|
|
147
|
+
const topLevelItems: MenuItem[] = []
|
|
148
|
+
if (callbacks.onSelectElement && signals.config.value.features?.selectElement) {
|
|
149
|
+
topLevelItems.push({
|
|
138
150
|
label: 'Select Element',
|
|
139
151
|
icon: (
|
|
140
152
|
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
@@ -147,28 +159,80 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
|
|
|
147
159
|
})
|
|
148
160
|
}
|
|
149
161
|
|
|
150
|
-
// Single consolidated collections item
|
|
151
162
|
if (collectionDefinitions) {
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
163
|
+
const entries = Object.entries(collectionDefinitions)
|
|
164
|
+
if (entries.length > 0) {
|
|
165
|
+
const contentItems: MenuItem[] = entries.map(([name, def]) => ({
|
|
166
|
+
label: def.label,
|
|
167
|
+
icon: <GridIcon />,
|
|
168
|
+
onClick: () => callbacks.onOpenCollection?.(name),
|
|
169
|
+
}))
|
|
170
|
+
|
|
171
|
+
if (currentPageCollection && callbacks.onEditContent) {
|
|
172
|
+
contentItems.unshift({
|
|
173
|
+
label: 'Edit Content',
|
|
174
|
+
icon: (
|
|
175
|
+
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
176
|
+
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z" />
|
|
177
|
+
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
|
178
|
+
<path d="M10 13H8" />
|
|
179
|
+
<path d="M16 17H8" />
|
|
180
|
+
<path d="M16 13h-2" />
|
|
181
|
+
</svg>
|
|
182
|
+
),
|
|
183
|
+
onClick: () => callbacks.onEditContent?.(),
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
menuSections.push({
|
|
188
|
+
label: 'Content',
|
|
189
|
+
icon: <GridIcon />,
|
|
190
|
+
items: contentItems,
|
|
166
191
|
})
|
|
167
192
|
}
|
|
168
193
|
}
|
|
169
194
|
|
|
195
|
+
topLevelItems.push(
|
|
196
|
+
{
|
|
197
|
+
label: 'Edit Page',
|
|
198
|
+
icon: (
|
|
199
|
+
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
200
|
+
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
|
|
201
|
+
</svg>
|
|
202
|
+
),
|
|
203
|
+
onClick: () => callbacks.onEdit(),
|
|
204
|
+
isActive: isEditing,
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
label: 'New Page',
|
|
208
|
+
icon: (
|
|
209
|
+
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
210
|
+
<path d="M12 5v14m-7-7h14" />
|
|
211
|
+
</svg>
|
|
212
|
+
),
|
|
213
|
+
onClick: () => signals.setCreatePageOpen(true),
|
|
214
|
+
},
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
const destructiveItems: MenuItem[] = [
|
|
218
|
+
{
|
|
219
|
+
label: 'Delete Page',
|
|
220
|
+
icon: (
|
|
221
|
+
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
222
|
+
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
223
|
+
</svg>
|
|
224
|
+
),
|
|
225
|
+
onClick: () => {
|
|
226
|
+
const pathname = window.location.pathname
|
|
227
|
+
signals.openDeletePageDialog({ pathname })
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
]
|
|
231
|
+
|
|
232
|
+
const settingsItems: MenuItem[] = []
|
|
233
|
+
|
|
170
234
|
if (callbacks.onSeoEditor) {
|
|
171
|
-
|
|
235
|
+
settingsItems.push({
|
|
172
236
|
label: 'SEO',
|
|
173
237
|
icon: (
|
|
174
238
|
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
@@ -180,37 +244,33 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
|
|
|
180
244
|
})
|
|
181
245
|
}
|
|
182
246
|
|
|
183
|
-
|
|
184
|
-
label: '
|
|
247
|
+
settingsItems.push({
|
|
248
|
+
label: 'Redirects',
|
|
185
249
|
icon: (
|
|
186
250
|
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
187
|
-
<path d="
|
|
251
|
+
<path d="M9 18l6-6-6-6" />
|
|
252
|
+
<path d="M15 18l6-6-6-6" />
|
|
188
253
|
</svg>
|
|
189
254
|
),
|
|
190
|
-
onClick: () =>
|
|
191
|
-
isActive: isEditing,
|
|
255
|
+
onClick: () => signals.openRedirectsManager(),
|
|
192
256
|
})
|
|
193
257
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
<
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
</svg>
|
|
205
|
-
),
|
|
206
|
-
onClick: () => callbacks.onEditContent?.(),
|
|
207
|
-
})
|
|
208
|
-
}
|
|
258
|
+
menuSections.push({
|
|
259
|
+
label: 'Settings',
|
|
260
|
+
icon: (
|
|
261
|
+
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
262
|
+
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
|
|
263
|
+
<circle cx="12" cy="12" r="3" />
|
|
264
|
+
</svg>
|
|
265
|
+
),
|
|
266
|
+
items: settingsItems,
|
|
267
|
+
})
|
|
209
268
|
|
|
210
269
|
return (
|
|
211
270
|
<div
|
|
271
|
+
style={{ zIndex: Z_INDEX.MODAL }}
|
|
212
272
|
class={cn(
|
|
213
|
-
'fixed bottom-4 sm:bottom-8
|
|
273
|
+
'fixed bottom-4 sm:bottom-8 font-sans transition-all duration-300',
|
|
214
274
|
isToolbarOpen
|
|
215
275
|
? 'left-4 right-4 sm:left-1/2 sm:right-auto sm:-translate-x-1/2'
|
|
216
276
|
: 'right-4 sm:right-8',
|
|
@@ -345,10 +405,10 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
|
|
|
345
405
|
}}
|
|
346
406
|
/>
|
|
347
407
|
{/* Menu popover */}
|
|
348
|
-
<div class="absolute bottom-full right-0 mb-4 min-w-[
|
|
349
|
-
{
|
|
408
|
+
<div class="absolute bottom-full right-0 mb-4 min-w-[200px] bg-cms-dark rounded-cms-lg shadow-[0_8px_32px_rgba(0,0,0,0.4)] border border-white/10 overflow-hidden py-1">
|
|
409
|
+
{topLevelItems.map((item, index) => (
|
|
350
410
|
<button
|
|
351
|
-
key={index}
|
|
411
|
+
key={`top-${index}`}
|
|
352
412
|
onClick={(e) => {
|
|
353
413
|
e.stopPropagation()
|
|
354
414
|
item.onClick()
|
|
@@ -365,6 +425,77 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
|
|
|
365
425
|
{item.label}
|
|
366
426
|
</button>
|
|
367
427
|
))}
|
|
428
|
+
{topLevelItems.length > 0 && menuSections.length > 0 && <div class="border-t border-white/10 my-1" />}
|
|
429
|
+
{menuSections.map((section) => {
|
|
430
|
+
const isExpanded = expandedSections.has(section.label)
|
|
431
|
+
return (
|
|
432
|
+
<div key={section.label}>
|
|
433
|
+
<button
|
|
434
|
+
onClick={(e) => {
|
|
435
|
+
e.stopPropagation()
|
|
436
|
+
setExpandedSections((prev) => {
|
|
437
|
+
const next = new Set(prev)
|
|
438
|
+
if (next.has(section.label)) {
|
|
439
|
+
next.delete(section.label)
|
|
440
|
+
} else {
|
|
441
|
+
next.add(section.label)
|
|
442
|
+
}
|
|
443
|
+
return next
|
|
444
|
+
})
|
|
445
|
+
}}
|
|
446
|
+
class="w-full px-4 py-2.5 text-sm font-medium text-left transition-colors cursor-pointer flex items-center gap-3 text-white/80 hover:bg-white/10 hover:text-white"
|
|
447
|
+
>
|
|
448
|
+
<span class="shrink-0 opacity-70">{section.icon}</span>
|
|
449
|
+
{section.label}
|
|
450
|
+
<svg
|
|
451
|
+
class={cn('w-3.5 h-3.5 ml-auto opacity-50 transition-transform duration-150', isExpanded && 'rotate-180')}
|
|
452
|
+
viewBox="0 0 24 24"
|
|
453
|
+
fill="none"
|
|
454
|
+
stroke="currentColor"
|
|
455
|
+
stroke-width="2"
|
|
456
|
+
stroke-linecap="round"
|
|
457
|
+
stroke-linejoin="round"
|
|
458
|
+
>
|
|
459
|
+
<path d="m6 9 6 6 6-6" />
|
|
460
|
+
</svg>
|
|
461
|
+
</button>
|
|
462
|
+
{isExpanded && section.items.map((item, index) => (
|
|
463
|
+
<button
|
|
464
|
+
key={index}
|
|
465
|
+
onClick={(e) => {
|
|
466
|
+
e.stopPropagation()
|
|
467
|
+
item.onClick()
|
|
468
|
+
setIsMenuOpen(false)
|
|
469
|
+
}}
|
|
470
|
+
class={cn(
|
|
471
|
+
'w-full pl-11 pr-4 py-2 text-sm text-left transition-colors cursor-pointer flex items-center gap-3',
|
|
472
|
+
item.isActive
|
|
473
|
+
? 'bg-white/20 text-white'
|
|
474
|
+
: 'text-white/60 hover:bg-white/10 hover:text-white',
|
|
475
|
+
)}
|
|
476
|
+
>
|
|
477
|
+
<span class="shrink-0 opacity-70">{item.icon}</span>
|
|
478
|
+
{item.label}
|
|
479
|
+
</button>
|
|
480
|
+
))}
|
|
481
|
+
</div>
|
|
482
|
+
)
|
|
483
|
+
})}
|
|
484
|
+
{destructiveItems.length > 0 && <div class="border-t border-white/10 my-1" />}
|
|
485
|
+
{destructiveItems.map((item, index) => (
|
|
486
|
+
<button
|
|
487
|
+
key={`destructive-${index}`}
|
|
488
|
+
onClick={(e) => {
|
|
489
|
+
e.stopPropagation()
|
|
490
|
+
item.onClick()
|
|
491
|
+
setIsMenuOpen(false)
|
|
492
|
+
}}
|
|
493
|
+
class="w-full px-4 py-2.5 text-sm font-medium text-left transition-colors cursor-pointer flex items-center gap-3 text-red-400/80 hover:bg-red-500/10 hover:text-red-400"
|
|
494
|
+
>
|
|
495
|
+
<span class="shrink-0 opacity-70">{item.icon}</span>
|
|
496
|
+
{item.label}
|
|
497
|
+
</button>
|
|
498
|
+
))}
|
|
368
499
|
</div>
|
|
369
500
|
</>
|
|
370
501
|
)}
|
package/src/editor/constants.ts
CHANGED
|
@@ -114,3 +114,29 @@ export const CSS = {
|
|
|
114
114
|
/** Data attribute for background image elements */
|
|
115
115
|
BG_IMAGE_ATTRIBUTE: 'data-cms-bg-img',
|
|
116
116
|
} as const
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Clamp a floating panel horizontally within the viewport and compute its max height.
|
|
120
|
+
*/
|
|
121
|
+
export function clampPanelPosition(
|
|
122
|
+
cursor: { x: number; y: number },
|
|
123
|
+
panelWidth: number,
|
|
124
|
+
padding = LAYOUT.VIEWPORT_PADDING,
|
|
125
|
+
): { top: string; left: string; maxHeight: string } {
|
|
126
|
+
const viewportWidth = window.innerWidth
|
|
127
|
+
const viewportHeight = window.innerHeight
|
|
128
|
+
|
|
129
|
+
let left = cursor.x
|
|
130
|
+
if (left + panelWidth > viewportWidth - padding) {
|
|
131
|
+
left = viewportWidth - panelWidth - padding
|
|
132
|
+
}
|
|
133
|
+
if (left < padding) left = padding
|
|
134
|
+
|
|
135
|
+
const maxHeight = Math.max(viewportHeight - cursor.y - padding, 200)
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
top: `${cursor.y}px`,
|
|
139
|
+
left: `${left}px`,
|
|
140
|
+
maxHeight: `${maxHeight}px`,
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/editor/editor.ts
CHANGED
|
@@ -137,6 +137,16 @@ export async function startEditMode(
|
|
|
137
137
|
return
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
// Check if this is a reference field element (e.g., author name from a referenced collection)
|
|
141
|
+
// Reference elements open a picker to change the reference, not inline text editing
|
|
142
|
+
const manifestEntry = currentManifest.entries[cmsId]
|
|
143
|
+
if (manifestEntry?.referenceCollection && manifestEntry.referencedBy?.length) {
|
|
144
|
+
logDebug(config.debug, 'Reference element detected:', cmsId, manifestEntry.referenceCollection)
|
|
145
|
+
makeElementNonEditable(el)
|
|
146
|
+
setupReferenceClickHandler(config, el, cmsId, manifestEntry, currentManifest)
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
|
|
140
150
|
// Check if this is a markdown content element
|
|
141
151
|
// Markdown elements use WYSIWYG editing instead of contentEditable
|
|
142
152
|
if (el.hasAttribute(MARKDOWN_ATTRIBUTE)) {
|
|
@@ -893,6 +903,108 @@ export async function saveAllChanges(
|
|
|
893
903
|
}
|
|
894
904
|
}
|
|
895
905
|
|
|
906
|
+
/**
|
|
907
|
+
* Setup click handler for reference field elements.
|
|
908
|
+
* When clicked, resolves the owning entry via DOM traversal and opens a reference picker.
|
|
909
|
+
*/
|
|
910
|
+
function setupReferenceClickHandler(
|
|
911
|
+
config: CmsConfig,
|
|
912
|
+
el: HTMLElement,
|
|
913
|
+
cmsId: string,
|
|
914
|
+
entry: ManifestEntry,
|
|
915
|
+
manifest: { entries: Record<string, ManifestEntry> },
|
|
916
|
+
): void {
|
|
917
|
+
el.style.cursor = 'pointer'
|
|
918
|
+
// Remove the disabled guard so our click handler can fire
|
|
919
|
+
// (disableAllInteractiveElements adds a capturing handler that blocks clicks on <a> elements)
|
|
920
|
+
el.removeAttribute('data-cms-disabled')
|
|
921
|
+
|
|
922
|
+
el.addEventListener('click', (e) => {
|
|
923
|
+
e.preventDefault()
|
|
924
|
+
e.stopPropagation()
|
|
925
|
+
|
|
926
|
+
logDebug(config.debug, 'Reference element clicked:', cmsId, entry.referenceCollection)
|
|
927
|
+
|
|
928
|
+
// Find the owning entry by walking up the DOM to find a sibling
|
|
929
|
+
// that belongs to one of the referencing collections
|
|
930
|
+
const owner = findOwnerEntry(el, manifest, entry.referencedBy ?? [])
|
|
931
|
+
if (!owner) {
|
|
932
|
+
logDebug(config.debug, 'Could not resolve owning entry for reference:', cmsId)
|
|
933
|
+
return
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Look up the owning entry in collection definitions to get the real content file path
|
|
937
|
+
// (manifest sourcePath may point to a page file if resolved via prop tracking)
|
|
938
|
+
const collectionDefs = signals.manifest.value.collectionDefinitions
|
|
939
|
+
const ownerDef = collectionDefs?.[owner.collection]
|
|
940
|
+
const ownerEntryInfo = ownerDef?.entries?.find(e => e.slug === owner.slug)
|
|
941
|
+
const contentFilePath = ownerEntryInfo?.sourcePath
|
|
942
|
+
if (!contentFilePath) {
|
|
943
|
+
logDebug(config.debug, 'Could not resolve content file path for owner:', owner.collection, owner.slug)
|
|
944
|
+
return
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Use the clicked element's collectionSlug as the current value — it reflects
|
|
948
|
+
// what's actually rendered on the page, unlike cached collection data which can be stale
|
|
949
|
+
const rect = el.getBoundingClientRect()
|
|
950
|
+
signals.openReferencePicker({
|
|
951
|
+
cmsId,
|
|
952
|
+
fieldName: owner.fieldName,
|
|
953
|
+
collection: entry.referenceCollection!,
|
|
954
|
+
currentValue: owner.isArray ? null : (entry.collectionSlug ?? null),
|
|
955
|
+
ownerPath: contentFilePath,
|
|
956
|
+
isArray: owner.isArray ?? false,
|
|
957
|
+
currentValues: [],
|
|
958
|
+
cursorPos: { x: rect.left, y: rect.bottom + 4 },
|
|
959
|
+
})
|
|
960
|
+
})
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Walk up the DOM from a reference element to find the owning collection entry.
|
|
965
|
+
* At each parent level, only checks direct children (not nested descendants)
|
|
966
|
+
* to avoid matching elements from adjacent cards on listing pages.
|
|
967
|
+
*/
|
|
968
|
+
function findOwnerEntry(
|
|
969
|
+
element: HTMLElement,
|
|
970
|
+
manifest: { entries: Record<string, ManifestEntry> },
|
|
971
|
+
referencedBy: Array<{ collection: string; fieldName: string; isArray?: boolean }>,
|
|
972
|
+
): { collection: string; slug: string; fieldName: string; isArray?: boolean } | undefined {
|
|
973
|
+
const refMap = new Map(referencedBy.map(r => [r.collection, r]))
|
|
974
|
+
let current: HTMLElement | null = element
|
|
975
|
+
|
|
976
|
+
while (current && current !== document.body) {
|
|
977
|
+
const parent: HTMLElement | null = current.parentElement
|
|
978
|
+
if (!parent) break
|
|
979
|
+
|
|
980
|
+
for (const sibling of parent.children) {
|
|
981
|
+
if (sibling === current || !(sibling instanceof HTMLElement)) continue
|
|
982
|
+
|
|
983
|
+
const candidates: Element[] = sibling.hasAttribute('data-cms-id') ? [sibling] : []
|
|
984
|
+
candidates.push(...sibling.querySelectorAll('[data-cms-id]'))
|
|
985
|
+
|
|
986
|
+
for (const el of candidates) {
|
|
987
|
+
const id = el.getAttribute('data-cms-id')
|
|
988
|
+
if (!id) continue
|
|
989
|
+
const entry = manifest.entries[id]
|
|
990
|
+
if (!entry?.collectionSlug || !entry.collectionName) continue
|
|
991
|
+
|
|
992
|
+
const ref = refMap.get(entry.collectionName)
|
|
993
|
+
if (!ref) continue
|
|
994
|
+
|
|
995
|
+
return {
|
|
996
|
+
collection: entry.collectionName,
|
|
997
|
+
slug: entry.collectionSlug,
|
|
998
|
+
fieldName: ref.fieldName,
|
|
999
|
+
isArray: ref.isArray,
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
current = parent
|
|
1004
|
+
}
|
|
1005
|
+
return undefined
|
|
1006
|
+
}
|
|
1007
|
+
|
|
896
1008
|
/**
|
|
897
1009
|
* Setup click handler for markdown elements.
|
|
898
1010
|
* When a markdown element is clicked, it opens the WYSIWYG editor instead of using contentEditable.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { API } from './constants'
|
|
2
|
+
|
|
3
|
+
export async function fetchWithTimeout(
|
|
4
|
+
url: string,
|
|
5
|
+
options: RequestInit = {},
|
|
6
|
+
timeoutMs: number = API.REQUEST_TIMEOUT_MS,
|
|
7
|
+
): Promise<Response> {
|
|
8
|
+
const controller = new AbortController()
|
|
9
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
|
|
10
|
+
|
|
11
|
+
// If the caller already provided a signal, forward its abort to our controller
|
|
12
|
+
if (options.signal) {
|
|
13
|
+
options.signal.addEventListener('abort', () => controller.abort(), { once: true })
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
return await fetch(url, {
|
|
18
|
+
...options,
|
|
19
|
+
signal: controller.signal,
|
|
20
|
+
})
|
|
21
|
+
} finally {
|
|
22
|
+
clearTimeout(timeoutId)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** POST JSON and return parsed response, or an error object on failure. */
|
|
27
|
+
export async function postJson<TRes extends { success: boolean; error?: string }>(
|
|
28
|
+
url: string,
|
|
29
|
+
body: unknown,
|
|
30
|
+
errorContext?: string,
|
|
31
|
+
): Promise<TRes> {
|
|
32
|
+
const res = await fetchWithTimeout(url, {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
credentials: 'include',
|
|
35
|
+
headers: { 'Content-Type': 'application/json' },
|
|
36
|
+
body: JSON.stringify(body),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
if (!res.ok) {
|
|
40
|
+
const text = await res.text().catch(() => '')
|
|
41
|
+
const prefix = errorContext || 'Request failed'
|
|
42
|
+
return { success: false, error: `${prefix} (${res.status}): ${text || res.statusText}` } as TRes
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return res.json()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** GET JSON and return parsed response. Returns fallback on failure. */
|
|
49
|
+
export async function getJson<TRes>(
|
|
50
|
+
url: string,
|
|
51
|
+
fallback: TRes,
|
|
52
|
+
signal?: AbortSignal,
|
|
53
|
+
): Promise<TRes> {
|
|
54
|
+
const res = await fetchWithTimeout(url, {
|
|
55
|
+
method: 'GET',
|
|
56
|
+
credentials: 'include',
|
|
57
|
+
signal,
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
if (!res.ok) return fallback
|
|
61
|
+
return res.json()
|
|
62
|
+
}
|
package/src/editor/index.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import { CollectionsBrowser } from './components/collections-browser'
|
|
|
9
9
|
import { ColorToolbar } from './components/color-toolbar'
|
|
10
10
|
import { ConfirmDialog } from './components/confirm-dialog'
|
|
11
11
|
import { CreatePageModal } from './components/create-page-modal'
|
|
12
|
+
import { DeletePageDialog } from './components/delete-page-dialog'
|
|
12
13
|
import { EditableHighlights } from './components/editable-highlights'
|
|
13
14
|
import { ErrorBoundary } from './components/error-boundary'
|
|
14
15
|
import { ImageOverlay } from './components/image-overlay'
|
|
@@ -16,12 +17,15 @@ import { MarkdownEditorOverlay } from './components/markdown-editor-overlay'
|
|
|
16
17
|
import { MediaLibrary } from './components/media-library'
|
|
17
18
|
import { Outline } from './components/outline'
|
|
18
19
|
import { RedirectCountdown } from './components/redirect-countdown'
|
|
20
|
+
import { RedirectsManager } from './components/redirects-manager'
|
|
21
|
+
import { ReferencePicker } from './components/reference-picker'
|
|
19
22
|
import { SelectionHighlight } from './components/selection-highlight'
|
|
20
23
|
import { SeoEditor } from './components/seo-editor'
|
|
21
24
|
import { TextStyleToolbar } from './components/text-style-toolbar'
|
|
22
25
|
import { ToastContainer } from './components/toast/toast-container'
|
|
23
26
|
import { Toolbar } from './components/toolbar'
|
|
24
27
|
import { getConfig } from './config'
|
|
28
|
+
import { Z_INDEX } from './constants'
|
|
25
29
|
import { disableAllInteractiveElements, enableAllInteractiveElements, logDebug } from './dom'
|
|
26
30
|
import {
|
|
27
31
|
discardAllChanges,
|
|
@@ -278,6 +282,8 @@ const CmsUI = () => {
|
|
|
278
282
|
|
|
279
283
|
if (msg.type === 'cms-deselect-element') {
|
|
280
284
|
handleBlockEditorClose()
|
|
285
|
+
} else if (msg.type === 'cms-set-features') {
|
|
286
|
+
signals.setFeatures(msg.features)
|
|
281
287
|
}
|
|
282
288
|
}
|
|
283
289
|
|
|
@@ -290,6 +296,8 @@ const CmsUI = () => {
|
|
|
290
296
|
if (signals.isEditing.value) {
|
|
291
297
|
hideTooltip()
|
|
292
298
|
stopEditMode(updateUI)
|
|
299
|
+
} else if (signals.currentPageCollection.value) {
|
|
300
|
+
await openMarkdownEditorForCurrentPage()
|
|
293
301
|
} else {
|
|
294
302
|
signals.isSelectMode.value = false
|
|
295
303
|
await startEditMode(config, updateUI)
|
|
@@ -613,6 +621,14 @@ const CmsUI = () => {
|
|
|
613
621
|
<CreatePageModal />
|
|
614
622
|
</ErrorBoundary>
|
|
615
623
|
|
|
624
|
+
<ErrorBoundary componentName="Delete Page Dialog">
|
|
625
|
+
<DeletePageDialog />
|
|
626
|
+
</ErrorBoundary>
|
|
627
|
+
|
|
628
|
+
<ErrorBoundary componentName="Redirects Manager">
|
|
629
|
+
<RedirectsManager />
|
|
630
|
+
</ErrorBoundary>
|
|
631
|
+
|
|
616
632
|
<ErrorBoundary componentName="Markdown Editor">
|
|
617
633
|
<MarkdownEditorOverlay />
|
|
618
634
|
</ErrorBoundary>
|
|
@@ -621,6 +637,8 @@ const CmsUI = () => {
|
|
|
621
637
|
<MediaLibrary />
|
|
622
638
|
</ErrorBoundary>
|
|
623
639
|
|
|
640
|
+
<ReferencePicker />
|
|
641
|
+
|
|
624
642
|
<ErrorBoundary componentName="Confirm Dialog">
|
|
625
643
|
<ConfirmDialog />
|
|
626
644
|
</ErrorBoundary>
|
|
@@ -648,7 +666,7 @@ class CmsEditor {
|
|
|
648
666
|
private setupUI(): void {
|
|
649
667
|
const hostElement = document.createElement('div')
|
|
650
668
|
hostElement.id = 'cms-app-host'
|
|
651
|
-
hostElement.style.cssText =
|
|
669
|
+
hostElement.style.cssText = `position: fixed; top: 0; left: 0; width: 0; height: 0; z-index: ${Z_INDEX.MODAL};`
|
|
652
670
|
document.body.appendChild(hostElement)
|
|
653
671
|
|
|
654
672
|
// Create shadow DOM with closed mode for better isolation
|