@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,297 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
2
+ import { fetchMediaLibrary, uploadMedia } from '../markdown-api'
3
+ import {
4
+ config,
5
+ isMediaLibraryLoading,
6
+ isMediaLibraryOpen,
7
+ mediaLibraryItems,
8
+ mediaLibraryState,
9
+ resetMediaLibraryState,
10
+ setMediaLibraryItems,
11
+ setMediaLibraryLoading,
12
+ showToast,
13
+ } from '../signals'
14
+ import type { MediaItem } from '../types'
15
+
16
+ export function MediaLibrary() {
17
+ const visible = isMediaLibraryOpen.value
18
+ const items = mediaLibraryItems.value
19
+ const isLoading = isMediaLibraryLoading.value
20
+ const insertCallback = mediaLibraryState.value.insertCallback
21
+
22
+ const [uploadProgress, setUploadProgress] = useState<number | null>(null)
23
+ const [searchQuery, setSearchQuery] = useState('')
24
+ const fileInputRef = useRef<HTMLInputElement>(null)
25
+ const containerRef = useRef<HTMLDivElement>(null)
26
+
27
+ // Load media items on open
28
+ // biome-ignore lint/correctness/useExhaustiveDependencies: know what i am doing
29
+ useEffect(() => {
30
+ if (visible && items.length === 0) {
31
+ loadMediaItems()
32
+ }
33
+ }, [visible])
34
+
35
+ const loadMediaItems = async () => {
36
+ setMediaLibraryLoading(true)
37
+ try {
38
+ const result = await fetchMediaLibrary(config.value)
39
+ setMediaLibraryItems(result.items)
40
+ } catch (error) {
41
+ showToast('Failed to load media library', 'error')
42
+ } finally {
43
+ setMediaLibraryLoading(false)
44
+ }
45
+ }
46
+
47
+ const handleClose = useCallback(() => {
48
+ resetMediaLibraryState()
49
+ setSearchQuery('')
50
+ }, [])
51
+
52
+ const handleSelectImage = useCallback(
53
+ (item: MediaItem) => {
54
+ if (insertCallback) {
55
+ const alt = item.annotation || item.filename || 'Image'
56
+ insertCallback(item.url, alt)
57
+ handleClose()
58
+ }
59
+ },
60
+ [insertCallback, handleClose],
61
+ )
62
+
63
+ const handleUploadClick = useCallback(() => {
64
+ fileInputRef.current?.click()
65
+ }, [])
66
+
67
+ const handleFileChange = async (e: Event) => {
68
+ const target = e.target as HTMLInputElement
69
+ const file = target.files?.[0]
70
+ if (!file) return
71
+
72
+ setUploadProgress(0)
73
+ try {
74
+ const result = await uploadMedia(config.value, file, (percent) => {
75
+ setUploadProgress(percent)
76
+ })
77
+
78
+ if (result.success && result.url) {
79
+ // Add the new item to the list
80
+ const newItem: MediaItem = {
81
+ id: result.id || crypto.randomUUID(),
82
+ url: result.url,
83
+ filename: result.filename || file.name,
84
+ annotation: result.annotation,
85
+ contentType: file.type,
86
+ }
87
+ setMediaLibraryItems([newItem, ...items])
88
+ showToast('Image uploaded successfully', 'success')
89
+ } else {
90
+ showToast(result.error || 'Upload failed', 'error')
91
+ }
92
+ } catch (error) {
93
+ showToast('Upload failed', 'error')
94
+ } finally {
95
+ setUploadProgress(null)
96
+ target.value = ''
97
+ }
98
+ }
99
+
100
+ const handleDrop = async (e: DragEvent) => {
101
+ e.preventDefault()
102
+ e.stopPropagation()
103
+
104
+ const file = e.dataTransfer?.files[0]
105
+ if (!file || !file.type.startsWith('image/')) {
106
+ showToast('Please drop an image file', 'error')
107
+ return
108
+ }
109
+
110
+ setUploadProgress(0)
111
+ try {
112
+ const result = await uploadMedia(config.value, file, (percent) => {
113
+ setUploadProgress(percent)
114
+ })
115
+
116
+ if (result.success && result.url) {
117
+ const newItem: MediaItem = {
118
+ id: result.id || crypto.randomUUID(),
119
+ url: result.url,
120
+ filename: result.filename || file.name,
121
+ annotation: result.annotation,
122
+ contentType: file.type,
123
+ }
124
+ setMediaLibraryItems([newItem, ...items])
125
+ showToast('Image uploaded successfully', 'success')
126
+ } else {
127
+ showToast(result.error || 'Upload failed', 'error')
128
+ }
129
+ } catch (error) {
130
+ showToast('Upload failed', 'error')
131
+ } finally {
132
+ setUploadProgress(null)
133
+ }
134
+ }
135
+
136
+ const handleDragOver = (e: DragEvent) => {
137
+ e.preventDefault()
138
+ e.stopPropagation()
139
+ }
140
+
141
+ // Filter items by search query
142
+ const filteredItems = searchQuery
143
+ ? items.filter((item) => item.filename.toLowerCase().includes(searchQuery.toLowerCase()))
144
+ : items
145
+
146
+ if (!visible) return null
147
+
148
+ return (
149
+ <div
150
+ class="fixed inset-0 z-2147483647 flex items-center justify-center bg-black/60 backdrop-blur-sm"
151
+ onClick={handleClose}
152
+ data-cms-ui
153
+ >
154
+ <div
155
+ ref={containerRef}
156
+ class="bg-cms-dark rounded-cms-xl shadow-[0_8px_32px_rgba(0,0,0,0.4)] max-w-3xl w-full max-h-[80vh] flex flex-col border border-white/10"
157
+ onClick={(e) => e.stopPropagation()}
158
+ onDrop={handleDrop}
159
+ onDragOver={handleDragOver}
160
+ data-cms-ui
161
+ >
162
+ {/* Header */}
163
+ <div class="flex items-center justify-between p-5 border-b border-white/10">
164
+ <h2 class="text-lg font-semibold text-white">Media Library</h2>
165
+ <button
166
+ type="button"
167
+ onClick={handleClose}
168
+ class="text-white/50 hover:text-white p-1.5 hover:bg-white/10 rounded-full transition-colors"
169
+ data-cms-ui
170
+ >
171
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
172
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
173
+ </svg>
174
+ </button>
175
+ </div>
176
+
177
+ {/* Toolbar */}
178
+ <div class="flex items-center gap-3 p-4 border-b border-white/10">
179
+ <input
180
+ type="text"
181
+ placeholder="Search images..."
182
+ value={searchQuery}
183
+ onInput={(e) => setSearchQuery((e.target as HTMLInputElement).value)}
184
+ class="flex-1 px-4 py-2.5 bg-white/10 border border-white/20 rounded-cms-md text-sm text-white placeholder:text-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10"
185
+ data-cms-ui
186
+ />
187
+ <button
188
+ type="button"
189
+ onClick={handleUploadClick}
190
+ class="px-5 py-2.5 bg-cms-primary text-cms-primary-text rounded-cms-pill text-sm font-medium hover:bg-cms-primary-hover transition-colors"
191
+ data-cms-ui
192
+ >
193
+ Upload
194
+ </button>
195
+ <input
196
+ ref={fileInputRef}
197
+ type="file"
198
+ accept="image/*"
199
+ class="hidden"
200
+ onChange={handleFileChange}
201
+ data-cms-ui
202
+ />
203
+ </div>
204
+
205
+ {/* Upload progress */}
206
+ {uploadProgress !== null && (
207
+ <div class="px-4 py-3 bg-white/5 border-b border-white/10">
208
+ <div class="flex items-center gap-3">
209
+ <div class="flex-1 h-2 bg-white/10 rounded-full overflow-hidden">
210
+ <div
211
+ class="h-full bg-cms-primary transition-all duration-200 rounded-full"
212
+ style={{ width: `${uploadProgress}%` }}
213
+ />
214
+ </div>
215
+ <span class="text-sm text-white font-medium">{uploadProgress}%</span>
216
+ </div>
217
+ </div>
218
+ )}
219
+
220
+ {/* Grid */}
221
+ <div class="flex-1 overflow-auto p-4">
222
+ {isLoading
223
+ ? (
224
+ <div class="flex items-center justify-center h-48">
225
+ <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-cms-primary" />
226
+ </div>
227
+ )
228
+ : filteredItems.length === 0
229
+ ? (
230
+ <div class="flex flex-col items-center justify-center h-48 text-white/50">
231
+ <svg class="w-12 h-12 mb-3 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
232
+ <path
233
+ stroke-linecap="round"
234
+ stroke-linejoin="round"
235
+ stroke-width="1.5"
236
+ d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
237
+ />
238
+ </svg>
239
+ <p class="text-sm">
240
+ {searchQuery ? 'No images found' : 'No images yet. Upload one to get started.'}
241
+ </p>
242
+ </div>
243
+ )
244
+ : (
245
+ <div class="grid grid-cols-4 gap-3">
246
+ {filteredItems.map((item) => (
247
+ <div key={item.id} class="group relative aspect-square" data-cms-ui>
248
+ <button
249
+ type="button"
250
+ onClick={() => handleSelectImage(item)}
251
+ class="w-full h-full rounded-cms-md overflow-hidden border-2 border-white/10 hover:border-cms-primary focus:outline-none focus:border-cms-primary transition-all"
252
+ data-cms-ui
253
+ >
254
+ <img
255
+ src={item.url}
256
+ alt={item.annotation || item.filename}
257
+ class="w-full h-full object-cover"
258
+ />
259
+ <div class="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors pointer-events-none" />
260
+ <div class="absolute bottom-0 left-0 right-0 p-2 bg-linear-to-t from-black/70 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
261
+ <p class="text-xs text-white truncate">{item.filename}</p>
262
+ </div>
263
+ </button>
264
+ {item.annotation && (
265
+ <div class="absolute top-1.5 right-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
266
+ <div class="relative group/tooltip">
267
+ <button
268
+ type="button"
269
+ class="p-1 bg-black/60 hover:bg-black/80 rounded-full text-white/70 hover:text-white transition-colors"
270
+ onClick={(e) => e.stopPropagation()}
271
+ title={item.annotation}
272
+ data-cms-ui
273
+ >
274
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
275
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
276
+ </svg>
277
+ </button>
278
+ <div class="absolute right-0 top-full mt-1 w-48 p-2 bg-black/90 text-white text-xs rounded-md opacity-0 invisible group-hover/tooltip:opacity-100 group-hover/tooltip:visible transition-all z-10 pointer-events-none">
279
+ {item.annotation}
280
+ </div>
281
+ </div>
282
+ </div>
283
+ )}
284
+ </div>
285
+ ))}
286
+ </div>
287
+ )}
288
+ </div>
289
+
290
+ {/* Footer with drop hint */}
291
+ <div class="px-4 py-4 border-t border-white/10 bg-white/5 text-center text-sm text-white/50 rounded-b-cms-xl">
292
+ Drag and drop images here to upload
293
+ </div>
294
+ </div>
295
+ </div>
296
+ )
297
+ }
@@ -0,0 +1,402 @@
1
+ import { useEffect, useRef } from 'preact/hooks'
2
+ import { getColorPreview, parseColorClass } from '../color-utils'
3
+ import { Z_INDEX } from '../constants'
4
+ import * as signals from '../signals'
5
+
6
+ export interface OutlineProps {
7
+ visible: boolean
8
+ rect: DOMRect | null
9
+ isComponent?: boolean
10
+ componentName?: string
11
+ tagName?: string
12
+ /** The actual element being outlined - used for scroll tracking */
13
+ element?: HTMLElement | null
14
+ /** CMS ID of the hovered element */
15
+ cmsId?: string | null
16
+ /** Callback when a color swatch is clicked */
17
+ onColorClick?: (cmsId: string, rect: DOMRect) => void
18
+ /** Callback when an attribute indicator is clicked */
19
+ onAttributeClick?: (cmsId: string, rect: DOMRect) => void
20
+ }
21
+
22
+ // Minimum space needed to show label outside the element
23
+ const LABEL_OUTSIDE_THRESHOLD = 28
24
+ // Padding from viewport edges for sticky label
25
+ const STICKY_PADDING = 8
26
+
27
+ /**
28
+ * Shadow DOM-based hover outline component.
29
+ * Uses a custom element with Shadow DOM to avoid style conflicts.
30
+ */
31
+ export function Outline(
32
+ { visible, rect, isComponent = false, componentName, tagName, element, cmsId, onColorClick, onAttributeClick }: OutlineProps,
33
+ ) {
34
+ const containerRef = useRef<HTMLDivElement>(null)
35
+ const shadowRootRef = useRef<ShadowRoot | null>(null)
36
+ const overlayRef = useRef<HTMLDivElement | null>(null)
37
+ const labelRef = useRef<HTMLDivElement | null>(null)
38
+ const toolbarRef = useRef<HTMLDivElement | null>(null)
39
+
40
+ // Initialize Shadow DOM once
41
+ useEffect(() => {
42
+ if (containerRef.current && !shadowRootRef.current) {
43
+ shadowRootRef.current = containerRef.current.attachShadow({ mode: 'open' })
44
+
45
+ // Create styles
46
+ const style = document.createElement('style')
47
+ style.textContent = `
48
+ :host {
49
+ position: fixed;
50
+ top: 0;
51
+ left: 0;
52
+ pointer-events: none;
53
+ z-index: ${Z_INDEX.OVERLAY};
54
+ }
55
+
56
+ .outline-overlay {
57
+ position: fixed;
58
+ border-radius: 16px;
59
+ box-sizing: border-box;
60
+ transition: opacity 100ms ease;
61
+ overflow: visible;
62
+ }
63
+
64
+ .outline-overlay.hidden {
65
+ opacity: 0;
66
+ }
67
+
68
+ .outline-overlay.visible {
69
+ opacity: 1;
70
+ }
71
+
72
+ .outline-label {
73
+ position: fixed;
74
+ padding: 6px 14px;
75
+ border-radius: 9999px;
76
+ font-size: 11px;
77
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
78
+ font-weight: 600;
79
+ white-space: nowrap;
80
+ display: flex;
81
+ align-items: center;
82
+ gap: 6px;
83
+ box-shadow: 0 8px 32px rgba(0,0,0,0.3);
84
+ border: 1px solid rgba(255,255,255,0.1);
85
+ z-index: ${Z_INDEX.MODAL};
86
+ }
87
+
88
+ .outline-label .tag {
89
+ opacity: 0.85;
90
+ }
91
+
92
+ .outline-label .component-name {
93
+ font-weight: 700;
94
+ }
95
+
96
+ .outline-label .separator {
97
+ opacity: 0.5;
98
+ }
99
+
100
+ .element-toolbar {
101
+ position: fixed;
102
+ display: flex;
103
+ align-items: center;
104
+ gap: 6px;
105
+ padding: 8px;
106
+ background: #1A1A1A;
107
+ border: 1px solid rgba(255,255,255,0.1);
108
+ border-radius: 12px;
109
+ box-shadow: 0 8px 32px rgba(0,0,0,0.3);
110
+ pointer-events: auto;
111
+ z-index: ${Z_INDEX.MODAL};
112
+ }
113
+
114
+ .element-toolbar.hidden {
115
+ display: none;
116
+ }
117
+
118
+ .color-swatch {
119
+ width: 24px;
120
+ height: 24px;
121
+ border: 2px solid transparent;
122
+ border-radius: 50%;
123
+ cursor: pointer;
124
+ transition: all 150ms ease;
125
+ }
126
+
127
+ .color-swatch:hover {
128
+ transform: scale(1.15);
129
+ box-shadow: 0 2px 8px rgba(0,0,0,0.3);
130
+ border-color: rgba(223, 255, 64, 0.5);
131
+ }
132
+
133
+ .color-swatch.white {
134
+ border-color: rgba(255,255,255,0.2);
135
+ }
136
+
137
+ .toolbar-divider {
138
+ width: 1px;
139
+ height: 20px;
140
+ background: rgba(255,255,255,0.15);
141
+ margin: 0 2px;
142
+ }
143
+
144
+ .attr-button {
145
+ width: 24px;
146
+ height: 24px;
147
+ display: flex;
148
+ align-items: center;
149
+ justify-content: center;
150
+ background: transparent;
151
+ border: none;
152
+ border-radius: 50%;
153
+ cursor: pointer;
154
+ transition: all 150ms ease;
155
+ }
156
+
157
+ .attr-button:hover {
158
+ transform: scale(1.15);
159
+ background: rgba(255,255,255,0.1);
160
+ }
161
+
162
+ .attr-button svg {
163
+ width: 14px;
164
+ height: 14px;
165
+ color: rgba(255,255,255,0.7);
166
+ }
167
+
168
+ .attr-button:hover svg {
169
+ color: #DFFF40;
170
+ }
171
+ `
172
+
173
+ overlayRef.current = document.createElement('div')
174
+ overlayRef.current.className = 'outline-overlay hidden'
175
+
176
+ labelRef.current = document.createElement('div')
177
+ labelRef.current.className = 'outline-label'
178
+ // Label is now a sibling, not a child, for independent positioning
179
+
180
+ toolbarRef.current = document.createElement('div')
181
+ toolbarRef.current.className = 'element-toolbar hidden'
182
+
183
+ // Add hover listeners for toolbar to signal hover state
184
+ toolbarRef.current.addEventListener('mouseenter', () => {
185
+ signals.setHoveringSwatches(true)
186
+ signals.setHoveringAttributeButton(true)
187
+ })
188
+ toolbarRef.current.addEventListener('mouseleave', () => {
189
+ signals.setHoveringSwatches(false)
190
+ signals.setHoveringAttributeButton(false)
191
+ })
192
+
193
+ shadowRootRef.current.appendChild(style)
194
+ shadowRootRef.current.appendChild(overlayRef.current)
195
+ shadowRootRef.current.appendChild(labelRef.current)
196
+ shadowRootRef.current.appendChild(toolbarRef.current)
197
+ }
198
+ }, [])
199
+
200
+ // Update overlay visibility and position
201
+ useEffect(() => {
202
+ if (!overlayRef.current || !labelRef.current || !toolbarRef.current) return
203
+
204
+ if (!visible || !rect) {
205
+ overlayRef.current.className = 'outline-overlay hidden'
206
+ labelRef.current.style.display = 'none'
207
+ toolbarRef.current.className = 'element-toolbar hidden'
208
+ signals.setHoveringSwatches(false)
209
+ signals.setHoveringAttributeButton(false)
210
+ return
211
+ }
212
+
213
+ overlayRef.current.className = 'outline-overlay visible'
214
+
215
+ const viewportHeight = window.innerHeight
216
+ const viewportWidth = window.innerWidth
217
+
218
+ // Use viewport-relative coordinates (fixed positioning)
219
+ const left = rect.left - 6
220
+ const top = rect.top - 6
221
+ const width = rect.width + 12
222
+ const height = rect.height + 12
223
+
224
+ overlayRef.current.style.left = `${left}px`
225
+ overlayRef.current.style.top = `${top}px`
226
+ overlayRef.current.style.width = `${width}px`
227
+ overlayRef.current.style.height = `${height}px`
228
+
229
+ // Different styling for components vs text elements
230
+ if (isComponent) {
231
+ overlayRef.current.style.border = `2px solid #1A1A1A` // Dark border
232
+ overlayRef.current.style.backgroundColor = 'rgba(0, 0, 0, 0.03)'
233
+ overlayRef.current.style.boxShadow = '0 4px 16px rgba(0, 0, 0, 0.08)'
234
+ labelRef.current.style.display = 'flex'
235
+ labelRef.current.style.backgroundColor = '#1A1A1A'
236
+ labelRef.current.style.color = 'white'
237
+ toolbarRef.current.className = 'element-toolbar hidden' // Hide toolbar for components
238
+
239
+ // Build label content
240
+ let labelContent = ''
241
+ if (tagName) {
242
+ labelContent += `<span class="tag">&lt;${tagName}&gt;</span>`
243
+ }
244
+ if (componentName) {
245
+ if (tagName) labelContent += `<span class="separator">·</span>`
246
+ labelContent += `<span class="component-name">${componentName}</span>`
247
+ }
248
+ if (!tagName && !componentName) {
249
+ labelContent = 'COMPONENT'
250
+ }
251
+ labelRef.current.innerHTML = labelContent
252
+
253
+ // Calculate sticky label position
254
+ // The label should stay visible within the viewport, attached to the element
255
+ const elementTop = rect.top
256
+ const elementBottom = rect.bottom
257
+ const elementLeft = rect.left
258
+
259
+ // Ideal position is above the element
260
+ let labelTop = elementTop - 36 // Increased offset for thicker border/shadow
261
+ let labelLeft = Math.max(STICKY_PADDING, elementLeft)
262
+
263
+ // If element top is above viewport, stick label to top of viewport
264
+ if (elementTop < STICKY_PADDING + 36) {
265
+ // Element is partially scrolled up - stick label to visible portion
266
+ labelTop = Math.max(STICKY_PADDING, Math.min(elementTop + STICKY_PADDING, elementBottom - 36))
267
+ }
268
+
269
+ // If element is below viewport, position at bottom
270
+ if (elementTop > viewportHeight) {
271
+ labelRef.current.style.display = 'none'
272
+ } else if (elementBottom < 0) {
273
+ labelRef.current.style.display = 'none'
274
+ } else {
275
+ // Clamp label position to viewport
276
+ labelTop = Math.max(STICKY_PADDING, Math.min(labelTop, viewportHeight - 36))
277
+ labelLeft = Math.min(labelLeft, viewportWidth - 150) // Ensure label doesn't go off-screen right
278
+
279
+ labelRef.current.style.top = `${labelTop}px`
280
+ labelRef.current.style.left = `${labelLeft}px`
281
+ }
282
+ } else {
283
+ overlayRef.current.style.border = `2px dashed #1A1A1A`
284
+ overlayRef.current.style.backgroundColor = 'transparent'
285
+ overlayRef.current.style.boxShadow = 'none'
286
+ labelRef.current.style.display = 'none'
287
+
288
+ // Check for color swatches and attribute button
289
+ const manifest = signals.manifest.value
290
+ const pendingColorChange = cmsId ? signals.pendingColorChanges.value.get(cmsId) : null
291
+ const entry = cmsId ? manifest.entries[cmsId] : null
292
+ const colorClasses = pendingColorChange?.newClasses ?? entry?.colorClasses
293
+
294
+ const hasColorSwatches = colorClasses && (colorClasses.bg?.value || colorClasses.text?.value) && onColorClick
295
+ const hasEditableAttributes = entry?.attributes && Object.keys(entry.attributes).length > 0
296
+
297
+ // Show unified toolbar if there are swatches or attribute button
298
+ if ((hasColorSwatches || hasEditableAttributes) && (onColorClick || onAttributeClick)) {
299
+ toolbarRef.current.className = 'element-toolbar'
300
+ toolbarRef.current.innerHTML = ''
301
+
302
+ // Position toolbar at bottom center of the element
303
+ const toolbarTop = rect.bottom + 6
304
+ const toolbarLeft = rect.left + rect.width / 2
305
+
306
+ toolbarRef.current.style.top = `${toolbarTop}px`
307
+ toolbarRef.current.style.left = `${toolbarLeft}px`
308
+ toolbarRef.current.style.transform = 'translateX(-50%)'
309
+
310
+ // Helper to apply swatch styles including transparent checkerboard
311
+ const applySwatchStyle = (swatch: HTMLDivElement, colorName: string, preview: string) => {
312
+ if (colorName === 'transparent') {
313
+ swatch.style.backgroundColor = 'transparent'
314
+ swatch.style.backgroundImage =
315
+ 'linear-gradient(45deg, #555 25%, transparent 25%, transparent 75%, #555 75%, #555), linear-gradient(45deg, #555 25%, transparent 25%, transparent 75%, #555 75%, #555)'
316
+ swatch.style.backgroundSize = '8px 8px'
317
+ swatch.style.backgroundPosition = '0 0, 4px 4px'
318
+ } else {
319
+ swatch.style.backgroundColor = preview
320
+ }
321
+ }
322
+
323
+ // Add color swatches
324
+ if (hasColorSwatches && colorClasses) {
325
+ // Create bg swatch
326
+ if (colorClasses.bg?.value) {
327
+ const parsed = parseColorClass(colorClasses.bg.value)
328
+ if (parsed) {
329
+ const preview = getColorPreview(parsed.colorName, parsed.shade)
330
+ const swatch = document.createElement('div')
331
+ swatch.className = `color-swatch${parsed.colorName === 'white' ? ' white' : ''}`
332
+ applySwatchStyle(swatch, parsed.colorName, preview)
333
+ swatch.title = `Background: ${colorClasses.bg.value}`
334
+ swatch.onclick = (e) => {
335
+ e.stopPropagation()
336
+ if (cmsId && onColorClick) onColorClick(cmsId, rect)
337
+ }
338
+ toolbarRef.current.appendChild(swatch)
339
+ }
340
+ }
341
+
342
+ // Create text swatch
343
+ if (colorClasses.text?.value) {
344
+ const parsed = parseColorClass(colorClasses.text.value)
345
+ if (parsed) {
346
+ const preview = getColorPreview(parsed.colorName, parsed.shade)
347
+ const swatch = document.createElement('div')
348
+ swatch.className = `color-swatch${parsed.colorName === 'white' ? ' white' : ''}`
349
+ applySwatchStyle(swatch, parsed.colorName, preview)
350
+ swatch.title = `Text: ${colorClasses.text.value}`
351
+ swatch.onclick = (e) => {
352
+ e.stopPropagation()
353
+ if (cmsId && onColorClick) onColorClick(cmsId, rect)
354
+ }
355
+ toolbarRef.current.appendChild(swatch)
356
+ }
357
+ }
358
+ }
359
+
360
+ // Add divider and attribute button if needed
361
+ if (hasEditableAttributes && onAttributeClick) {
362
+ // Add divider if there are swatches
363
+ if (hasColorSwatches) {
364
+ const divider = document.createElement('div')
365
+ divider.className = 'toolbar-divider'
366
+ toolbarRef.current.appendChild(divider)
367
+ }
368
+
369
+ // Add attribute button
370
+ const attrButton = document.createElement('button')
371
+ attrButton.className = 'attr-button'
372
+ attrButton.innerHTML =
373
+ `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>`
374
+ attrButton.title = 'Edit attributes'
375
+ attrButton.onclick = (e) => {
376
+ e.stopPropagation()
377
+ if (cmsId) onAttributeClick(cmsId, rect)
378
+ }
379
+ toolbarRef.current.appendChild(attrButton)
380
+ }
381
+ } else {
382
+ toolbarRef.current.className = 'element-toolbar hidden'
383
+ }
384
+ }
385
+ }, [visible, rect, isComponent, componentName, tagName, cmsId, onColorClick, onAttributeClick])
386
+
387
+ return (
388
+ <div
389
+ ref={containerRef}
390
+ data-cms-ui
391
+ style={{
392
+ position: 'fixed',
393
+ top: 0,
394
+ left: 0,
395
+ width: 0,
396
+ height: 0,
397
+ pointerEvents: 'none',
398
+ zIndex: Z_INDEX.OVERLAY,
399
+ }}
400
+ />
401
+ )
402
+ }