@nuasite/cms 0.18.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/editor.js +52746 -36711
  2. package/package.json +16 -14
  3. package/src/build-processor.ts +4 -1
  4. package/src/collection-scanner.ts +425 -48
  5. package/src/dev-middleware.ts +26 -203
  6. package/src/editor/api.ts +1 -22
  7. package/src/editor/components/ai-chat.tsx +3 -3
  8. package/src/editor/components/ai-tooltip.tsx +2 -1
  9. package/src/editor/components/block-editor.tsx +13 -108
  10. package/src/editor/components/collections-browser.tsx +168 -205
  11. package/src/editor/components/component-card.tsx +49 -0
  12. package/src/editor/components/confirm-dialog.tsx +34 -47
  13. package/src/editor/components/create-page-modal.tsx +529 -101
  14. package/src/editor/components/delete-page-dialog.tsx +100 -0
  15. package/src/editor/components/fields.tsx +175 -0
  16. package/src/editor/components/frontmatter-fields.tsx +281 -70
  17. package/src/editor/components/frontmatter-sidebar.tsx +223 -0
  18. package/src/editor/components/highlight-overlay.ts +3 -2
  19. package/src/editor/components/markdown-editor-overlay.tsx +131 -85
  20. package/src/editor/components/markdown-inline-editor.tsx +74 -5
  21. package/src/editor/components/mdx-block-view.tsx +102 -0
  22. package/src/editor/components/mdx-component-picker.tsx +123 -0
  23. package/src/editor/components/mdx-props-editor.tsx +94 -0
  24. package/src/editor/components/media-library.tsx +373 -100
  25. package/src/editor/components/modal-shell.tsx +87 -0
  26. package/src/editor/components/prop-editor.tsx +52 -0
  27. package/src/editor/components/redirect-countdown.tsx +3 -1
  28. package/src/editor/components/redirects-manager.tsx +269 -0
  29. package/src/editor/components/reference-picker.tsx +203 -0
  30. package/src/editor/components/seo-editor.tsx +285 -303
  31. package/src/editor/components/toast/toast-container.tsx +2 -1
  32. package/src/editor/components/toolbar.tsx +177 -46
  33. package/src/editor/constants.ts +26 -0
  34. package/src/editor/editor.ts +112 -0
  35. package/src/editor/fetch.ts +62 -0
  36. package/src/editor/index.tsx +19 -1
  37. package/src/editor/markdown-api.ts +105 -156
  38. package/src/editor/milkdown-mdx-plugin.tsx +269 -0
  39. package/src/editor/signals.ts +206 -13
  40. package/src/editor/types.ts +52 -1
  41. package/src/handlers/api-routes.ts +251 -0
  42. package/src/handlers/component-ops.ts +2 -18
  43. package/src/handlers/markdown-ops.ts +202 -47
  44. package/src/handlers/page-ops.ts +229 -0
  45. package/src/handlers/redirect-ops.ts +163 -0
  46. package/src/handlers/source-writer.ts +157 -1
  47. package/src/html-processor.ts +14 -2
  48. package/src/index.ts +76 -2
  49. package/src/manifest-writer.ts +19 -1
  50. package/src/media/contember.ts +2 -1
  51. package/src/media/local.ts +66 -28
  52. package/src/media/project-images.ts +81 -0
  53. package/src/media/s3.ts +32 -11
  54. package/src/media/types.ts +24 -2
  55. package/src/shared.ts +27 -0
  56. package/src/source-finder/collection-finder.ts +219 -41
  57. package/src/source-finder/index.ts +7 -1
  58. package/src/source-finder/search-index.ts +178 -36
  59. package/src/source-finder/snippet-utils.ts +423 -3
  60. package/src/types.ts +111 -2
  61. package/src/utils.ts +40 -4
