@nuasite/cms 0.3.0 → 0.5.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 (188) hide show
  1. package/dist/editor.js +35991 -0
  2. package/package.json +12 -11
  3. package/src/dev-middleware.ts +82 -2
  4. package/src/editor/components/block-editor.tsx +48 -57
  5. package/src/editor/components/outline.tsx +42 -35
  6. package/src/editor/components/toolbar.tsx +7 -4
  7. package/src/editor/hooks/useBlockEditorHandlers.ts +5 -5
  8. package/src/editor/hooks/useElementDetection.ts +2 -2
  9. package/src/editor/index.tsx +3 -2
  10. package/src/handlers/array-ops.ts +4 -4
  11. package/src/index.ts +54 -28
  12. package/dist/src/build-processor.d.ts +0 -20
  13. package/dist/src/build-processor.d.ts.map +0 -1
  14. package/dist/src/collection-scanner.d.ts +0 -6
  15. package/dist/src/collection-scanner.d.ts.map +0 -1
  16. package/dist/src/component-registry.d.ts +0 -67
  17. package/dist/src/component-registry.d.ts.map +0 -1
  18. package/dist/src/config.d.ts +0 -24
  19. package/dist/src/config.d.ts.map +0 -1
  20. package/dist/src/dev-middleware.d.ts +0 -20
  21. package/dist/src/dev-middleware.d.ts.map +0 -1
  22. package/dist/src/editor/ai.d.ts +0 -60
  23. package/dist/src/editor/ai.d.ts.map +0 -1
  24. package/dist/src/editor/api.d.ts +0 -154
  25. package/dist/src/editor/api.d.ts.map +0 -1
  26. package/dist/src/editor/color-utils.d.ts +0 -106
  27. package/dist/src/editor/color-utils.d.ts.map +0 -1
  28. package/dist/src/editor/components/ai-chat.d.ts +0 -11
  29. package/dist/src/editor/components/ai-chat.d.ts.map +0 -1
  30. package/dist/src/editor/components/ai-tooltip.d.ts +0 -12
  31. package/dist/src/editor/components/ai-tooltip.d.ts.map +0 -1
  32. package/dist/src/editor/components/attribute-editor.d.ts +0 -5
  33. package/dist/src/editor/components/attribute-editor.d.ts.map +0 -1
  34. package/dist/src/editor/components/block-editor.d.ts +0 -12
  35. package/dist/src/editor/components/block-editor.d.ts.map +0 -1
  36. package/dist/src/editor/components/collections-browser.d.ts +0 -2
  37. package/dist/src/editor/components/collections-browser.d.ts.map +0 -1
  38. package/dist/src/editor/components/color-toolbar.d.ts +0 -12
  39. package/dist/src/editor/components/color-toolbar.d.ts.map +0 -1
  40. package/dist/src/editor/components/confirm-dialog.d.ts +0 -2
  41. package/dist/src/editor/components/confirm-dialog.d.ts.map +0 -1
  42. package/dist/src/editor/components/create-page-modal.d.ts +0 -2
  43. package/dist/src/editor/components/create-page-modal.d.ts.map +0 -1
  44. package/dist/src/editor/components/editable-highlights.d.ts +0 -9
  45. package/dist/src/editor/components/editable-highlights.d.ts.map +0 -1
  46. package/dist/src/editor/components/error-boundary.d.ts +0 -32
  47. package/dist/src/editor/components/error-boundary.d.ts.map +0 -1
  48. package/dist/src/editor/components/fields.d.ts +0 -75
  49. package/dist/src/editor/components/fields.d.ts.map +0 -1
  50. package/dist/src/editor/components/frontmatter-fields.d.ts +0 -29
  51. package/dist/src/editor/components/frontmatter-fields.d.ts.map +0 -1
  52. package/dist/src/editor/components/highlight-overlay.d.ts +0 -64
  53. package/dist/src/editor/components/highlight-overlay.d.ts.map +0 -1
  54. package/dist/src/editor/components/image-overlay.d.ts +0 -12
  55. package/dist/src/editor/components/image-overlay.d.ts.map +0 -1
  56. package/dist/src/editor/components/markdown-editor-overlay.d.ts +0 -6
  57. package/dist/src/editor/components/markdown-editor-overlay.d.ts.map +0 -1
  58. package/dist/src/editor/components/markdown-inline-editor.d.ts +0 -10
  59. package/dist/src/editor/components/markdown-inline-editor.d.ts.map +0 -1
  60. package/dist/src/editor/components/media-library.d.ts +0 -2
  61. package/dist/src/editor/components/media-library.d.ts.map +0 -1
  62. package/dist/src/editor/components/outline.d.ts +0 -21
  63. package/dist/src/editor/components/outline.d.ts.map +0 -1
  64. package/dist/src/editor/components/redirect-countdown.d.ts +0 -2
  65. package/dist/src/editor/components/redirect-countdown.d.ts.map +0 -1
  66. package/dist/src/editor/components/seo-editor.d.ts +0 -2
  67. package/dist/src/editor/components/seo-editor.d.ts.map +0 -1
  68. package/dist/src/editor/components/text-style-toolbar.d.ts +0 -8
  69. package/dist/src/editor/components/text-style-toolbar.d.ts.map +0 -1
  70. package/dist/src/editor/components/toast/toast-container.d.ts +0 -7
  71. package/dist/src/editor/components/toast/toast-container.d.ts.map +0 -1
  72. package/dist/src/editor/components/toast/toast.d.ts +0 -7
  73. package/dist/src/editor/components/toast/toast.d.ts.map +0 -1
  74. package/dist/src/editor/components/toast/types.d.ts +0 -7
  75. package/dist/src/editor/components/toast/types.d.ts.map +0 -1
  76. package/dist/src/editor/components/toolbar.d.ts +0 -21
  77. package/dist/src/editor/components/toolbar.d.ts.map +0 -1
  78. package/dist/src/editor/config.d.ts +0 -4
  79. package/dist/src/editor/config.d.ts.map +0 -1
  80. package/dist/src/editor/constants.d.ts +0 -102
  81. package/dist/src/editor/constants.d.ts.map +0 -1
  82. package/dist/src/editor/context.d.ts +0 -14
  83. package/dist/src/editor/context.d.ts.map +0 -1
  84. package/dist/src/editor/dom.d.ts +0 -86
  85. package/dist/src/editor/dom.d.ts.map +0 -1
  86. package/dist/src/editor/editor.d.ts +0 -64
  87. package/dist/src/editor/editor.d.ts.map +0 -1
  88. package/dist/src/editor/history.d.ts +0 -20
  89. package/dist/src/editor/history.d.ts.map +0 -1
  90. package/dist/src/editor/hooks/index.d.ts +0 -14
  91. package/dist/src/editor/hooks/index.d.ts.map +0 -1
  92. package/dist/src/editor/hooks/useAIHandlers.d.ts +0 -22
  93. package/dist/src/editor/hooks/useAIHandlers.d.ts.map +0 -1
  94. package/dist/src/editor/hooks/useBlockEditorHandlers.d.ts +0 -18
  95. package/dist/src/editor/hooks/useBlockEditorHandlers.d.ts.map +0 -1
  96. package/dist/src/editor/hooks/useElementDetection.d.ts +0 -26
  97. package/dist/src/editor/hooks/useElementDetection.d.ts.map +0 -1
  98. package/dist/src/editor/hooks/useImageHoverDetection.d.ts +0 -12
  99. package/dist/src/editor/hooks/useImageHoverDetection.d.ts.map +0 -1
  100. package/dist/src/editor/hooks/useTextSelection.d.ts +0 -23
  101. package/dist/src/editor/hooks/useTextSelection.d.ts.map +0 -1
  102. package/dist/src/editor/hooks/useTooltipState.d.ts +0 -19
  103. package/dist/src/editor/hooks/useTooltipState.d.ts.map +0 -1
  104. package/dist/src/editor/hooks/utils.d.ts +0 -32
  105. package/dist/src/editor/hooks/utils.d.ts.map +0 -1
  106. package/dist/src/editor/index.d.ts +0 -12
  107. package/dist/src/editor/index.d.ts.map +0 -1
  108. package/dist/src/editor/lib/cn.d.ts +0 -3
  109. package/dist/src/editor/lib/cn.d.ts.map +0 -1
  110. package/dist/src/editor/manifest.d.ts +0 -19
  111. package/dist/src/editor/manifest.d.ts.map +0 -1
  112. package/dist/src/editor/markdown-api.d.ts +0 -36
  113. package/dist/src/editor/markdown-api.d.ts.map +0 -1
  114. package/dist/src/editor/signals.d.ts +0 -242
  115. package/dist/src/editor/signals.d.ts.map +0 -1
  116. package/dist/src/editor/storage.d.ts +0 -29
  117. package/dist/src/editor/storage.d.ts.map +0 -1
  118. package/dist/src/editor/text-styling.d.ts +0 -350
  119. package/dist/src/editor/text-styling.d.ts.map +0 -1
  120. package/dist/src/editor/themes.d.ts +0 -38
  121. package/dist/src/editor/themes.d.ts.map +0 -1
  122. package/dist/src/editor/types.d.ts +0 -454
  123. package/dist/src/editor/types.d.ts.map +0 -1
  124. package/dist/src/error-collector.d.ts +0 -56
  125. package/dist/src/error-collector.d.ts.map +0 -1
  126. package/dist/src/handlers/array-ops.d.ts +0 -59
  127. package/dist/src/handlers/array-ops.d.ts.map +0 -1
  128. package/dist/src/handlers/component-ops.d.ts +0 -60
  129. package/dist/src/handlers/component-ops.d.ts.map +0 -1
  130. package/dist/src/handlers/markdown-ops.d.ts +0 -41
  131. package/dist/src/handlers/markdown-ops.d.ts.map +0 -1
  132. package/dist/src/handlers/request-utils.d.ts +0 -20
  133. package/dist/src/handlers/request-utils.d.ts.map +0 -1
  134. package/dist/src/handlers/source-writer.d.ts +0 -51
  135. package/dist/src/handlers/source-writer.d.ts.map +0 -1
  136. package/dist/src/html-processor.d.ts +0 -63
  137. package/dist/src/html-processor.d.ts.map +0 -1
  138. package/dist/src/index.d.ts +0 -41
  139. package/dist/src/index.d.ts.map +0 -1
  140. package/dist/src/manifest-writer.d.ts +0 -111
  141. package/dist/src/manifest-writer.d.ts.map +0 -1
  142. package/dist/src/media/contember.d.ts +0 -15
  143. package/dist/src/media/contember.d.ts.map +0 -1
  144. package/dist/src/media/local.d.ts +0 -9
  145. package/dist/src/media/local.d.ts.map +0 -1
  146. package/dist/src/media/s3.d.ts +0 -12
  147. package/dist/src/media/s3.d.ts.map +0 -1
  148. package/dist/src/media/types.d.ts +0 -40
  149. package/dist/src/media/types.d.ts.map +0 -1
  150. package/dist/src/preview-generator.d.ts +0 -19
  151. package/dist/src/preview-generator.d.ts.map +0 -1
  152. package/dist/src/seo-processor.d.ts +0 -23
  153. package/dist/src/seo-processor.d.ts.map +0 -1
  154. package/dist/src/source-finder/ast-extractors.d.ts +0 -35
  155. package/dist/src/source-finder/ast-extractors.d.ts.map +0 -1
  156. package/dist/src/source-finder/ast-parser.d.ts +0 -18
  157. package/dist/src/source-finder/ast-parser.d.ts.map +0 -1
  158. package/dist/src/source-finder/cache.d.ts +0 -18
  159. package/dist/src/source-finder/cache.d.ts.map +0 -1
  160. package/dist/src/source-finder/collection-finder.d.ts +0 -29
  161. package/dist/src/source-finder/collection-finder.d.ts.map +0 -1
  162. package/dist/src/source-finder/cross-file-tracker.d.ts +0 -39
  163. package/dist/src/source-finder/cross-file-tracker.d.ts.map +0 -1
  164. package/dist/src/source-finder/element-finder.d.ts +0 -42
  165. package/dist/src/source-finder/element-finder.d.ts.map +0 -1
  166. package/dist/src/source-finder/image-finder.d.ts +0 -24
  167. package/dist/src/source-finder/image-finder.d.ts.map +0 -1
  168. package/dist/src/source-finder/index.d.ts +0 -9
  169. package/dist/src/source-finder/index.d.ts.map +0 -1
  170. package/dist/src/source-finder/search-index.d.ts +0 -27
  171. package/dist/src/source-finder/search-index.d.ts.map +0 -1
  172. package/dist/src/source-finder/snippet-utils.d.ts +0 -96
  173. package/dist/src/source-finder/snippet-utils.d.ts.map +0 -1
  174. package/dist/src/source-finder/source-lookup.d.ts +0 -16
  175. package/dist/src/source-finder/source-lookup.d.ts.map +0 -1
  176. package/dist/src/source-finder/types.d.ts +0 -167
  177. package/dist/src/source-finder/types.d.ts.map +0 -1
  178. package/dist/src/source-finder/variable-extraction.d.ts +0 -37
  179. package/dist/src/source-finder/variable-extraction.d.ts.map +0 -1
  180. package/dist/src/tailwind-colors.d.ts +0 -54
  181. package/dist/src/tailwind-colors.d.ts.map +0 -1
  182. package/dist/src/tsconfig.tsbuildinfo +0 -1
  183. package/dist/src/types.d.ts +0 -367
  184. package/dist/src/types.d.ts.map +0 -1
  185. package/dist/src/utils.d.ts +0 -61
  186. package/dist/src/utils.d.ts.map +0 -1
  187. package/dist/src/vite-plugin.d.ts +0 -14
  188. package/dist/src/vite-plugin.d.ts.map +0 -1
