@nuasite/cms 0.18.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/editor.js +52746 -36711
  2. package/package.json +16 -14
  3. package/src/build-processor.ts +4 -1
  4. package/src/collection-scanner.ts +425 -48
  5. package/src/dev-middleware.ts +26 -203
  6. package/src/editor/api.ts +1 -22
  7. package/src/editor/components/ai-chat.tsx +3 -3
  8. package/src/editor/components/ai-tooltip.tsx +2 -1
  9. package/src/editor/components/block-editor.tsx +13 -108
  10. package/src/editor/components/collections-browser.tsx +168 -205
  11. package/src/editor/components/component-card.tsx +49 -0
  12. package/src/editor/components/confirm-dialog.tsx +34 -47
  13. package/src/editor/components/create-page-modal.tsx +529 -101
  14. package/src/editor/components/delete-page-dialog.tsx +100 -0
  15. package/src/editor/components/fields.tsx +175 -0
  16. package/src/editor/components/frontmatter-fields.tsx +281 -70
  17. package/src/editor/components/frontmatter-sidebar.tsx +223 -0
  18. package/src/editor/components/highlight-overlay.ts +3 -2
  19. package/src/editor/components/markdown-editor-overlay.tsx +131 -85
  20. package/src/editor/components/markdown-inline-editor.tsx +74 -5
  21. package/src/editor/components/mdx-block-view.tsx +102 -0
  22. package/src/editor/components/mdx-component-picker.tsx +123 -0
  23. package/src/editor/components/mdx-props-editor.tsx +94 -0
  24. package/src/editor/components/media-library.tsx +373 -100
  25. package/src/editor/components/modal-shell.tsx +87 -0
  26. package/src/editor/components/prop-editor.tsx +52 -0
  27. package/src/editor/components/redirect-countdown.tsx +3 -1
  28. package/src/editor/components/redirects-manager.tsx +269 -0
  29. package/src/editor/components/reference-picker.tsx +203 -0
  30. package/src/editor/components/seo-editor.tsx +285 -303
  31. package/src/editor/components/toast/toast-container.tsx +2 -1
  32. package/src/editor/components/toolbar.tsx +177 -46
  33. package/src/editor/constants.ts +26 -0
  34. package/src/editor/editor.ts +112 -0
  35. package/src/editor/fetch.ts +62 -0
  36. package/src/editor/index.tsx +19 -1
  37. package/src/editor/markdown-api.ts +105 -156
  38. package/src/editor/milkdown-mdx-plugin.tsx +269 -0
  39. package/src/editor/signals.ts +206 -13
  40. package/src/editor/types.ts +52 -1
  41. package/src/handlers/api-routes.ts +251 -0
  42. package/src/handlers/component-ops.ts +2 -18
  43. package/src/handlers/markdown-ops.ts +202 -47
  44. package/src/handlers/page-ops.ts +229 -0
  45. package/src/handlers/redirect-ops.ts +163 -0
  46. package/src/handlers/source-writer.ts +157 -1
  47. package/src/html-processor.ts +14 -2
  48. package/src/index.ts +76 -2
  49. package/src/manifest-writer.ts +19 -1
  50. package/src/media/contember.ts +2 -1
  51. package/src/media/local.ts +66 -28
  52. package/src/media/project-images.ts +81 -0
  53. package/src/media/s3.ts +32 -11
  54. package/src/media/types.ts +24 -2
  55. package/src/shared.ts +27 -0
  56. package/src/source-finder/collection-finder.ts +219 -41
  57. package/src/source-finder/index.ts +7 -1
  58. package/src/source-finder/search-index.ts +178 -36
  59. package/src/source-finder/snippet-utils.ts +423 -3
  60. package/src/types.ts +111 -2
  61. package/src/utils.ts +40 -4
@@ -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
- // Build menu items dynamically
134
- const menuItems: Array<{ label: string; icon: ComponentChildren; onClick: () => void; isActive?: boolean }> = []
135
-
136
- if (callbacks.onSelectElement) {
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 labels = Object.values(collectionDefinitions).map((d) => d.label)
153
- const collectionsLabel = labels.length <= 2 ? labels.join(', ') : `${labels.slice(0, 2).join(', ')}, ...`
154
- if (labels.length > 0) {
155
- menuItems.push({
156
- label: collectionsLabel,
157
- icon: (
158
- <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
159
- <rect x="3" y="3" width="7" height="7" rx="1" />
160
- <rect x="14" y="3" width="7" height="7" rx="1" />
161
- <rect x="3" y="14" width="7" height="7" rx="1" />
162
- <rect x="14" y="14" width="7" height="7" rx="1" />
163
- </svg>
164
- ),
165
- onClick: () => callbacks.onOpenCollections?.(),
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
- menuItems.push({
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
- menuItems.push({
184
- label: 'Edit Page',
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="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" />
251
+ <path d="M9 18l6-6-6-6" />
252
+ <path d="M15 18l6-6-6-6" />
188
253
  </svg>
189
254
  ),
190
- onClick: () => callbacks.onEdit(),
191
- isActive: isEditing,
255
+ onClick: () => signals.openRedirectsManager(),
192
256
  })
193
257
 
194
- if (currentPageCollection && callbacks.onEditContent) {
195
- menuItems.push({
196
- label: 'Content',
197
- icon: (
198
- <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
199
- <path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z" />
200
- <path d="M14 2v4a2 2 0 0 0 2 2h4" />
201
- <path d="M10 13H8" />
202
- <path d="M16 17H8" />
203
- <path d="M16 13h-2" />
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 z-2147483647 font-sans transition-all duration-300',
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-[180px] bg-cms-dark rounded-cms-lg shadow-[0_8px_32px_rgba(0,0,0,0.4)] border border-white/10 overflow-hidden py-1">
349
- {menuItems.map((item, index) => (
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
  )}
@@ -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
+ }
@@ -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
+ }
@@ -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 = 'position: fixed; top: 0; left: 0; width: 0; height: 0; z-index: 2147483647;'
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