@@ -0,0 +1,87 @@
1
+ import type { ComponentChildren } from 'preact'
2
+ import { Z_INDEX } from '../constants'
3
+
4
+ export function ModalBackdrop({ onClose, maxWidth = 'max-w-lg', extraClass, children }: {
5
+ onClose: () => void
6
+ maxWidth?: string
7
+ extraClass?: string
8
+ children: ComponentChildren
9
+ }) {
10
+ return (
11
+ <div
12
+ style={{ zIndex: Z_INDEX.MODAL }}
13
+ class="fixed inset-0 flex items-center justify-center bg-black/60 backdrop-blur-sm"
14
+ onClick={onClose}
15
+ data-cms-ui
16
+ >
17
+ <div
18
+ class={`bg-cms-dark rounded-cms-xl shadow-[0_8px_32px_rgba(0,0,0,0.4)] ${maxWidth} w-full border border-white/10 ${extraClass ?? ''}`}
19
+ onClick={(e) => e.stopPropagation()}
20
+ data-cms-ui
21
+ >
22
+ {children}
23
+ </div>
24
+ </div>
25
+ )
26
+ }
27
+
28
+ export function ModalHeader({ title, onBack, onClose }: {
29
+ title: string
30
+ onBack?: () => void
31
+ onClose: () => void
32
+ }) {
33
+ return (
34
+ <div class="flex items-center gap-3 p-5 border-b border-white/10">
35
+ {onBack && (
36
+ <button
37
+ type="button"
38
+ onClick={onBack}
39
+ class="text-white/50 hover:text-white p-1.5 hover:bg-white/10 rounded-full transition-colors cursor-pointer"
40
+ data-cms-ui
41
+ >
42
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
43
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
44
+ </svg>
45
+ </button>
46
+ )}
47
+ <h2 class="text-lg font-semibold text-white flex-1">{title}</h2>
48
+ <CloseButton onClick={onClose} />
49
+ </div>
50
+ )
51
+ }
52
+
53
+ export function ModalFooter({ children }: { children: ComponentChildren }) {
54
+ return (
55
+ <div class="flex items-center justify-end gap-2 p-5 border-t border-white/10 bg-white/5 rounded-b-cms-xl">
56
+ {children}
57
+ </div>
58
+ )
59
+ }
60
+
61
+ export function CloseButton({ onClick }: { onClick: () => void }) {
62
+ return (
63
+ <button
64
+ type="button"
65
+ onClick={onClick}
66
+ class="text-white/50 hover:text-white p-1.5 hover:bg-white/10 rounded-full transition-colors cursor-pointer"
67
+ data-cms-ui
68
+ >
69
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
70
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
71
+ </svg>
72
+ </button>
73
+ )
74
+ }
75
+
76
+ export function CancelButton({ onClick, label = 'Cancel' }: { onClick: () => void; label?: string }) {
77
+ return (
78
+ <button
79
+ type="button"
80
+ onClick={onClick}
81
+ class="px-4 py-2.5 text-sm text-white/80 font-medium rounded-cms-pill hover:bg-white/10 hover:text-white transition-colors cursor-pointer"
82
+ data-cms-ui
83
+ >
84
+ {label}
85
+ </button>
86
+ )
87
+ }
@@ -0,0 +1,52 @@
1
+ import type { ComponentProp } from '../types'
2
+
3
+ export interface PropEditorProps {
4
+ prop: ComponentProp
5
+ value: string
6
+ onChange: (value: string) => void
7
+ }
8
+
9
+ export function PropEditor({ prop, value, onChange }: PropEditorProps) {
10
+ const isBoolean = prop.type === 'boolean'
11
+ const isNumber = prop.type === 'number'
12
+
13
+ return (
14
+ <div class="mb-4">
15
+ <label class="block text-[13px] font-medium text-white mb-1.5">
16
+ {prop.name}
17
+ {prop.required && <span class="text-cms-error ml-1">*</span>}
18
+ </label>
19
+ {prop.description && (
20
+ <div class="text-[11px] text-white/50 mb-1.5">
21
+ {prop.description}
22
+ </div>
23
+ )}
24
+ {isBoolean
25
+ ? (
26
+ <label class="flex items-center gap-2 cursor-pointer">
27
+ <input
28
+ type="checkbox"
29
+ checked={value === 'true'}
30
+ onChange={(e) => onChange((e.target as HTMLInputElement).checked ? 'true' : 'false')}
31
+ class="accent-cms-primary w-5 h-5 rounded"
32
+ />
33
+ <span class="text-[13px] text-white">
34
+ {value === 'true' ? 'Enabled' : 'Disabled'}
35
+ </span>
36
+ </label>
37
+ )
38
+ : (
39
+ <input
40
+ type={isNumber ? 'number' : 'text'}
41
+ value={value}
42
+ onInput={(e) => onChange((e.target as HTMLInputElement).value)}
43
+ placeholder={prop.defaultValue || `Enter ${prop.name}...`}
44
+ class="w-full px-4 py-2.5 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-md"
45
+ />
46
+ )}
47
+ <div class="text-[10px] text-white/40 mt-1.5 font-mono">
48
+ {prop.type}
49
+ </div>
50
+ </div>
51
+ )
52
+ }
@@ -1,3 +1,4 @@
1
+ import { Z_INDEX } from '../constants'
1
2
  import { redirectCountdown, stopRedirectCountdown } from '../signals'