package/package.json CHANGED
@@ -14,7 +14,7 @@
14
14
  "directory": "packages/astro-cms"
15
15
  },
16
16
  "license": "Apache-2.0",
17
- "version": "0.3.0",
17
+ "version": "0.5.0",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -28,24 +28,23 @@
28
28
  "dependencies": {
29
29
  "@astrojs/compiler": "^2.13.0",
30
30
  "@babel/parser": "^7.24.0",
31
+ "node-html-parser": "^6.1.13",
32
+ "yaml": "^2.8.2"
33
+ },
34
+ "devDependencies": {
31
35
  "@milkdown/core": "^7.8.0",
36
+ "@milkdown/ctx": "^7.8.0",
32
37
  "@milkdown/plugin-listener": "^7.8.0",
33
38
  "@milkdown/preset-commonmark": "^7.8.0",
34
39
  "@milkdown/preset-gfm": "^7.8.0",
40
+ "@milkdown/prose": "^7.8.0",
35
41
  "@milkdown/utils": "^7.8.0",
36
42
  "@preact/signals": "^2.6.1",
37
43
  "@tailwindcss/vite": "^4.1.11",
44
+ "@types/bun": "latest",
38
45
  "clsx": "^2.1.1",
39
46
  "marked": "^17.0.1",
40
- "node-html-parser": "^6.1.13",
41
47
  "preact": "^10.28.0",
42
- "tailwind-merge": "^3.4.0",
43
- "yaml": "^2.8.2"
44
- },
45
- "devDependencies": {
46
- "@types/bun": "latest",
47
- "@milkdown/ctx": "^7.8.0",
48
- "@milkdown/prose": "^7.8.0",
49
48
  "prosemirror-commands": "^1.7.1",
50
49
  "prosemirror-inputrules": "^1.5.1",
51
50
  "prosemirror-keymap": "^1.2.3",
@@ -54,7 +53,8 @@
54
53
  "prosemirror-schema-list": "^1.5.1",
55
54
  "prosemirror-state": "^1.4.4",
56
55
  "prosemirror-transform": "^1.10.5",
57
- "prosemirror-view": "^1.41.5"
56
+ "prosemirror-view": "^1.41.5",
57
+ "tailwind-merge": "^3.4.0"
58
58
  },
