@nuasite/cms 0.1.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 (269) hide show
  1. package/README.md +237 -0
  2. package/dist/src/build-processor.d.ts +20 -0
  3. package/dist/src/build-processor.d.ts.map +1 -0
  4. package/dist/src/collection-scanner.d.ts +6 -0
  5. package/dist/src/collection-scanner.d.ts.map +1 -0
  6. package/dist/src/component-registry.d.ts +63 -0
  7. package/dist/src/component-registry.d.ts.map +1 -0
  8. package/dist/src/config.d.ts +24 -0
  9. package/dist/src/config.d.ts.map +1 -0
  10. package/dist/src/dev-middleware.d.ts +20 -0
  11. package/dist/src/dev-middleware.d.ts.map +1 -0
  12. package/dist/src/editor/ai.d.ts +60 -0
  13. package/dist/src/editor/ai.d.ts.map +1 -0
  14. package/dist/src/editor/api.d.ts +140 -0
  15. package/dist/src/editor/api.d.ts.map +1 -0
  16. package/dist/src/editor/color-utils.d.ts +106 -0
  17. package/dist/src/editor/color-utils.d.ts.map +1 -0
  18. package/dist/src/editor/components/ai-chat.d.ts +11 -0
  19. package/dist/src/editor/components/ai-chat.d.ts.map +1 -0
  20. package/dist/src/editor/components/ai-tooltip.d.ts +12 -0
  21. package/dist/src/editor/components/ai-tooltip.d.ts.map +1 -0
  22. package/dist/src/editor/components/attribute-editor.d.ts +5 -0
  23. package/dist/src/editor/components/attribute-editor.d.ts.map +1 -0
  24. package/dist/src/editor/components/block-editor.d.ts +12 -0
  25. package/dist/src/editor/components/block-editor.d.ts.map +1 -0
  26. package/dist/src/editor/components/collections-browser.d.ts +2 -0
  27. package/dist/src/editor/components/collections-browser.d.ts.map +1 -0
  28. package/dist/src/editor/components/color-toolbar.d.ts +12 -0
  29. package/dist/src/editor/components/color-toolbar.d.ts.map +1 -0
  30. package/dist/src/editor/components/confirm-dialog.d.ts +2 -0
  31. package/dist/src/editor/components/confirm-dialog.d.ts.map +1 -0
  32. package/dist/src/editor/components/create-page-modal.d.ts +2 -0
  33. package/dist/src/editor/components/create-page-modal.d.ts.map +1 -0
  34. package/dist/src/editor/components/editable-highlights.d.ts +9 -0
  35. package/dist/src/editor/components/editable-highlights.d.ts.map +1 -0
  36. package/dist/src/editor/components/error-boundary.d.ts +32 -0
  37. package/dist/src/editor/components/error-boundary.d.ts.map +1 -0
  38. package/dist/src/editor/components/fields.d.ts +75 -0
  39. package/dist/src/editor/components/fields.d.ts.map +1 -0
  40. package/dist/src/editor/components/frontmatter-fields.d.ts +29 -0
  41. package/dist/src/editor/components/frontmatter-fields.d.ts.map +1 -0
  42. package/dist/src/editor/components/highlight-overlay.d.ts +64 -0
  43. package/dist/src/editor/components/highlight-overlay.d.ts.map +1 -0
  44. package/dist/src/editor/components/image-overlay.d.ts +12 -0
  45. package/dist/src/editor/components/image-overlay.d.ts.map +1 -0
  46. package/dist/src/editor/components/markdown-editor-overlay.d.ts +6 -0
  47. package/dist/src/editor/components/markdown-editor-overlay.d.ts.map +1 -0
  48. package/dist/src/editor/components/markdown-inline-editor.d.ts +10 -0
  49. package/dist/src/editor/components/markdown-inline-editor.d.ts.map +1 -0
  50. package/dist/src/editor/components/media-library.d.ts +2 -0
  51. package/dist/src/editor/components/media-library.d.ts.map +1 -0
  52. package/dist/src/editor/components/outline.d.ts +21 -0
  53. package/dist/src/editor/components/outline.d.ts.map +1 -0
  54. package/dist/src/editor/components/redirect-countdown.d.ts +2 -0
  55. package/dist/src/editor/components/redirect-countdown.d.ts.map +1 -0
  56. package/dist/src/editor/components/seo-editor.d.ts +2 -0
  57. package/dist/src/editor/components/seo-editor.d.ts.map +1 -0
  58. package/dist/src/editor/components/text-style-toolbar.d.ts +8 -0
  59. package/dist/src/editor/components/text-style-toolbar.d.ts.map +1 -0
  60. package/dist/src/editor/components/toast/toast-container.d.ts +7 -0
  61. package/dist/src/editor/components/toast/toast-container.d.ts.map +1 -0
  62. package/dist/src/editor/components/toast/toast.d.ts +7 -0
  63. package/dist/src/editor/components/toast/toast.d.ts.map +1 -0
  64. package/dist/src/editor/components/toast/types.d.ts +7 -0
  65. package/dist/src/editor/components/toast/types.d.ts.map +1 -0
  66. package/dist/src/editor/components/toolbar.d.ts +21 -0
  67. package/dist/src/editor/components/toolbar.d.ts.map +1 -0
  68. package/dist/src/editor/config.d.ts +4 -0
  69. package/dist/src/editor/config.d.ts.map +1 -0
  70. package/dist/src/editor/constants.d.ts +101 -0
  71. package/dist/src/editor/constants.d.ts.map +1 -0
  72. package/dist/src/editor/context.d.ts +14 -0
  73. package/dist/src/editor/context.d.ts.map +1 -0
  74. package/dist/src/editor/dom.d.ts +77 -0
  75. package/dist/src/editor/dom.d.ts.map +1 -0
  76. package/dist/src/editor/editor.d.ts +64 -0
  77. package/dist/src/editor/editor.d.ts.map +1 -0
  78. package/dist/src/editor/history.d.ts +20 -0
  79. package/dist/src/editor/history.d.ts.map +1 -0
  80. package/dist/src/editor/hooks/index.d.ts +14 -0
  81. package/dist/src/editor/hooks/index.d.ts.map +1 -0
  82. package/dist/src/editor/hooks/useAIHandlers.d.ts +22 -0
  83. package/dist/src/editor/hooks/useAIHandlers.d.ts.map +1 -0
  84. package/dist/src/editor/hooks/useBlockEditorHandlers.d.ts +18 -0
  85. package/dist/src/editor/hooks/useBlockEditorHandlers.d.ts.map +1 -0
  86. package/dist/src/editor/hooks/useElementDetection.d.ts +26 -0
  87. package/dist/src/editor/hooks/useElementDetection.d.ts.map +1 -0
  88. package/dist/src/editor/hooks/useImageHoverDetection.d.ts +12 -0
  89. package/dist/src/editor/hooks/useImageHoverDetection.d.ts.map +1 -0
  90. package/dist/src/editor/hooks/useTextSelection.d.ts +23 -0
  91. package/dist/src/editor/hooks/useTextSelection.d.ts.map +1 -0
  92. package/dist/src/editor/hooks/useTooltipState.d.ts +19 -0
  93. package/dist/src/editor/hooks/useTooltipState.d.ts.map +1 -0
  94. package/dist/src/editor/hooks/utils.d.ts +32 -0
  95. package/dist/src/editor/hooks/utils.d.ts.map +1 -0
  96. package/dist/src/editor/index.d.ts +12 -0
  97. package/dist/src/editor/index.d.ts.map +1 -0
  98. package/dist/src/editor/lib/cn.d.ts +3 -0
  99. package/dist/src/editor/lib/cn.d.ts.map +1 -0
  100. package/dist/src/editor/manifest.d.ts +19 -0
  101. package/dist/src/editor/manifest.d.ts.map +1 -0
  102. package/dist/src/editor/markdown-api.d.ts +36 -0
  103. package/dist/src/editor/markdown-api.d.ts.map +1 -0
  104. package/dist/src/editor/signals.d.ts +242 -0
  105. package/dist/src/editor/signals.d.ts.map +1 -0
  106. package/dist/src/editor/storage.d.ts +27 -0
  107. package/dist/src/editor/storage.d.ts.map +1 -0
  108. package/dist/src/editor/text-styling.d.ts +350 -0
  109. package/dist/src/editor/text-styling.d.ts.map +1 -0
  110. package/dist/src/editor/themes.d.ts +38 -0
  111. package/dist/src/editor/themes.d.ts.map +1 -0
  112. package/dist/src/editor/types.d.ts +454 -0
  113. package/dist/src/editor/types.d.ts.map +1 -0
  114. package/dist/src/error-collector.d.ts +56 -0
  115. package/dist/src/error-collector.d.ts.map +1 -0
  116. package/dist/src/handlers/component-ops.d.ts +34 -0
  117. package/dist/src/handlers/component-ops.d.ts.map +1 -0
  118. package/dist/src/handlers/markdown-ops.d.ts +41 -0
  119. package/dist/src/handlers/markdown-ops.d.ts.map +1 -0
  120. package/dist/src/handlers/request-utils.d.ts +20 -0
  121. package/dist/src/handlers/request-utils.d.ts.map +1 -0
  122. package/dist/src/handlers/source-writer.d.ts +51 -0
  123. package/dist/src/handlers/source-writer.d.ts.map +1 -0
  124. package/dist/src/html-processor.d.ts +63 -0
  125. package/dist/src/html-processor.d.ts.map +1 -0
  126. package/dist/src/index.d.ts +41 -0
  127. package/dist/src/index.d.ts.map +1 -0
  128. package/dist/src/manifest-writer.d.ts +111 -0
  129. package/dist/src/manifest-writer.d.ts.map +1 -0
  130. package/dist/src/media/contember.d.ts +15 -0
  131. package/dist/src/media/contember.d.ts.map +1 -0
  132. package/dist/src/media/local.d.ts +9 -0
  133. package/dist/src/media/local.d.ts.map +1 -0
  134. package/dist/src/media/s3.d.ts +12 -0
  135. package/dist/src/media/s3.d.ts.map +1 -0
  136. package/dist/src/media/types.d.ts +40 -0
  137. package/dist/src/media/types.d.ts.map +1 -0
  138. package/dist/src/preview-generator.d.ts +19 -0
  139. package/dist/src/preview-generator.d.ts.map +1 -0
  140. package/dist/src/seo-processor.d.ts +23 -0
  141. package/dist/src/seo-processor.d.ts.map +1 -0
  142. package/dist/src/source-finder/ast-extractors.d.ts +35 -0
  143. package/dist/src/source-finder/ast-extractors.d.ts.map +1 -0
  144. package/dist/src/source-finder/ast-parser.d.ts +16 -0
  145. package/dist/src/source-finder/ast-parser.d.ts.map +1 -0
  146. package/dist/src/source-finder/cache.d.ts +18 -0
  147. package/dist/src/source-finder/cache.d.ts.map +1 -0
  148. package/dist/src/source-finder/collection-finder.d.ts +29 -0
  149. package/dist/src/source-finder/collection-finder.d.ts.map +1 -0
  150. package/dist/src/source-finder/cross-file-tracker.d.ts +39 -0
  151. package/dist/src/source-finder/cross-file-tracker.d.ts.map +1 -0
  152. package/dist/src/source-finder/element-finder.d.ts +42 -0
  153. package/dist/src/source-finder/element-finder.d.ts.map +1 -0
  154. package/dist/src/source-finder/image-finder.d.ts +24 -0
  155. package/dist/src/source-finder/image-finder.d.ts.map +1 -0
  156. package/dist/src/source-finder/index.d.ts +9 -0
  157. package/dist/src/source-finder/index.d.ts.map +1 -0
  158. package/dist/src/source-finder/search-index.d.ts +27 -0
  159. package/dist/src/source-finder/search-index.d.ts.map +1 -0
  160. package/dist/src/source-finder/snippet-utils.d.ts +90 -0
  161. package/dist/src/source-finder/snippet-utils.d.ts.map +1 -0
  162. package/dist/src/source-finder/source-lookup.d.ts +16 -0
  163. package/dist/src/source-finder/source-lookup.d.ts.map +1 -0
  164. package/dist/src/source-finder/types.d.ts +167 -0
  165. package/dist/src/source-finder/types.d.ts.map +1 -0
  166. package/dist/src/source-finder/variable-extraction.d.ts +37 -0
  167. package/dist/src/source-finder/variable-extraction.d.ts.map +1 -0
  168. package/dist/src/tailwind-colors.d.ts +54 -0
  169. package/dist/src/tailwind-colors.d.ts.map +1 -0
  170. package/dist/src/tsconfig.tsbuildinfo +1 -0
  171. package/dist/src/types.d.ts +367 -0
  172. package/dist/src/types.d.ts.map +1 -0
  173. package/dist/src/utils.d.ts +61 -0
  174. package/dist/src/utils.d.ts.map +1 -0
  175. package/dist/src/vite-plugin.d.ts +14 -0
  176. package/dist/src/vite-plugin.d.ts.map +1 -0
  177. package/dist/types/tsconfig.tsbuildinfo +1 -0
  178. package/package.json +80 -0
  179. package/src/build-processor.ts +784 -0
  180. package/src/collection-scanner.ts +304 -0
  181. package/src/component-registry.ts +393 -0
  182. package/src/config.ts +74 -0
  183. package/src/dev-middleware.ts +525 -0
  184. package/src/dist/src/tsconfig.tsbuildinfo +1 -0
  185. package/src/editor/ai.ts +185 -0
  186. package/src/editor/api.ts +513 -0
  187. package/src/editor/color-utils.ts +556 -0
  188. package/src/editor/components/ai-chat.tsx +632 -0
  189. package/src/editor/components/ai-tooltip.tsx +179 -0
  190. package/src/editor/components/attribute-editor.tsx +596 -0
  191. package/src/editor/components/block-editor.tsx +546 -0
  192. package/src/editor/components/collections-browser.tsx +248 -0
  193. package/src/editor/components/color-toolbar.tsx +314 -0
  194. package/src/editor/components/confirm-dialog.tsx +69 -0
  195. package/src/editor/components/create-page-modal.tsx +163 -0
  196. package/src/editor/components/editable-highlights.tsx +260 -0
  197. package/src/editor/components/error-boundary.tsx +87 -0
  198. package/src/editor/components/fields.tsx +387 -0
  199. package/src/editor/components/frontmatter-fields.tsx +469 -0
  200. package/src/editor/components/highlight-overlay.ts +229 -0
  201. package/src/editor/components/image-overlay.tsx +230 -0
  202. package/src/editor/components/markdown-editor-overlay.tsx +505 -0
  203. package/src/editor/components/markdown-inline-editor.tsx +780 -0
  204. package/src/editor/components/media-library.tsx +297 -0
  205. package/src/editor/components/outline.tsx +402 -0
  206. package/src/editor/components/redirect-countdown.tsx +45 -0
  207. package/src/editor/components/seo-editor.tsx +498 -0
  208. package/src/editor/components/text-style-toolbar.tsx +362 -0
  209. package/src/editor/components/toast/toast-container.tsx +15 -0
  210. package/src/editor/components/toast/toast.tsx +49 -0
  211. package/src/editor/components/toast/types.ts +7 -0
  212. package/src/editor/components/toolbar.tsx +366 -0
  213. package/src/editor/config.ts +12 -0
  214. package/src/editor/constants.ts +106 -0
  215. package/src/editor/context.tsx +38 -0
  216. package/src/editor/dom.ts +357 -0
  217. package/src/editor/editor.ts +1510 -0
  218. package/src/editor/env.d.ts +4 -0
  219. package/src/editor/history.ts +355 -0
  220. package/src/editor/hooks/index.ts +19 -0
  221. package/src/editor/hooks/useAIHandlers.ts +345 -0
  222. package/src/editor/hooks/useBlockEditorHandlers.ts +206 -0
  223. package/src/editor/hooks/useElementDetection.ts +284 -0
  224. package/src/editor/hooks/useImageHoverDetection.ts +102 -0
  225. package/src/editor/hooks/useTextSelection.ts +187 -0
  226. package/src/editor/hooks/useTooltipState.ts +126 -0
  227. package/src/editor/hooks/utils.ts +101 -0
  228. package/src/editor/index.tsx +481 -0
  229. package/src/editor/lib/cn.ts +4 -0
  230. package/src/editor/manifest.ts +25 -0
  231. package/src/editor/markdown-api.ts +209 -0
  232. package/src/editor/signals.ts +1351 -0
  233. package/src/editor/storage.ts +266 -0
  234. package/src/editor/styles.css +465 -0
  235. package/src/editor/text-styling.ts +773 -0
  236. package/src/editor/themes.ts +210 -0
  237. package/src/editor/types.ts +591 -0
  238. package/src/error-collector.ts +106 -0
  239. package/src/handlers/component-ops.ts +463 -0
  240. package/src/handlers/markdown-ops.ts +202 -0
  241. package/src/handlers/request-utils.ts +151 -0
  242. package/src/handlers/source-writer.ts +649 -0
  243. package/src/html-processor.ts +1108 -0
  244. package/src/index.ts +284 -0
  245. package/src/manifest-writer.ts +371 -0
  246. package/src/media/contember.ts +84 -0
  247. package/src/media/local.ts +114 -0
  248. package/src/media/s3.ts +133 -0
  249. package/src/media/types.ts +33 -0
  250. package/src/preview-generator.ts +293 -0
  251. package/src/seo-processor.ts +567 -0
  252. package/src/source-finder/ast-extractors.ts +185 -0
  253. package/src/source-finder/ast-parser.ts +150 -0
  254. package/src/source-finder/cache.ts +76 -0
  255. package/src/source-finder/collection-finder.ts +335 -0
  256. package/src/source-finder/cross-file-tracker.ts +741 -0
  257. package/src/source-finder/element-finder.ts +387 -0
  258. package/src/source-finder/image-finder.ts +283 -0
  259. package/src/source-finder/index.ts +37 -0
  260. package/src/source-finder/search-index.ts +525 -0
  261. package/src/source-finder/snippet-utils.ts +668 -0
  262. package/src/source-finder/source-lookup.ts +200 -0
  263. package/src/source-finder/types.ts +210 -0
  264. package/src/source-finder/variable-extraction.ts +406 -0
  265. package/src/tailwind-colors.ts +874 -0
  266. package/src/tsconfig.json +25 -0
  267. package/src/types.ts +406 -0
  268. package/src/utils.ts +186 -0
  269. package/src/vite-plugin.ts +42 -0