2
3
 
3
4
  export function RedirectCountdown() {
@@ -8,7 +9,8 @@ export function RedirectCountdown() {
8
9
 
9
10
  return (
10
11
  <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
+ style={{ zIndex: Z_INDEX.MODAL }}
13
+ class="fixed bottom-6 left-1/2 -translate-x-1/2 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
14
  data-cms-ui
13
15
  onMouseDown={stopPropagation}
14
16
  onClick={stopPropagation}
@@ -0,0 +1,269 @@
1
+ import { useCallback, useEffect, useState } from 'preact/hooks'
2
+ import { addRedirect, deleteRedirect, getRedirects, updateRedirect } from '../markdown-api'
3
+ import {
4
+ closeRedirectsManager,
5
+ config,
6
+ isRedirectsManagerOpen,
7
+ redirectsManagerState,
8
+ setRedirectsManagerEditing,
9
+ setRedirectsManagerRules,
10
+ showToast,
11
+ } from '../signals'
12
+ import type { RedirectRule } from '../types'
13
+ import { ModalBackdrop, ModalHeader } from './modal-shell'
14
+
15
+ export function RedirectsManager() {
16
+ const visible = isRedirectsManagerOpen.value
17
+ const state = redirectsManagerState.value
18
+
19
+ useLoadRedirects()
20
+
21
+ if (!visible) return null
22
+
23
+ return (
24
+ <ModalBackdrop onClose={() => closeRedirectsManager()} maxWidth="max-w-2xl" extraClass="max-h-[80vh] flex flex-col">
25
+ <ModalHeader title="Redirects" onClose={() => closeRedirectsManager()} />
26
+
27
+ <div class="flex-1 overflow-y-auto p-5 space-y-3">
28
+ {state.isLoading && <div class="text-center py-8 text-white/50">Loading redirects...</div>}
29
+
30
+ {!state.isLoading && state.rules.length === 0 && (
31
+ <div class="text-center py-8">
32
+ <p class="text-white/50 mb-2">No redirects configured</p>
33
+ <p class="text-white/30 text-sm">Redirects are stored in src/_redirects</p>
34
+ </div>
35
+ )}
36
+
37
+ {!state.isLoading && state.rules.map((rule) => (
38
+ <RedirectRow
39
+ key={rule.lineIndex}
40
+ rule={rule}
41
+ isEditing={state.editingIndex === rule.lineIndex}
42
+ />
43
+ ))}
44
+ </div>
45
+
46
+ <div class="shrink-0 p-5 border-t border-white/10 bg-white/5 rounded-b-cms-xl">
47
+ <AddRedirectForm />
48
+ </div>
49
+ </ModalBackdrop>
50
+ )
51
+ }
52
+
53
+ function RedirectRow({ rule, isEditing }: { rule: RedirectRule; isEditing: boolean }) {
54
+ const [source, setSource] = useState(rule.source)
55
+ const [destination, setDestination] = useState(rule.destination)
56
+ const [statusCode, setStatusCode] = useState(String(rule.statusCode))
57
+ const [isSaving, setIsSaving] = useState(false)
58
+
59
+ const handleSave = useCallback(async () => {
60
+ const cfg = config.value
61
+ if (!cfg) return
62
+
63
+ setIsSaving(true)
64
+ const result = await updateRedirect(cfg, {
65
+ lineIndex: rule.lineIndex,
66
+ source,
67
+ destination,
68
+ statusCode: parseInt(statusCode, 10) || 307,
69
+ })
70
+ setIsSaving(false)
71
+
72
+ if (result.success) {
73
+ setRedirectsManagerEditing(null)
74
+ await refreshRedirects()
75
+ showToast('Redirect updated', 'success')
76
+ } else {
77
+ showToast(result.error || 'Failed to update', 'error')
78
+ }
79
+ }, [rule.lineIndex, source, destination, statusCode])
80
+
81
+ const handleDelete = useCallback(async () => {
82
+ const cfg = config.value
83
+ if (!cfg) return
84
+
85
+ const result = await deleteRedirect(cfg, { lineIndex: rule.lineIndex })
86
+ if (result.success) {
87
+ await refreshRedirects()
88
+ showToast('Redirect deleted', 'success')
89
+ } else {
90
+ showToast(result.error || 'Failed to delete', 'error')
91
+ }
92
+ }, [rule.lineIndex])
93
+
94
+ if (isEditing) {
95
+ return (
96
+ <div class="flex flex-col gap-2 p-3 bg-white/5 rounded-cms-lg border border-white/10">
97
+ <div class="flex gap-2">
98
+ <input
99
+ type="text"
100
+ value={source}
101
+ onInput={(e) => setSource((e.target as HTMLInputElement).value)}
102
+ class="flex-1 px-2.5 py-1.5 bg-white/5 border border-white/10 rounded-cms-md text-white text-sm focus:outline-none focus:border-cms-primary/50"
103
+ placeholder="/old-path"
104
+ data-cms-ui
105
+ />
106
+ <span class="text-white/30 self-center">→</span>
107
+ <input
108
+ type="text"
109
+ value={destination}
110
+ onInput={(e) => setDestination((e.target as HTMLInputElement).value)}
111
+ class="flex-1 px-2.5 py-1.5 bg-white/5 border border-white/10 rounded-cms-md text-white text-sm focus:outline-none focus:border-cms-primary/50"
112
+ placeholder="/new-path"
113
+ data-cms-ui
114
+ />
115
+ <input
116
+ type="text"
117
+ value={statusCode}
118
+ onInput={(e) => setStatusCode((e.target as HTMLInputElement).value)}
119
+ class="w-16 px-2.5 py-1.5 bg-white/5 border border-white/10 rounded-cms-md text-white text-sm text-center focus:outline-none focus:border-cms-primary/50"
120
+ placeholder="307"
121
+ data-cms-ui
122
+ />
123
+ </div>
124
+ <div class="flex justify-end gap-2">
125
+ <button
126
+ type="button"
127
+ onClick={() => setRedirectsManagerEditing(null)}
128
+ class="px-3 py-1.5 text-xs text-white/60 hover:text-white hover:bg-white/10 rounded-cms-md transition-colors cursor-pointer"
129
+ data-cms-ui
130
+ >
131
+ Cancel
132
+ </button>
133
+ <button
134
+ type="button"
135
+ onClick={handleSave}
136
+ disabled={isSaving}
137
+ class="px-3 py-1.5 text-xs font-medium bg-cms-primary text-cms-primary-text rounded-cms-md hover:bg-cms-primary-hover transition-colors cursor-pointer disabled:opacity-40"
138
+ data-cms-ui
139
+ >
140
+ {isSaving ? 'Saving...' : 'Save'}
141
+ </button>
142
+ </div>
143
+ </div>
144
+ )
145
+ }
146
+
147
+ return (
148
+ <div class="flex items-center gap-3 p-3 bg-white/5 rounded-cms-lg border border-white/10 group">
149
+ <div class="flex-1 min-w-0 flex items-center gap-2 text-sm">
150
+ <span class="text-white/80 truncate">{rule.source}</span>
151
+ <span class="text-white/30 shrink-0">→</span>
152
+ <span class="text-white/60 truncate">{rule.destination}</span>
153
+ </div>
154
+ <span class="text-xs text-white/30 tabular-nums shrink-0">{rule.statusCode}</span>
155
+ <div class="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
156
+ <button
157
+ type="button"
158
+ onClick={() => setRedirectsManagerEditing(rule.lineIndex)}
159
+ class="p-1.5 text-white/40 hover:text-white hover:bg-white/10 rounded transition-colors cursor-pointer"
160
+ title="Edit"
161
+ data-cms-ui
162
+ >
163
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
164
+ <path
165
+ stroke-linecap="round"
166
+ stroke-linejoin="round"
167
+ stroke-width="2"
168
+ d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
169
+ />
170
+ </svg>
171
+ </button>
172
+ <button
173
+ type="button"
174
+ onClick={handleDelete}
175
+ class="p-1.5 text-white/40 hover:text-red-400 hover:bg-white/10 rounded transition-colors cursor-pointer"
176
+ title="Delete"
177
+ data-cms-ui
178
+ >
179
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
180
+ <path
181
+ stroke-linecap="round"
182
+ stroke-linejoin="round"
183
+ stroke-width="2"
184
+ d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
185
+ />
186
+ </svg>
187
+ </button>
188
+ </div>
189
+ </div>
190
+ )
191
+ }
192
+
193
+ function AddRedirectForm() {
194
+ const [source, setSource] = useState('')
195
+ const [destination, setDestination] = useState('')
196
+ const [isAdding, setIsAdding] = useState(false)
197
+
198
+ const handleAdd = useCallback(async () => {
199
+ const cfg = config.value
200
+ if (!cfg || !source.trim() || !destination.trim()) return
201
+
202
+ setIsAdding(true)
203
+ const result = await addRedirect(cfg, {
204
+ source: source.trim(),
205
+ destination: destination.trim(),
206
+ statusCode: 307,
207
+ })
208
+ setIsAdding(false)
209
+
210
+ if (result.success) {
211
+ setSource('')
212
+ setDestination('')
213
+ await refreshRedirects()
214
+ showToast('Redirect added', 'success')
215
+ } else {
216
+ showToast(result.error || 'Failed to add redirect', 'error')
217
+ }
218
+ }, [source, destination])
219
+
220
+ return (
221
+ <div class="flex gap-2 items-end">
222
+ <div class="flex-1 space-y-1">
223
+ <label class="text-xs text-white/40" data-cms-ui>From</label>
224
+ <input
225
+ type="text"
226
+ value={source}
227
+ onInput={(e) => setSource((e.target as HTMLInputElement).value)}
228
+ placeholder="/old-path"
229
+ class="w-full px-2.5 py-1.5 bg-white/5 border border-white/10 rounded-cms-md text-white text-sm placeholder:text-white/30 focus:outline-none focus:border-cms-primary/50"
230
+ data-cms-ui
231
+ />
232
+ </div>
233
+ <div class="flex-1 space-y-1">
234
+ <label class="text-xs text-white/40" data-cms-ui>To</label>
235
+ <input
236
+ type="text"
237
+ value={destination}
238
+ onInput={(e) => setDestination((e.target as HTMLInputElement).value)}
239
+ placeholder="/new-path"
240
+ class="w-full px-2.5 py-1.5 bg-white/5 border border-white/10 rounded-cms-md text-white text-sm placeholder:text-white/30 focus:outline-none focus:border-cms-primary/50"
241
+ data-cms-ui
242
+ />
243
+ </div>
244
+ <button
245
+ type="button"
246
+ onClick={handleAdd}
247
+ disabled={isAdding || !source.trim() || !destination.trim()}
248
+ class="px-4 py-1.5 text-sm font-medium bg-cms-primary text-cms-primary-text rounded-cms-md hover:bg-cms-primary-hover transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
249
+ data-cms-ui
250
+ >
251
+ {isAdding ? 'Adding...' : 'Add'}
252
+ </button>
253
+ </div>
254
+ )
255
+ }
256
+
257
+ async function refreshRedirects(): Promise<void> {
258
+ const cfg = config.value
259
+ if (!cfg) return
260
+ const result = await getRedirects(cfg)
261
+ setRedirectsManagerRules(result.rules)
262
+ }
263
+
264
+ export function useLoadRedirects() {
265
+ const isOpen = isRedirectsManagerOpen.value
266
+ useEffect(() => {
267
+ if (isOpen) refreshRedirects()
268
+ }, [isOpen])
269
+ }
@@ -0,0 +1,203 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
2
+ import { clampPanelPosition, Z_INDEX } from '../constants'
3
+ import { updateMarkdownPage } from '../markdown-api'
4
+ import { closeReferencePicker, config, manifest, referencePickerState, showToast } from '../signals'
5
+
6
+ const PANEL_WIDTH = 320
7
+
8
+ function getCollectionEntryOptions(collectionName?: string): Array<{ value: string; label: string }> {
9
+ if (!collectionName) return []
10
+ const def = manifest.value.collectionDefinitions?.[collectionName]
11
+ if (!def?.entries) return []
12
+ return def.entries.map(e => ({
13
+ value: e.slug,
14
+ label: e.title ?? e.slug,
15
+ }))
16
+ }
17
+
18
+ export function ReferencePicker() {
19
+ const state = referencePickerState.value
20
+ const panelRef = useRef<HTMLDivElement>(null)
21
+ const inputRef = useRef<HTMLInputElement>(null)
22
+ const [query, setQuery] = useState('')
23
+ const [saving, setSaving] = useState(false)
24
+
25
+ const options = useMemo(
26
+ () => getCollectionEntryOptions(state.collection ?? undefined),
27
+ [state.collection],
28
+ )
29
+
30
+ // Reset search when picker opens
31
+ useEffect(() => {
32
+ if (state.isOpen) {
33
+ setQuery('')
34
+ setSaving(false)
35
+ setTimeout(() => inputRef.current?.focus(), 50)
36
+ }
37
+ }, [state.isOpen])
38
+
39
+ const filtered = useMemo(() => {
40
+ if (!query) return options
41
+ const q = query.toLowerCase()
42
+ return options.filter(o => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q))
43
+ }, [query, options])
44
+
45
+ const currentLabel = useMemo(() => {
46
+ if (state.isArray) return null
47
+ return options.find(o => o.value === state.currentValue)?.label ?? state.currentValue
48
+ }, [options, state.currentValue, state.isArray])
49
+
50
+ const updateReference = useCallback(async (value: string | string[]) => {
51
+ if (!state.fieldName || !state.ownerPath) return
52
+ setSaving(true)
53
+ try {
54
+ const result = await updateMarkdownPage(config.value, {
55
+ filePath: state.ownerPath,
56
+ frontmatter: { [state.fieldName]: value },
57
+ })
58
+ if (result.success) {
59
+ showToast('Reference updated', 'success')
60
+ } else {
61
+ showToast(result.error || 'Failed to update reference', 'error')
62
+ }
63
+ } catch {
64
+ showToast('Failed to update reference', 'error')
65
+ }
66
+ closeReferencePicker()
67
+ }, [state.fieldName, state.ownerPath])
68
+
69
+ const handleSelect = useCallback((newValue: string) => updateReference(newValue), [updateReference])
70
+
71
+ const handleArrayToggle = useCallback((toggledValue: string) => {
72
+ const current = new Set(state.currentValues)
73
+ if (current.has(toggledValue)) {
74
+ current.delete(toggledValue)
75
+ } else {
76
+ current.add(toggledValue)
77
+ }
78
+ updateReference([...current])
79
+ }, [state.currentValues, updateReference])
80
+
81
+ // Close on outside click or Escape
82
+ useEffect(() => {
83
+ if (!state.isOpen) return
84
+ const onMouseDown = (e: MouseEvent) => {
85
+ if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
86
+ closeReferencePicker()
87
+ }
88
+ }
89
+ const onKeyDown = (e: KeyboardEvent) => {
90
+ if (e.key === 'Escape') closeReferencePicker()
91
+ }
92
+ document.addEventListener('mousedown', onMouseDown)
93
+ document.addEventListener('keydown', onKeyDown)
94
+ return () => {
95
+ document.removeEventListener('mousedown', onMouseDown)
96
+ document.removeEventListener('keydown', onKeyDown)
97
+ }
98
+ }, [state.isOpen])
99
+
100
+ if (!state.isOpen || !state.cursorPos) return null
101
+
102
+ const position = clampPanelPosition(state.cursorPos, PANEL_WIDTH)
103
+ const fieldLabel = (state.fieldName ?? 'reference')
104
+ .replace(/([A-Z])/g, ' $1')
105
+ .replace(/^./, s => s.toUpperCase())
106
+ .trim()
107
+
108
+ const selectedSet = useMemo(
109
+ () => new Set(state.isArray ? state.currentValues : (state.currentValue ? [state.currentValue] : [])),
110
+ [state.isArray, state.currentValues, state.currentValue],
111
+ )
112
+
113
+ return (
114
+ <div
115
+ ref={panelRef}
116
+ data-cms-ui
117
+ style={{ zIndex: Z_INDEX.MODAL, top: position.top, left: position.left, maxHeight: position.maxHeight }}
118
+ class="fixed w-80 bg-cms-dark rounded-cms-xl shadow-[0_8px_32px_rgba(0,0,0,0.4)] border border-white/10 font-sans overflow-hidden flex flex-col"
119
+ onMouseDown={(e: MouseEvent) => e.stopPropagation()}
120
+ onClick={(e: MouseEvent) => e.stopPropagation()}
121
+ >
122
+ {saving
123
+ ? (
124
+ <div class="flex items-center justify-center gap-2 px-4 py-6">
125
+ <span class="inline-block w-4 h-4 border-2 border-white/80 border-t-transparent rounded-full animate-spin" />
126
+ <span class="text-sm text-white/80">Updating...</span>
127
+ </div>
128
+ )
129
+ : (
130
+ <>
131
+ {/* Header */}
132
+ <div class="px-4 pt-3 pb-2">
133
+ <div class="text-xs text-white/50 font-medium mb-1">{fieldLabel}</div>
134
+ {currentLabel && !state.isArray && (
135
+ <div class="text-sm text-white/70 mb-2">
136
+ Current: <span class="text-white">{currentLabel}</span>
137
+ </div>
138
+ )}
139
+ <input
140
+ ref={inputRef}
141
+ type="text"
142
+ value={query}
143
+ placeholder="Search..."
144
+ onInput={(e) => setQuery((e.target as HTMLInputElement).value)}
145
+ autocomplete="off"
146
+ class="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-colors"
147
+ data-cms-ui
148
+ />
149
+ </div>
150
+
151
+ {/* Options list */}
152
+ <div class="overflow-y-auto max-h-64 px-2 pb-2">
153
+ {filtered.length === 0
154
+ ? <div class="px-2 py-3 text-xs text-white/40 text-center">No options found</div>
155
+ : filtered.map(opt => {
156
+ const isSelected = selectedSet.has(opt.value)
157
+ return (
158
+ <button
159
+ key={opt.value}
160
+ type="button"
161
+ onMouseDown={(e) => {
162
+ e.preventDefault()
163
+ if (state.isArray) {
164
+ handleArrayToggle(opt.value)
165
+ } else {
166
+ handleSelect(opt.value)
167
+ }
168
+ }}
169
+ class={`w-full text-left px-3 py-2 text-sm rounded-cms-sm transition-colors cursor-pointer flex items-center gap-2 ${
170
+ isSelected
171
+ ? 'bg-cms-primary/15 text-white'
172
+ : 'text-white/70 hover:bg-white/10 hover:text-white'
173
+ }`}
174
+ data-cms-ui
175
+ >
176
+ {state.isArray && (
177
+ <span
178
+ class={`w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors ${
179
+ isSelected ? 'bg-cms-primary border-cms-primary' : 'border-white/30 bg-white/5'
180
+ }`}
181
+ >
182
+ {isSelected && (
183
+ <svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
184
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
185
+ </svg>
186
+ )}
187
+ </span>
188
+ )}
189
+ <span class="truncate">{opt.label}</span>
190
+ {isSelected && !state.isArray && (
191
+ <svg class="w-4 h-4 ml-auto text-cms-primary shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
192
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
193
+ </svg>
194
+ )}
195
+ </button>
196
+ )
197
+ })}
198
+ </div>
199
+ </>
200
+ )}
201
+ </div>
202
+ )
203
+ }