59
59
  "peerDependencies": {
60
60
  "astro": "^5.16.6",
@@ -68,7 +68,8 @@
68
68
  }
69
69
  },
70
70
  "scripts": {
71
- "prepack": "bun run ../../scripts/workspace-deps/resolve-deps.ts"
71
+ "build": "vite build --config vite.config.editor.ts",
72
+ "prepack": "bun run build && bun run ../../scripts/workspace-deps/resolve-deps.ts"
72
73
  },
73
74
  "keywords": [
74
75
  "astro",
@@ -98,18 +98,50 @@ export function createDevMiddleware(
98
98
  }
99
99
 
100
100
  // Serve global CMS manifest (component definitions, available colors, collection definitions, and settings)
101
- server.middlewares.use((req, res, next) => {
101
+ server.middlewares.use(async (req, res, next) => {
102
102
  const pathname = (req.url || '').split('?')[0]
103
103
  if (pathname === '/cms-manifest.json') {
104
104
  res.setHeader('Content-Type', 'application/json')
105
105
  res.setHeader('Access-Control-Allow-Origin', '*')
106
106
  res.setHeader('Cache-Control', 'no-store')
107
+
108
+ // Build pages from visited pages (have titles from SEO) + filesystem scan
109
+ const pageMap = new Map<string, { pathname: string; title?: string }>()
110
+
111
+ // 1. Add pages discovered from filesystem (src/pages)
112
+ const discoveredPages = await discoverPagesFromFilesystem()
113
+ for (const pagePath of discoveredPages) {
114
+ pageMap.set(pagePath, { pathname: pagePath })
115
+ }
116
+
117
+ // 2. Add collection entry pages from collection definitions
118
+ const collectionDefs = manifestWriter.getCollectionDefinitions()
119
+ for (const def of Object.values(collectionDefs)) {
120
+ if (def.entries) {
121
+ for (const entry of def.entries) {
122
+ if (entry.pathname) {
123
+ pageMap.set(entry.pathname, { pathname: entry.pathname, title: entry.title })
124
+ }
125
+ }
126
+ }
127
+ }
128
+
129
+ // 3. Overlay visited pages (they have SEO titles)
130
+ for (const [pagePath, data] of manifestWriter.getPageDataForPreviews()) {
131
+ const existing = pageMap.get(pagePath)
132
+ const title = data.seo?.title?.content || existing?.title
133
+ pageMap.set(pagePath, { pathname: pagePath, ...(title ? { title } : {}) })
134
+ }
135
+
136
+ const pages = Array.from(pageMap.values())
137
+ .sort((a, b) => a.pathname.localeCompare(b.pathname))
138
+
107
139
  const manifest: Record<string, unknown> = {
108
140
  componentDefinitions,
109
141
  availableColors: manifestWriter.getAvailableColors(),
110
142
  availableTextStyles: manifestWriter.getAvailableTextStyles(),
143
+ pages,
111
144
  }
112
- const collectionDefs = manifestWriter.getCollectionDefinitions()
113
145
  if (Object.keys(collectionDefs).length > 0) {
114
146
  manifest.collectionDefinitions = collectionDefs
115
147
  }
@@ -577,6 +609,54 @@ async function processHtmlForDev(
577
609
  }
578
610
  }
579
611
 
612
+ /** Page file extensions recognized by Astro */
613
+ const PAGE_EXTENSIONS = new Set(['.astro', '.md', '.mdx'])
614
+
615
+ /**
616
+ * Scan src/pages directory to discover all static page routes.
617
+ * Skips dynamic routes (files with [ in the name) and API routes (.ts/.js).
618
+ */
619
+ async function discoverPagesFromFilesystem(): Promise<string[]> {
620
+ const projectRoot = getProjectRoot()
621
+ const pagesDir = path.join(projectRoot, 'src', 'pages')
622
+
623
+ try {
624
+ await fs.access(pagesDir)
625
+ } catch {
626
+ return []
627
+ }
628
+
629
+ const pages: string[] = []
630
+
631
+ async function walk(dir: string, urlPrefix: string) {
632
+ const entries = await fs.readdir(dir, { withFileTypes: true })
633
+ for (const entry of entries) {
634
+ if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue
635
+
636
+ const fullPath = path.join(dir, entry.name)
637
+ if (entry.isDirectory()) {
638
+ // Skip directories with dynamic segments
639
+ if (entry.name.includes('[')) continue
640
+ await walk(fullPath, `${urlPrefix}${entry.name}/`)
641
+ } else {
642
+ const ext = path.extname(entry.name)
643
+ if (!PAGE_EXTENSIONS.has(ext)) continue
644
+ // Skip dynamic routes
645
+ if (entry.name.includes('[')) continue
646
+
647
+ const baseName = path.basename(entry.name, ext)
648
+ const pagePath = baseName === 'index'
649
+ ? urlPrefix.replace(/\/$/, '') || '/'
650
+ : `${urlPrefix}${baseName}`
651
+ pages.push(pagePath)
652
+ }
653
+ }
654
+ }
655
+
656
+ await walk(pagesDir, '/')
657
+ return pages
658
+ }
659
+
580
660
  function mediaMimeFromExt(ext: string): string {
581
661
  const map: Record<string, string> = {
582
662
  '.jpg': 'image/jpeg',
@@ -7,7 +7,7 @@ import type { ComponentProp, InsertPosition } from '../types'
7
7
  export interface BlockEditorProps {
8
8
  visible: boolean
9
9
  componentId: string | null
10
- rect: DOMRect | null
10
+ cursor: { x: number; y: number } | null
11
11
  onClose: () => void
12
12
  onUpdateProps: (componentId: string, props: Record<string, any>) => void
13
13
  onInsertComponent: (
@@ -24,7 +24,7 @@ type EditorMode = 'edit' | 'insert-picker' | 'insert-props' | 'confirm-remove'
24
24
  export function BlockEditor({
25
25
  visible,
26
26
  componentId,
27
- rect,
27
+ cursor,
28
28
  onClose,
29
29
  onUpdateProps,
30
30
  onInsertComponent,
@@ -66,7 +66,7 @@ export function BlockEditor({
66
66
  setSelectedComponent(null)
67
67
  setInsertPosition('after')
68
68
  }
69
- }, [visible, componentId])
69
+ }, [visible])
70
70
 
71
71
  useEffect(() => {
72
72
  if (currentInstance) {
@@ -79,7 +79,6 @@ export function BlockEditor({
79
79
 
80
80
  const updatePosition = () => {
81
81
  const editorWidth = LAYOUT.BLOCK_EDITOR_WIDTH
82
- const editorHeight = LAYOUT.BLOCK_EDITOR_HEIGHT
83
82
  const padding = LAYOUT.VIEWPORT_PADDING
84
83
  const viewportWidth = window.innerWidth
85
84
  const viewportHeight = window.innerHeight
@@ -87,18 +86,11 @@ export function BlockEditor({
87
86
  let top: number
88
87
  let left: number
89
88
 
90
- if (rect) {
91
- top = rect.bottom + padding
92
- left = rect.left
93
-
94
- if (top + editorHeight > viewportHeight - padding) {
95
- top = Math.max(padding, rect.top - editorHeight - padding)
96
- }
97
-
98
- if (top < padding) {
99
- top = Math.max(padding, (viewportHeight - editorHeight) / 2)
100
- }
89
+ if (cursor) {
90
+ top = cursor.y
91
+ left = cursor.x
101
92
 
93
+ // Keep within viewport bounds
102
94
  if (left + editorWidth > viewportWidth - padding) {
103
95
  left = viewportWidth - editorWidth - padding
104
96
  }
@@ -106,7 +98,7 @@ export function BlockEditor({
106
98
  left = padding
107
99
  }
108
100
  } else {
109
- top = (viewportHeight - editorHeight) / 2
101
+ top = viewportHeight / 2
110
102
  left = (viewportWidth - editorWidth) / 2
111
103
  }
112
104
 
@@ -119,13 +111,11 @@ export function BlockEditor({
119
111
 
120
112
  updatePosition()
121
113
  window.addEventListener('resize', updatePosition)
122
- window.addEventListener('scroll', updatePosition)
123
114
 
124
115
  return () => {
125
116
  window.removeEventListener('resize', updatePosition)
126
- window.removeEventListener('scroll', updatePosition)
127
117
  }
128
- }, [visible, rect])
118
+ }, [visible, cursor])
129
119
 
130
120
  // Inject/remove inline mock preview into the real page at the insertion point
131
121
  useEffect(() => {
@@ -361,28 +351,13 @@ export function BlockEditor({
361
351
  </div>
362
352
 
363
353
  {/* Content */}
364
- <div class="p-5 overflow-y-auto flex-1 bg-cms-dark">
354
+ <div class={`flex-1 flex flex-col overflow-hidden bg-cms-dark ${mode === 'edit' && currentDefinition ? '' : 'overflow-y-auto'}`}>
365
355
  {mode === 'edit' && currentDefinition
366
356
  ? (
367
357
  <>
368
- {/* Insert buttons */}
369
- <div class="mb-5 flex gap-2">
370
- <button
371
- onClick={() => handleStartInsert('before')}
372
- class="flex-1 py-2.5 px-3 bg-white/10 text-white/80 rounded-cms-md cursor-pointer text-[13px] font-medium flex items-center justify-center gap-1.5 hover:bg-white/20 hover:text-white transition-colors"
373
- >
374
- <span class="text-base">↑</span> {isArrayItem ? 'Add item before' : 'Insert before'}
375
- </button>
376
- <button
377
- onClick={() => handleStartInsert('after')}
378
- class="flex-1 py-2.5 px-3 bg-white/10 text-white/80 rounded-cms-md cursor-pointer text-[13px] font-medium flex items-center justify-center gap-1.5 hover:bg-white/20 hover:text-white transition-colors"
379
- >
380
- <span class="text-base">↓</span> {isArrayItem ? 'Add item after' : 'Insert after'}
381
- </button>
382
- </div>
383
-
384
- {/* Props editor */}
385
- <div class="mb-5">
358
+ {/* Scrollable body */}
359
+ <div class="p-5 overflow-y-auto flex-1">
360
+ {/* Props editor */}
386
361
  <div class="text-xs font-medium text-white/50 tracking-wide mb-3 uppercase">
387
362
  Properties
388
363
  </div>
@@ -396,34 +371,50 @@ export function BlockEditor({
396
371
  ))}
397
372
  </div>
398
373
 
399
- {/* Actions */}
400
- <div class="flex gap-2 justify-between pt-4 border-t border-white/10 mt-4">
401
- <button
402
- onClick={() => setMode('confirm-remove')}
403
- class="px-4 py-2.5 bg-cms-error text-white rounded-cms-pill cursor-pointer hover:bg-red-600 transition-colors font-medium"
404
- >
405
- {isArrayItem ? 'Remove item' : 'Remove'}
406
- </button>
374
+ {/* Insert buttons + Actions — outside scroll area */}
375
+ <div class="px-5 py-4 border-t border-white/10 flex flex-col gap-3">
407
376
  <div class="flex gap-2">
408
377
  <button
409
- onClick={onClose}
410
- class="px-4 py-2.5 bg-white/10 text-white/80 rounded-cms-pill cursor-pointer hover:bg-white/20 hover:text-white transition-colors font-medium"
378
+ onClick={() => handleStartInsert('before')}
379
+ class="flex-1 py-2.5 px-3 bg-white/10 text-white/80 rounded-cms-md cursor-pointer text-[13px] font-medium flex items-center justify-center gap-1.5 hover:bg-white/20 hover:text-white transition-colors"
380
+ >
381
+ <span class="text-base">↑</span> {isArrayItem ? 'Add item before' : 'Insert before'}
382
+ </button>
383
+ <button
384
+ onClick={() => handleStartInsert('after')}
385
+ class="flex-1 py-2.5 px-3 bg-white/10 text-white/80 rounded-cms-md cursor-pointer text-[13px] font-medium flex items-center justify-center gap-1.5 hover:bg-white/20 hover:text-white transition-colors"
411
386
  >
412
- Cancel
387
+ <span class="text-base">↓</span> {isArrayItem ? 'Add item after' : 'Insert after'}
413
388
  </button>
389
+ </div>
390
+ <div class="flex gap-2 justify-between">
414
391
  <button
415
- onClick={handleSave}
416
- 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"
392
+ onClick={() => setMode('confirm-remove')}
393
+ class="px-4 py-2.5 bg-cms-error text-white rounded-cms-pill cursor-pointer hover:bg-red-600 transition-colors font-medium"
417
394
  >
418
- Save
395
+ {isArrayItem ? 'Remove item' : 'Remove'}
419
396
  </button>
397
+ <div class="flex gap-2">
398
+ <button
399
+ onClick={onClose}
400
+ class="px-4 py-2.5 bg-white/10 text-white/80 rounded-cms-pill cursor-pointer hover:bg-white/20 hover:text-white transition-colors font-medium"
401
+ >
402
+ Cancel
403
+ </button>
404
+ <button
405
+ onClick={handleSave}
406
+ 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"
407
+ >
408
+ Save
409
+ </button>
410
+ </div>
420
411
  </div>
421
412
  </div>
422
413
  </>
423
414
  )
424
415
  : mode === 'confirm-remove'
425
416
  ? (
426
- <div class="text-center py-4">
417
+ <div class="text-center p-5">
427
418
  <div class="px-4 py-3 bg-red-500/10 border border-red-500/30 rounded-cms-md mb-5 text-[13px] text-white">
428
419
  {isArrayItem
429
420
  ? (
@@ -460,7 +451,7 @@ export function BlockEditor({
460
451
  )
461
452
  : mode === 'insert-props' && selectedComponent
462
453
  ? (
463
- <>
454
+ <div class="p-5">
464
455
  {/* New component props */}
465
456
  <div class="mb-5">
466
457
  <div class="px-4 py-3 bg-white/10 rounded-cms-md mb-4 text-[13px] text-white">
@@ -500,12 +491,12 @@ export function BlockEditor({
500
491
  {isArrayItem ? 'Add item' : 'Insert component'}
501
492
  </button>
502
493
  </div>
503
- </>
494
+ </div>
504
495
  )
505
496
  : mode === 'insert-picker'
506
497
  ? (
507
498
  /* Component picker for insertion */
508
- <div>
499
+ <div class="p-5">
509
500
  <div class="text-xs font-medium text-white/50 tracking-wide mb-4 uppercase">
510
501
  Select component to insert
511
502
  </div>
@@ -559,7 +550,7 @@ export function BlockEditor({
559
550
  )
560
551
  : (
561
552
  /* No component selected - show placeholder */
562
- <div class="text-center text-white/50 py-8">
553
+ <div class="text-center text-white/50 p-5 py-8">
563
554
  <p>Select a component to edit its properties.</p>
564
555
  </div>
565
556
  )}
@@ -110,6 +110,19 @@ export function Outline(
110
110
  box-shadow: 0 8px 32px rgba(0,0,0,0.3);
111
111
  pointer-events: auto;
112
112
  z-index: ${Z_INDEX.MODAL};
113
+ margin-top: 6px;
114
+ }
115
+
116
+ .element-toolbar::before {
117
+ content: '';
118
+ position: absolute;
119
+ top: -25px;
120
+ left: -50px;
121
+ right: -50px;
122
+ bottom: 0;
123
+ z-index: -1;
124
+ pointer-events: auto;
125
+ background: transparent;
113
126
  }
114
127
 
115
128
  .element-toolbar.hidden {
@@ -143,8 +156,8 @@ export function Outline(
143
156
  }
144
157
 
145
158
  .attr-button {
146
- width: 24px;
147
- height: 24px;
159
+ width: 32px;
160
+ height: 32px;
148
161
  display: flex;
149
162
  align-items: center;
150
163
  justify-content: center;
@@ -161,8 +174,8 @@ export function Outline(
161
174
  }
162
175
 
163
176
  .attr-button svg {
164
- width: 14px;
165
- height: 14px;
177
+ width: 18px;
178
+ height: 18px;
166
179
  color: rgba(255,255,255,0.7);
167
180
  }
168
181
 
@@ -325,41 +338,35 @@ export function Outline(
325
338
  }
326
339
  }
327
340
 
328
- // Add color swatches
341
+ // Add unified color swatch
329
342
  if (hasColorSwatches && colorClasses) {
330
- // Create bg swatch
331
- if (colorClasses.bg?.value) {
332
- const parsed = parseColorClass(colorClasses.bg.value)
333
- if (parsed) {
334
- const preview = getColorPreview(parsed.colorName, parsed.shade)
335
- const swatch = document.createElement('div')
336
- swatch.className = `color-swatch${parsed.colorName === 'white' ? ' white' : ''}`
337
- applySwatchStyle(swatch, parsed.colorName, preview)
338
- swatch.title = `Background: ${colorClasses.bg.value}`
339
- swatch.onclick = (e) => {
340
- e.stopPropagation()
341
- if (cmsId && onColorClick) onColorClick(cmsId, rect)
342
- }
343
- toolbarRef.current.appendChild(swatch)
344
- }
343
+ const bgParsed = colorClasses.bg?.value ? parseColorClass(colorClasses.bg.value) : null
344
+ const textParsed = colorClasses.text?.value ? parseColorClass(colorClasses.text.value) : null
345
+ const bgPreview = bgParsed ? getColorPreview(bgParsed.colorName, bgParsed.shade) : null
346
+ const textPreview = textParsed ? getColorPreview(textParsed.colorName, textParsed.shade) : null
347
+
348
+ const swatch = document.createElement('div')
349
+ const isWhite = (bgParsed && !textParsed && bgParsed.colorName === 'white')
350
+ || (!bgParsed && textParsed && textParsed.colorName === 'white')
351
+ swatch.className = `color-swatch${isWhite ? ' white' : ''}`
352
+
353
+ if (bgPreview && textPreview) {
354
+ // Split swatch: diagonal half bg, half text
355
+ swatch.style.background = `linear-gradient(135deg, ${bgPreview} 50%, ${textPreview} 50%)`
356
+ swatch.title = `Background: ${colorClasses.bg!.value} / Text: ${colorClasses.text!.value}`
357
+ } else if (bgParsed && bgPreview) {
358
+ applySwatchStyle(swatch, bgParsed.colorName, bgPreview)
359
+ swatch.title = `Background: ${colorClasses.bg!.value}`
360
+ } else if (textParsed && textPreview) {
361
+ applySwatchStyle(swatch, textParsed.colorName, textPreview)
362
+ swatch.title = `Text: ${colorClasses.text!.value}`
345
363
  }
346
364
 
347
- // Create text swatch
348
- if (colorClasses.text?.value) {
349
- const parsed = parseColorClass(colorClasses.text.value)
350
- if (parsed) {
351
- const preview = getColorPreview(parsed.colorName, parsed.shade)
352
- const swatch = document.createElement('div')
353
- swatch.className = `color-swatch${parsed.colorName === 'white' ? ' white' : ''}`
354
- applySwatchStyle(swatch, parsed.colorName, preview)
355
- swatch.title = `Text: ${colorClasses.text.value}`
356
- swatch.onclick = (e) => {
357
- e.stopPropagation()
358
- if (cmsId && onColorClick) onColorClick(cmsId, rect)
359
- }
360
- toolbarRef.current.appendChild(swatch)
361
- }
365
+ swatch.onclick = (e) => {
366
+ e.stopPropagation()
367
+ if (cmsId && onColorClick) onColorClick(cmsId, rect)
362
368
  }
369
+ toolbarRef.current.appendChild(swatch)
363
370
  }
364
371
 
365
372
  // Add divider and attribute button if needed
@@ -18,6 +18,7 @@ export interface ToolbarCallbacks {
18
18
  onToggleHighlights?: () => void
19
19
  onSeoEditor?: () => void
20
20
  onOpenCollection?: (name: string) => void
21
+ onOpenCollections?: () => void
21
22
  }
22
23
 
23
24
  export interface ToolbarProps {
@@ -143,11 +144,13 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
143
144
  })
144
145
  }
145
146
 
146
- // Collection items from definitions
147
+ // Single consolidated collections item
147
148
  if (collectionDefinitions) {
148
- for (const def of Object.values(collectionDefinitions)) {
149
+ const labels = Object.values(collectionDefinitions).map((d) => d.label)
150
+ const collectionsLabel = labels.length <= 2 ? labels.join(', ') : `${labels.slice(0, 2).join(', ')}, ...`
151
+ if (labels.length > 0) {
149
152
  menuItems.push({
150
- label: def.label,
153
+ label: collectionsLabel,
151
154
  icon: (
152
155
  <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
153
156
  <rect x="3" y="3" width="7" height="7" rx="1" />
@@ -156,7 +159,7 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
156
159
  <rect x="14" y="14" width="7" height="7" rx="1" />
157
160
  </svg>
158
161
  ),
159
- onClick: () => callbacks.onOpenCollection?.(def.name),
162
+ onClick: () => callbacks.onOpenCollections?.(),
160
163
  })
161
164
  }
162
165
  }
@@ -50,16 +50,16 @@ export function useBlockEditorHandlers({
50
50
  config,
51
51
  showToast,
52
52
  }: BlockEditorHandlersOptions) {
53
- const [blockEditorRect, setBlockEditorRect] = useState<DOMRect | null>(null)
53
+ const [blockEditorCursor, setBlockEditorCursor] = useState<{ x: number; y: number } | null>(null)
54
54
 
55
55
  /**
56
56
  * Open block editor for a component
57
57
  */
58
58
  const handleComponentSelect = useCallback(
59
- (componentId: string, rect: DOMRect) => {
59
+ (componentId: string, cursor: { x: number; y: number }) => {
60
60
  signals.setCurrentComponentId(componentId)
61
61
  signals.setBlockEditorOpen(true)
62
- setBlockEditorRect(rect)
62
+ setBlockEditorCursor(cursor)
63
63
  },
64
64
  [],
65
65
  )
@@ -70,7 +70,7 @@ export function useBlockEditorHandlers({
70
70
  const handleBlockEditorClose = useCallback(() => {
71
71
  signals.setBlockEditorOpen(false)
72
72
  signals.setCurrentComponentId(null)
73
- setBlockEditorRect(null)
73
+ setBlockEditorCursor(null)
74
74
  }, [])
75
75
 
76
76
  /**
@@ -253,7 +253,7 @@ export function useBlockEditorHandlers({
253
253
  )
254
254
 
255
255
  return {
256
- blockEditorRect,
256
+ blockEditorCursor,
257
257
  handleComponentSelect,
258
258
  handleBlockEditorClose,
259
259
  handleUpdateProps,
@@ -209,7 +209,7 @@ export function useElementDetection(): OutlineState {
209
209
  }
210
210
 
211
211
  export interface ComponentClickHandlerOptions {
212
- onComponentSelect: (componentId: string, rect: DOMRect) => void
212
+ onComponentSelect: (componentId: string, cursor: { x: number; y: number }) => void
213
213
  }
214
214
 
215
215
  /**
@@ -269,7 +269,7 @@ export function useComponentClickHandler({
269
269
  if (componentId) {
270
270
  ev.preventDefault()
271
271
  ev.stopPropagation()
272
- onComponentSelect(componentId, rect)
272
+ onComponentSelect(componentId, { x: ev.clientX, y: ev.clientY })
273
273
  }
274
274
  }
275
275
  }
@@ -109,7 +109,7 @@ const CmsUI = () => {
109
109
  })
110
110
 
111
111
  const {
112
- blockEditorRect,
112
+ blockEditorCursor,
113
113
  handleComponentSelect,
114
114
  handleBlockEditorClose,
115
115
  handleUpdateProps,
@@ -287,6 +287,7 @@ const CmsUI = () => {
287
287
  onToggleHighlights: handleToggleHighlights,
288
288
  onSeoEditor: hasSeoData ? handleSeoEditor : undefined,
289
289
  onOpenCollection: handleOpenCollection,
290
+ onOpenCollections: openCollectionsBrowser,
290
291
  }}
291
292
  collectionDefinitions={Object.keys(collectionDefinitions).length > 0 ? collectionDefinitions : undefined}
292
293
  />
@@ -346,7 +347,7 @@ const CmsUI = () => {
346
347
  <BlockEditor
347
348
  visible={blockEditorState.isOpen && isEditing}
348
349
  componentId={blockEditorState.currentComponentId}
349
- rect={blockEditorRect}
350
+ cursor={blockEditorCursor}
350
351
  onClose={handleBlockEditorClose}
351
352
  onUpdateProps={handleUpdateProps}
352
353
  onInsertComponent={handleInsertComponent}
@@ -131,7 +131,7 @@ function extractElementBounds(
131
131
  ): ArrayElementBounds[] {
132
132
  const bounds: ArrayElementBounds[] = []
133
133
  for (const el of elements) {
134
- if (el && el.loc) {
134
+ if (el?.loc) {
135
135
  bounds.push({
136
136
  // Babel loc is 1-indexed; convert to 0-indexed file lines
137
137
  startLine: el.loc.start.line - 1 + frontmatterStartLine,
@@ -273,7 +273,7 @@ export async function handleRemoveArrayItem(
273
273
  const freshLines = freshContent.split('\n')
274
274
 
275
275
  const bounds = elementBounds[arrayIndex]!
276
- let removeStart = bounds.startLine
276
+ const removeStart = bounds.startLine
277
277
  let removeEnd = bounds.endLine
278
278
 
279
279
  // Clean up trailing comma on the line after the element, or leading comma
@@ -298,7 +298,7 @@ export async function handleRemoveArrayItem(
298
298
  if (removeStart > 0 && removeStart <= freshLines.length) {
299
299
  const prevLine = freshLines[removeStart - 1]!
300
300
  const nextLine = freshLines[removeStart]
301
- if (nextLine !== undefined && nextLine.trim().startsWith(']') && prevLine.trimEnd().endsWith(',')) {
301
+ if (nextLine?.trim().startsWith(']') && prevLine.trimEnd().endsWith(',')) {
302
302
  freshLines[removeStart - 1] = prevLine.replace(/,\s*$/, '')
303
303
  }
304
304
  }
@@ -397,7 +397,7 @@ export async function handleAddArrayItem(
397
397
  for (let i = freshLines.length - 1; i >= 0; i--) {
398
398
  if (freshLines[i]!.trim().startsWith(']')) {
399
399
  const prev = freshLines[i - 1]
400
- if (prev && prev.trimEnd().endsWith(',')) {
400
+ if (prev?.trimEnd().endsWith(',')) {
401
401
  // Check if this is the array we're editing by scanning backwards
402
402
  // to find the array variable
403
403
  freshLines[i - 1] = prev.replace(/,(\s*)$/, '$1')