@@ -0,0 +1,45 @@
1
+ import { redirectCountdown, stopRedirectCountdown } from '../signals'
2
+
3
+ export function RedirectCountdown() {
4
+ const state = redirectCountdown.value
5
+ if (!state) return null
6
+
7
+ const stopPropagation = (e: Event) => e.stopPropagation()
8
+
9
+ return (
10
+ <div
11
+ class="fixed bottom-6 left-1/2 -translate-x-1/2 z-2147483647 flex items-center gap-3 px-5 py-3 bg-cms-dark/95 border border-white/15 rounded-cms-pill shadow-[0_8px_32px_rgba(0,0,0,0.4)] backdrop-blur-md"
12
+ data-cms-ui
13
+ onMouseDown={stopPropagation}
14
+ onClick={stopPropagation}
15
+ >
16
+ <div class="flex items-center gap-2 text-white/90 text-sm font-medium">
17
+ <svg
18
+ class="w-4 h-4 text-cms-primary"
19
+ fill="none"
20
+ stroke="currentColor"
21
+ viewBox="0 0 24 24"
22
+ stroke-width="2"
23
+ >
24
+ <path
25
+ stroke-linecap="round"
26
+ stroke-linejoin="round"
27
+ d="M13 7l5 5m0 0l-5 5m5-5H6"
28
+ />
29
+ </svg>
30
+ <span>
31
+ Redirecting to <strong class="text-white">{state.label}</strong> in {state.secondsLeft}s
32
+ </span>
33
+ </div>
34
+ <div class="w-px h-5 bg-white/20" />
35
+ <button
36
+ type="button"
37
+ onClick={stopRedirectCountdown}
38
+ class="px-3 py-1.5 text-sm text-white/80 hover:text-white hover:bg-white/10 rounded-cms-pill transition-colors"
39
+ data-cms-ui
40
+ >
41
+ Cancel
42
+ </button>
43
+ </div>
44
+ )
45
+ }
@@ -0,0 +1,498 @@
1
+ import { useCallback, useState } from 'preact/hooks'
2
+ import { saveBatchChanges } from '../api'
3
+ import { isApplyingUndoRedo, recordChange } from '../history'
4
+ import {
5
+ clearPendingSeoChanges,
6
+ closeSeoEditor,
7
+ config,
8
+ dirtySeoChangesCount,
9
+ getPendingSeoChange,
10
+ isSeoEditorOpen,
11
+ manifest,
12
+ openMediaLibraryWithCallback,
13
+ pendingSeoChanges,
14
+ setPendingSeoChange,
15
+ showToast,
16
+ } from '../signals'
17
+ import type { ChangePayload, PageSeoData, PendingSeoChange } from '../types'
18
+ import { ImageField } from './fields'
19
+
20
+ interface SeoFieldProps {
21
+ label: string
22
+ id: string | undefined
23
+ value: string | undefined
24
+ placeholder?: string
25
+ multiline?: boolean
26
+ onChange: (id: string, value: string, originalValue: string) => void
27
+ }
28
+
29
+ function SeoField({ label, id, value, placeholder, multiline, onChange }: SeoFieldProps) {
30
+ const pendingChange = id ? getPendingSeoChange(id) : undefined
31
+ const currentValue = pendingChange?.newValue ?? value ?? ''
32
+ const isDirty = pendingChange?.isDirty ?? false
33
+
34
+ const handleChange = useCallback((e: Event) => {
35
+ const target = e.target as HTMLInputElement | HTMLTextAreaElement
36
+ if (id) {
37
+ onChange(id, target.value, value ?? '')
38
+ }
39
+ }, [id, value, onChange])
40
+
41
+ const InputComponent = multiline ? 'textarea' : 'input'
42
+
43
+ return (
44
+ <div class="space-y-1.5">
45
+ <div class="flex items-center justify-between">
46
+ <label class="text-sm font-medium text-white/80">{label}</label>
47
+ {isDirty && <span class="text-xs text-cms-primary font-medium">Modified</span>}
48
+ </div>
49
+ <InputComponent
50
+ type={multiline ? undefined : 'text'}
51
+ value={currentValue}
52
+ placeholder={placeholder ?? `Enter ${label.toLowerCase()}...`}
53
+ onInput={handleChange}
54
+ disabled={!id}
55
+ class={`w-full px-4 py-2.5 bg-white/10 border rounded-cms-md text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors ${
56
+ isDirty
57
+ ? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
58
+ : 'border-white/20 focus:border-white/40 focus:ring-white/10'
59
+ } ${!id ? 'opacity-50 cursor-not-allowed' : ''} ${multiline ? 'min-h-20 resize-y' : ''}`}
60
+ data-cms-ui
61
+ />
62
+ </div>
63
+ )
64
+ }
65
+
66
+ interface SeoSectionProps {
67
+ title: string
68
+ children: preact.ComponentChildren
69
+ }
70
+
71
+ function SeoSection({ title, children }: SeoSectionProps) {
72
+ return (
73
+ <div class="space-y-4">
74
+ <h3 class="text-sm font-semibold text-white/60 uppercase tracking-wider">{title}</h3>
75
+ <div class="space-y-4">
76
+ {children}
77
+ </div>
78
+ </div>
79
+ )
80
+ }
81
+
82
+ export function SeoEditor() {
83
+ const visible = isSeoEditorOpen.value
84
+ const seoData = (manifest.value as any).seo as PageSeoData | undefined
85
+
86
+ const handleClose = useCallback(() => {
87
+ closeSeoEditor()
88
+ }, [])
89
+
90
+ const handleFieldChange = useCallback((
91
+ id: string,
92
+ newValue: string,
93
+ originalValue: string,
94
+ ) => {
95
+ // Record undo action before updating signal
96
+ if (!isApplyingUndoRedo) {
97
+ const existing = getPendingSeoChange(id)
98
+ recordChange({
99
+ type: 'seo',
100
+ cmsId: id,
101
+ previousValue: existing?.newValue ?? originalValue,
102
+ currentValue: newValue,
103
+ originalValue,
104
+ wasDirty: existing?.isDirty ?? false,
105
+ })
106
+ }
107
+
108
+ const isDirty = newValue !== originalValue
109
+ const change: PendingSeoChange = {
110
+ id,
111
+ originalValue,
112
+ newValue,
113
+ isDirty,
114
+ }
115
+ setPendingSeoChange(id, change)
116
+ }, [])
117
+
118
+ // Count dirty changes for this editor
119
+ const dirtyCount = dirtySeoChangesCount.value
120
+
121
+ const [isSaving, setIsSaving] = useState(false)
122
+
123
+ // Helper to find SEO element by id and get its source info
124
+ const findSeoElementById = useCallback((id: string): { sourcePath: string; sourceLine: number; sourceSnippet: string; content: string } | null => {
125
+ if (!seoData) return null
126
+
127
+ // Search through all SEO fields
128
+ const fields = [
129
+ seoData.title,
130
+ seoData.description,
131
+ seoData.keywords,
132
+ seoData.canonical,
133
+ ...(seoData.openGraph ? Object.values(seoData.openGraph) : []),
134
+ ...(seoData.twitterCard ? Object.values(seoData.twitterCard) : []),
135
+ ...(seoData.favicons || []),
136
+ ]
137
+
138
+ for (const field of fields) {
139
+ if (field && (field as any).id === id) {
140
+ return {
141
+ sourcePath: field.sourcePath ?? '',
142
+ sourceLine: field.sourceLine ?? 0,
143
+ sourceSnippet: field.sourceSnippet ?? '',
144
+ content: (field as any).content ?? (field as any).href ?? '',
145
+ }
146
+ }
147
+ }
148
+ return null
149
+ }, [seoData])
150
+
151
+ const handleSaveAll = useCallback(async () => {
152
+ const dirtyChanges = Array.from(pendingSeoChanges.value.values()).filter(c => c.isDirty)
153
+ if (dirtyChanges.length === 0) return
154
+
155
+ setIsSaving(true)
156
+ try {
157
+ const changes: ChangePayload[] = dirtyChanges.map(change => {
158
+ const sourceInfo = findSeoElementById(change.id)
159
+ return {
160
+ cmsId: change.id,
161
+ newValue: change.newValue,
162
+ originalValue: sourceInfo?.content ?? change.originalValue,
163
+ sourcePath: sourceInfo?.sourcePath ?? '',
164
+ sourceLine: sourceInfo?.sourceLine ?? 0,
165
+ sourceSnippet: sourceInfo?.sourceSnippet ?? '',
166
+ }
167
+ })
168
+
169
+ const result = await saveBatchChanges(config.value.apiBase, {
170
+ changes,
171
+ meta: {
172
+ source: 'seo-editor',
173
+ url: window.location.href,
174
+ },
175
+ })
176
+
177
+ if (result.errors && result.errors.length > 0) {
178
+ showToast(`Saved ${result.updated} SEO changes, ${result.errors.length} failed`, 'error')
179
+ } else {
180
+ showToast(`Saved ${result.updated} SEO change(s) successfully!`, 'success')
181
+ clearPendingSeoChanges()
182
+ closeSeoEditor()
183
+ }
184
+ } catch (error) {
185
+ showToast(error instanceof Error ? error.message : 'Failed to save SEO changes', 'error')
186
+ } finally {
187
+ setIsSaving(false)
188
+ }
189
+ }, [findSeoElementById])
190
+
191
+ if (!visible) return null
192
+
193
+ const hasSeoData = seoData && (
194
+ seoData.title
195
+ || seoData.description
196
+ || seoData.keywords
197
+ || seoData.canonical
198
+ || seoData.openGraph
199
+ || seoData.twitterCard
200
+ || (seoData.favicons && seoData.favicons.length > 0)
201
+ )
202
+
203
+ return (
204
+ <div
205
+ class="fixed inset-0 z-2147483647 flex items-center justify-center bg-black/60 backdrop-blur-sm"
206
+ onClick={handleClose}
207
+ data-cms-ui
208
+ >
209
+ <div
210
+ class="bg-cms-dark rounded-cms-xl shadow-[0_8px_32px_rgba(0,0,0,0.4)] max-w-2xl w-full max-h-[85vh] flex flex-col border border-white/10"
211
+ onClick={(e) => e.stopPropagation()}
212
+ data-cms-ui
213
+ >
214
+ {/* Header */}
215
+ <div class="flex items-center justify-between p-5 border-b border-white/10">
216
+ <div class="flex items-center gap-3">
217
+ <h2 class="text-lg font-semibold text-white">SEO Settings</h2>
218
+ {dirtyCount > 0 && (
219
+ <span class="px-2 py-0.5 text-xs font-medium bg-cms-primary/20 text-cms-primary rounded-full">
220
+ {dirtyCount} change{dirtyCount !== 1 ? 's' : ''}
221
+ </span>
222
+ )}
223
+ </div>
224
+ <button
225
+ type="button"
226
+ onClick={handleClose}
227
+ class="text-white/50 hover:text-white p-1.5 hover:bg-white/10 rounded-full transition-colors"
228
+ data-cms-ui
229
+ >
230
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
231
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
232
+ </svg>
233
+ </button>
234
+ </div>
235
+
236
+ {/* Content */}
237
+ <div class="flex-1 overflow-auto p-5">
238
+ {!hasSeoData
239
+ ? (
240
+ <div class="flex flex-col items-center justify-center h-48 text-white/50">
241
+ <svg class="w-12 h-12 mb-3 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
242
+ <path
243
+ stroke-linecap="round"
244
+ stroke-linejoin="round"
245
+ stroke-width="1.5"
246
+ d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
247
+ />
248
+ </svg>
249
+ <p class="text-sm">No SEO data found for this page.</p>
250
+ <p class="text-xs text-white/40 mt-1">Add title, meta tags, or Open Graph tags to your page.</p>
251
+ </div>
252
+ )
253
+ : (
254
+ <div class="space-y-8">
255
+ {/* Basic SEO */}
256
+ <SeoSection title="Basic SEO">
257
+ {seoData.title && (
258
+ <SeoField
259
+ label="Page Title"
260
+ id={seoData.title.id}
261
+ value={seoData.title.content}
262
+ placeholder="Enter page title..."
263
+ onChange={handleFieldChange}
264
+ />
265
+ )}
266
+ {seoData.description && (
267
+ <SeoField
268
+ label="Meta Description"
269
+ id={seoData.description.id}
270
+ value={seoData.description.content}
271
+ placeholder="Enter meta description..."
272
+ multiline
273
+ onChange={handleFieldChange}
274
+ />
275
+ )}
276
+ {seoData.keywords && (
277
+ <SeoField
278
+ label="Keywords"
279
+ id={seoData.keywords.id}
280
+ value={seoData.keywords.content}
281
+ placeholder="keyword1, keyword2, keyword3..."
282
+ onChange={handleFieldChange}
283
+ />
284
+ )}
285
+ {seoData.canonical && (
286
+ <SeoField
287
+ label="Canonical URL"
288
+ id={seoData.canonical.id}
289
+ value={seoData.canonical.href}
290
+ placeholder="https://example.com/page"
291
+ onChange={handleFieldChange}
292
+ />
293
+ )}
294
+ </SeoSection>
295
+
296
+ {/* Favicons */}
297
+ {seoData.favicons && seoData.favicons.length > 0 && (
298
+ <SeoSection title="Favicons">
299
+ {seoData.favicons.map((favicon, index) => {
300
+ const faviconId = favicon.id
301
+ const originalValue = favicon.href
302
+ const pendingChange = faviconId ? getPendingSeoChange(faviconId) : undefined
303
+ const currentValue = pendingChange?.newValue ?? originalValue ?? ''
304
+ const isDirty = pendingChange?.isDirty ?? false
305
+
306
+ const label = favicon.sizes
307
+ ? `Favicon (${favicon.sizes})`
308
+ : favicon.type
309
+ ? `Favicon (${favicon.type.replace('image/', '')})`
310
+ : `Favicon${seoData.favicons!.length > 1 ? ` ${index + 1}` : ''}`
311
+
312
+ return (
313
+ <div key={faviconId || index} class="space-y-1.5">
314
+ <ImageField
315
+ label={label}
316
+ value={currentValue}
317
+ placeholder="/favicon.svg"
318
+ onChange={(v) => {
319
+ if (faviconId) handleFieldChange(faviconId, v, originalValue)
320
+ }}
321
+ onBrowse={() => {
322
+ openMediaLibraryWithCallback((url: string) => {
323
+ if (faviconId) handleFieldChange(faviconId, url, originalValue)
324
+ })
325
+ }}
326
+ isDirty={isDirty}
327
+ />
328
+ </div>
329
+ )
330
+ })}
331
+ </SeoSection>
332
+ )}
333
+
334
+ {/* Open Graph */}
335
+ {seoData.openGraph && Object.keys(seoData.openGraph).length > 0 && (
336
+ <SeoSection title="Open Graph">
337
+ {seoData.openGraph.title && (
338
+ <SeoField
339
+ label="OG Title"
340
+ id={seoData.openGraph.title.id}
341
+ value={seoData.openGraph.title.content}
342
+ placeholder="Enter Open Graph title..."
343
+ onChange={handleFieldChange}
344
+ />
345
+ )}
346
+ {seoData.openGraph.description && (
347
+ <SeoField
348
+ label="OG Description"
349
+ id={seoData.openGraph.description.id}
350
+ value={seoData.openGraph.description.content}
351
+ placeholder="Enter Open Graph description..."
352
+ multiline
353
+ onChange={handleFieldChange}
354
+ />
355
+ )}
356
+ {seoData.openGraph.image && (
357
+ <SeoField
358
+ label="OG Image"
359
+ id={seoData.openGraph.image.id}
360
+ value={seoData.openGraph.image.content}
361
+ placeholder="/images/og-image.jpg"
362
+ onChange={handleFieldChange}
363
+ />
364
+ )}
365
+ {seoData.openGraph.url && (
366
+ <SeoField
367
+ label="OG URL"
368
+ id={seoData.openGraph.url.id}
369
+ value={seoData.openGraph.url.content}
370
+ placeholder="https://example.com/page"
371
+ onChange={handleFieldChange}
372
+ />
373
+ )}
374
+ {seoData.openGraph.type && (
375
+ <SeoField
376
+ label="OG Type"
377
+ id={seoData.openGraph.type.id}
378
+ value={seoData.openGraph.type.content}
379
+ placeholder="website"
380
+ onChange={handleFieldChange}
381
+ />
382
+ )}
383
+ {seoData.openGraph.siteName && (
384
+ <SeoField
385
+ label="OG Site Name"
386
+ id={seoData.openGraph.siteName.id}
387
+ value={seoData.openGraph.siteName.content}
388
+ placeholder="My Website"
389
+ onChange={handleFieldChange}
390
+ />
391
+ )}
392
+ </SeoSection>
393
+ )}
394
+
395
+ {/* Twitter Card */}
396
+ {seoData.twitterCard && Object.keys(seoData.twitterCard).length > 0 && (
397
+ <SeoSection title="Twitter Card">
398
+ {seoData.twitterCard.card && (
399
+ <SeoField
400
+ label="Card Type"
401
+ id={seoData.twitterCard.card.id}
402
+ value={seoData.twitterCard.card.content}
403
+ placeholder="summary_large_image"
404
+ onChange={handleFieldChange}
405
+ />
406
+ )}
407
+ {seoData.twitterCard.title && (
408
+ <SeoField
409
+ label="Twitter Title"
410
+ id={seoData.twitterCard.title.id}
411
+ value={seoData.twitterCard.title.content}
412
+ placeholder="Enter Twitter title..."
413
+ onChange={handleFieldChange}
414
+ />
415
+ )}
416
+ {seoData.twitterCard.description && (
417
+ <SeoField
418
+ label="Twitter Description"
419
+ id={seoData.twitterCard.description.id}
420
+ value={seoData.twitterCard.description.content}
421
+ placeholder="Enter Twitter description..."
422
+ multiline
423
+ onChange={handleFieldChange}
424
+ />
425
+ )}
426
+ {seoData.twitterCard.image && (
427
+ <SeoField
428
+ label="Twitter Image"
429
+ id={seoData.twitterCard.image.id}
430
+ value={seoData.twitterCard.image.content}
431
+ placeholder="/images/twitter-image.jpg"
432
+ onChange={handleFieldChange}
433
+ />
434
+ )}
435
+ {seoData.twitterCard.site && (
436
+ <SeoField
437
+ label="Twitter Site"
438
+ id={seoData.twitterCard.site.id}
439
+ value={seoData.twitterCard.site.content}
440
+ placeholder="@username"
441
+ onChange={handleFieldChange}
442
+ />
443
+ )}
444
+ </SeoSection>
445
+ )}
446
+
447
+ {/* JSON-LD Preview */}
448
+ {seoData.jsonLd && seoData.jsonLd.length > 0 && (
449
+ <SeoSection title="Structured Data (JSON-LD)">
450
+ <div class="space-y-3">
451
+ {seoData.jsonLd.map((entry, index) => (
452
+ <div key={index} class="p-3 bg-white/5 rounded-cms-md border border-white/10">
453
+ <div class="flex items-center justify-between mb-2">
454
+ <span class="text-sm font-medium text-white/80">@type: {entry.type}</span>
455
+ </div>
456
+ <pre class="text-xs text-white/60 overflow-auto max-h-32 p-2 bg-black/30 rounded">
457
+ {JSON.stringify(entry.data, null, 2)}
458
+ </pre>
459
+ </div>
460
+ ))}
461
+ </div>
462
+ </SeoSection>
463
+ )}
464
+ </div>
465
+ )}
466
+ </div>
467
+
468
+ {/* Footer */}
469
+ {hasSeoData && (
470
+ <div class="flex items-center justify-end gap-3 px-5 py-4 border-t border-white/10">
471
+ <button
472
+ type="button"
473
+ onClick={handleClose}
474
+ class="px-4 py-2 text-sm font-medium text-white/70 hover:text-white hover:bg-white/10 rounded-cms-pill transition-colors"
475
+ data-cms-ui
476
+ >
477
+ Cancel
478
+ </button>
479
+ <button
480
+ type="button"
481
+ onClick={handleSaveAll}
482
+ disabled={dirtyCount === 0 || isSaving}
483
+ class={`px-5 py-2 text-sm font-medium rounded-cms-pill transition-colors flex items-center gap-2 ${
484
+ dirtyCount > 0 && !isSaving
485
+ ? 'bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover'
486
+ : 'bg-white/10 text-white/40 cursor-not-allowed'
487
+ }`}
488
+ data-cms-ui
489
+ >
490
+ {isSaving && <span class="inline-block w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />}
491
+ {isSaving ? 'Saving...' : 'Save Changes'}
492
+ </button>
493
+ </div>
494
+ )}
495
+ </div>
496
+ </div>
497
+ )
498
+ }