@nuasite/cms 0.39.2 → 0.41.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 (37) hide show
  1. package/dist/editor.js +15910 -15027
  2. package/package.json +1 -1
  3. package/src/collection-scanner.ts +127 -13
  4. package/src/content-config-ast.ts +91 -24
  5. package/src/editor/components/attribute-editor.tsx +0 -1
  6. package/src/editor/components/bg-image-overlay.tsx +7 -8
  7. package/src/editor/components/block-editor.tsx +12 -12
  8. package/src/editor/components/collections-browser.tsx +10 -10
  9. package/src/editor/components/create-page-modal.tsx +18 -18
  10. package/src/editor/components/delete-page-dialog.tsx +4 -3
  11. package/src/editor/components/field-utils.ts +54 -0
  12. package/src/editor/components/fields.tsx +516 -73
  13. package/src/editor/components/frontmatter-fields.tsx +188 -55
  14. package/src/editor/components/frontmatter-sidebar.tsx +56 -58
  15. package/src/editor/components/link-edit-popover.tsx +10 -5
  16. package/src/editor/components/markdown-editor-overlay.tsx +100 -39
  17. package/src/editor/components/markdown-inline-editor.tsx +58 -26
  18. package/src/editor/components/mdx-block-view.tsx +4 -4
  19. package/src/editor/components/mdx-component-picker.tsx +2 -2
  20. package/src/editor/components/media-library.tsx +19 -18
  21. package/src/editor/components/modal-shell.tsx +16 -3
  22. package/src/editor/components/prop-editor.tsx +15 -18
  23. package/src/editor/components/redirects-manager.tsx +42 -35
  24. package/src/editor/components/reference-picker.tsx +5 -4
  25. package/src/editor/components/seo-editor.tsx +36 -27
  26. package/src/editor/components/toolbar.tsx +50 -33
  27. package/src/editor/dom.ts +13 -2
  28. package/src/editor/editor.ts +7 -6
  29. package/src/editor/hooks/useBlockEditorHandlers.ts +7 -6
  30. package/src/editor/index.tsx +7 -6
  31. package/src/editor/signals.ts +44 -13
  32. package/src/editor/strings.ts +123 -0
  33. package/src/editor/styles.css +75 -2
  34. package/src/editor/types.ts +8 -0
  35. package/src/field-types.ts +15 -0
  36. package/src/index.ts +6 -0
  37. package/src/types.ts +7 -0
@@ -1,18 +1,34 @@
1
1
  import type { ComponentChildren } from 'preact'
2
+ import { createPortal } from 'preact/compat'
2
3
  import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
3
4
  import { getDropdownPosition } from '../constants'
4
5
  import { useClickOutsideEscape } from '../hooks/useClickOutsideEscape'
5
6
  import { useSearchFilter } from '../hooks/useSearchFilter'
6
7
  import { cn } from '../lib/cn'
8
+ import { uploadMedia } from '../markdown-api'
9
+ import { config, showToast } from '../signals'
10
+ import { STRINGS } from '../strings'
7
11
 
8
12
  // ============================================================================
9
13
  // Field Label
10
14
  // ============================================================================
11
15
 
12
- export function FieldLabel({ label, isDirty, onReset }: { label: string; isDirty?: boolean; onReset?: () => void }) {
16
+ export function FieldLabel({ label, isDirty, onReset, tooltip }: { label: string; isDirty?: boolean; onReset?: () => void; tooltip?: string }) {
13
17
  return (
14
18
  <div class="flex items-center justify-between">
15
- <label class="text-xs font-medium text-white/70">{label}</label>
19
+ <div class="flex items-center gap-1.5">
20
+ <label class="text-xs font-medium text-white/70">{label}</label>
21
+ {tooltip && (
22
+ <span class="relative group/tt inline-flex" data-cms-ui>
23
+ <svg class="w-3.5 h-3.5 text-white/40 hover:text-white/70 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
24
+ <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" />
25
+ </svg>
26
+ <span class="absolute left-0 top-full mt-1 w-64 p-2 bg-black/90 text-white text-xs rounded-cms-sm opacity-0 invisible group-hover/tt:opacity-100 group-hover/tt:visible transition-all z-50 pointer-events-none whitespace-normal">
27
+ {tooltip}
28
+ </span>
29
+ </span>
30
+ )}
31
+ </div>
16
32
  {isDirty && (
17
33
  <div class="flex items-center gap-1.5">
18
34
  <span class="text-xs text-cms-primary font-medium">Modified</span>
@@ -50,14 +66,15 @@ export interface TextFieldProps {
50
66
  onReset?: () => void
51
67
  inputType?: string
52
68
  required?: boolean
69
+ tooltip?: string
53
70
  }
54
71
 
55
72
  export function TextField(
56
- { label, value, placeholder, maxLength, minLength, onChange, isDirty, onReset, inputType = 'text', required }: TextFieldProps,
73
+ { label, value, placeholder, maxLength, minLength, onChange, isDirty, onReset, inputType = 'text', required, tooltip }: TextFieldProps,
57
74
  ) {
58
75
  return (
59
76
  <div class="space-y-1.5">
60
- <FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
77
+ <FieldLabel label={label} isDirty={isDirty} onReset={onReset} tooltip={tooltip} />
61
78
  <input
62
79
  type={inputType}
63
80
  value={value ?? ''}
@@ -69,7 +86,7 @@ export function TextField(
69
86
  class={cn(
70
87
  'w-full px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
71
88
  isDirty
72
- ? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
89
+ ? 'border-white/30 focus:border-white/40 focus:ring-white/10'
73
90
  : 'border-white/20 focus:border-white/40 focus:ring-white/10',
74
91
  )}
75
92
  data-cms-ui
@@ -79,67 +96,431 @@ export function TextField(
79
96
  }
80
97
 
81
98
  // ============================================================================
82
- // Image Field (text input + Browse button)
99
+ // Image Field — drop-zone preview (click/drag to upload) + "Choose from library" link
83
100
  // ============================================================================
84
101
 
85
102
  export interface ImageFieldProps {
86
103
  label: string
87
104
  value: string | undefined
88
- placeholder?: string
89
105
  onChange: (value: string) => void
90
106
  onBrowse: () => void
91
107
  isDirty?: boolean
92
108
  onReset?: () => void
93
- required?: boolean
94
109
  }
95
110
 
96
- export function ImageField({ label, value, placeholder, onChange, onBrowse, isDirty, onReset, required }: ImageFieldProps) {
111
+ export function ImageField({ label, value, onChange, onBrowse, isDirty, onReset }: ImageFieldProps) {
97
112
  const hasImage = !!value && value.length > 0
113
+ const fileInputRef = useRef<HTMLInputElement>(null)
114
+ const [isUploading, setIsUploading] = useState(false)
115
+ const [isDragOver, setIsDragOver] = useState(false)
116
+ // Track the src that failed so the fallback resets automatically when `value` changes
117
+ const [failedSrc, setFailedSrc] = useState<string | null>(null)
118
+ const showFallback = hasImage && failedSrc === value
119
+
120
+ const handleUploadClick = useCallback(() => {
121
+ fileInputRef.current?.click()
122
+ }, [])
123
+
124
+ const uploadFile = useCallback(async (file: File) => {
125
+ const cfg = config.value
126
+ if (!cfg) {
127
+ showToast(STRINGS.media.notConfigured, 'error')
128
+ return
129
+ }
130
+ setIsUploading(true)
131
+ try {
132
+ const result = await uploadMedia(cfg, file)
133
+ if (result.success && result.url) {
134
+ onChange(result.url)
135
+ showToast(STRINGS.media.fileUploaded, 'success')
136
+ } else {
137
+ showToast(result.error || STRINGS.media.uploadFailed, 'error')
138
+ }
139
+ } catch {
140
+ showToast(STRINGS.media.uploadFailed, 'error')
141
+ } finally {
142
+ setIsUploading(false)
143
+ }
144
+ }, [onChange])
145
+
146
+ const handleFileChange = useCallback(async (e: Event) => {
147
+ const target = e.target as HTMLInputElement
148
+ const file = target.files?.[0]
149
+ if (file) await uploadFile(file)
150
+ target.value = ''
151
+ }, [uploadFile])
152
+
153
+ const handleDragOver = useCallback((e: DragEvent) => {
154
+ e.preventDefault()
155
+ if (e.dataTransfer?.types.includes('Files')) setIsDragOver(true)
156
+ }, [])
157
+
158
+ const handleDragLeave = useCallback((e: DragEvent) => {
159
+ // Only clear on actually leaving the drop-zone, not crossing child boundaries
160
+ if ((e.currentTarget as Node).contains(e.relatedTarget as Node | null)) return
161
+ setIsDragOver(false)
162
+ }, [])
163
+
164
+ const handleDrop = useCallback(async (e: DragEvent) => {
165
+ e.preventDefault()
166
+ setIsDragOver(false)
167
+ const file = e.dataTransfer?.files?.[0]
168
+ if (file && file.type.startsWith('image/')) await uploadFile(file)
169
+ }, [uploadFile])
170
+
171
+ const containerClass = cn(
172
+ 'relative w-full rounded-cms-sm overflow-hidden bg-white/5 border group transition-colors focus:outline-none focus:ring-1 focus:ring-white/30',
173
+ isUploading ? 'cursor-wait' : 'cursor-pointer',
174
+ isDragOver ? 'border-cms-primary bg-cms-primary/10' : 'border-white/10 hover:border-white/20',
175
+ )
176
+ const overlayHint = isUploading ? 'Uploading…' : isDragOver ? 'Drop to upload' : hasImage ? 'Click to view' : 'Click or drop file'
98
177
 
99
178
  return (
100
- <div class="space-y-1.5 min-w-0">
179
+ <div class="space-y-2 min-w-0">
101
180
  <FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
102
- {hasImage && (
103
- <div
104
- class="relative w-full rounded-cms-sm overflow-hidden bg-white/5 border border-white/10 cursor-pointer group"
181
+ <input
182
+ ref={fileInputRef}
183
+ type="file"
184
+ accept="image/*"
185
+ class="hidden"
186
+ onChange={handleFileChange}
187
+ data-cms-ui
188
+ />
189
+ <div class="w-full max-w-sm space-y-2">
190
+ {hasImage
191
+ ? (
192
+ <a
193
+ href={value}
194
+ target="_blank"
195
+ rel="noopener noreferrer"
196
+ aria-label={`Open ${label.toLowerCase()} in new tab`}
197
+ onDragOver={handleDragOver}
198
+ onDragEnter={handleDragOver}
199
+ onDragLeave={handleDragLeave}
200
+ onDrop={handleDrop}
201
+ class={cn(containerClass, 'block')}
202
+ data-cms-ui
203
+ >
204
+ {!showFallback
205
+ ? (
206
+ <>
207
+ <img
208
+ src={value}
209
+ alt={label}
210
+ class="w-full h-32 object-contain"
211
+ onError={() => setFailedSrc(value ?? null)}
212
+ />
213
+ <div class="absolute inset-x-0 bottom-0 px-2 py-1.5 bg-linear-to-t from-black/80 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10">
214
+ <span class="block text-white text-[11px] font-medium truncate" title={value}>
215
+ {value}
216
+ </span>
217
+ </div>
218
+ </>
219
+ )
220
+ : (
221
+ <div class="w-full h-32 flex flex-col items-center justify-center gap-1 text-white/40">
222
+ <svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
223
+ <path
224
+ stroke-linecap="round"
225
+ stroke-linejoin="round"
226
+ 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"
227
+ />
228
+ </svg>
229
+ <span class="text-[11px] font-medium" title={value}>Image failed to load</span>
230
+ </div>
231
+ )}
232
+ <div class="absolute top-1.5 right-1.5 flex gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity z-20">
233
+ <button
234
+ type="button"
235
+ onClick={(e) => {
236
+ e.preventDefault()
237
+ e.stopPropagation()
238
+ handleUploadClick()
239
+ }}
240
+ class="w-6 h-6 flex items-center justify-center bg-black/60 hover:bg-white/20 text-white rounded-cms-xs transition-colors cursor-pointer"
241
+ title="Replace image"
242
+ data-cms-ui
243
+ >
244
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
245
+ <path stroke-linecap="round" stroke-linejoin="round" d="M4 4v6h6M20 20v-6h-6M4 10a8 8 0 0114-5M20 14a8 8 0 01-14 5" />
246
+ </svg>
247
+ </button>
248
+ <button
249
+ type="button"
250
+ onClick={(e) => {
251
+ e.preventDefault()
252
+ e.stopPropagation()
253
+ onChange('')
254
+ }}
255
+ class="w-6 h-6 flex items-center justify-center bg-black/60 hover:bg-red-500/80 text-white rounded-cms-xs transition-colors cursor-pointer"
256
+ title="Remove image from this field"
257
+ data-cms-ui
258
+ >
259
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
260
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
261
+ </svg>
262
+ </button>
263
+ </div>
264
+ {/* Hover overlay — decorative hint (pointer-events-none) */}
265
+ <div class="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/40 transition-colors pointer-events-none">
266
+ <span class="text-white/90 text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity">{overlayHint}</span>
267
+ </div>
268
+ </a>
269
+ )
270
+ : (
271
+ <div
272
+ role="button"
273
+ tabIndex={isUploading ? -1 : 0}
274
+ aria-label="Upload image — click or drop a file"
275
+ aria-busy={isUploading}
276
+ onClick={isUploading ? undefined : handleUploadClick}
277
+ onKeyDown={(e) => {
278
+ if (isUploading) return
279
+ if (e.key === 'Enter' || e.key === ' ') {
280
+ e.preventDefault()
281
+ handleUploadClick()
282
+ }
283
+ }}
284
+ onDragOver={handleDragOver}
285
+ onDragEnter={handleDragOver}
286
+ onDragLeave={handleDragLeave}
287
+ onDrop={handleDrop}
288
+ class={containerClass}
289
+ data-cms-ui
290
+ >
291
+ <div class="w-full h-32 flex flex-col items-center justify-center gap-1 text-white/25 group-hover:text-white/40 transition-colors">
292
+ <svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
293
+ <path
294
+ stroke-linecap="round"
295
+ stroke-linejoin="round"
296
+ 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"
297
+ />
298
+ </svg>
299
+ </div>
300
+ <div class="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/40 transition-colors pointer-events-none">
301
+ <span class="text-white/90 text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity">{overlayHint}</span>
302
+ </div>
303
+ </div>
304
+ )}
305
+ <button
306
+ type="button"
105
307
  onClick={onBrowse}
308
+ class="block text-xs text-white/50 hover:text-white underline decoration-white/20 hover:decoration-white underline-offset-2 transition-colors cursor-pointer"
106
309
  data-cms-ui
107
310
  >
108
- <img
109
- src={value}
110
- alt={label}
111
- class="w-full h-auto max-h-48"
112
- onError={(e) => {
113
- ;(e.target as HTMLImageElement).style.display = 'none'
114
- }}
115
- />
116
- <div class="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
117
- <span class="text-white text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity">Change</span>
118
- </div>
119
- </div>
120
- )}
121
- <div class="flex gap-2 min-w-0">
122
- <input
123
- type="text"
124
- value={value ?? ''}
125
- placeholder={placeholder}
126
- required={required}
127
- onInput={(e) => onChange((e.target as HTMLInputElement).value)}
128
- class={cn(
129
- 'flex-1 min-w-0 px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
130
- isDirty
131
- ? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
132
- : 'border-white/20 focus:border-white/40 focus:ring-white/10',
311
+ Choose from library
312
+ </button>
313
+ </div>
314
+ </div>
315
+ )
316
+ }
317
+
318
+ // ============================================================================
319
+ // File Field generic file picker (PDF, docs, etc.) with drop-zone + library
320
+ // ============================================================================
321
+
322
+ export interface FileFieldProps {
323
+ label: string
324
+ value: string | undefined
325
+ onChange: (value: string) => void
326
+ onBrowse: () => void
327
+ accept?: string
328
+ isDirty?: boolean
329
+ onReset?: () => void
330
+ }
331
+
332
+ function getFileBasename(url: string): string {
333
+ const clean = url.split('?')[0]?.split('#')[0] ?? url
334
+ const last = clean.split('/').pop() ?? clean
335
+ try {
336
+ return decodeURIComponent(last) || last
337
+ } catch {
338
+ return last
339
+ }
340
+ }
341
+
342
+ export function FileField({ label, value, onChange, onBrowse, accept, isDirty, onReset }: FileFieldProps) {
343
+ const hasFile = !!value && value.length > 0
344
+ const fileInputRef = useRef<HTMLInputElement>(null)
345
+ const [isUploading, setIsUploading] = useState(false)
346
+ const [isDragOver, setIsDragOver] = useState(false)
347
+
348
+ const handleUploadClick = useCallback(() => {
349
+ fileInputRef.current?.click()
350
+ }, [])
351
+
352
+ const uploadFile = useCallback(async (file: File) => {
353
+ const cfg = config.value
354
+ if (!cfg) {
355
+ showToast(STRINGS.media.notConfigured, 'error')
356
+ return
357
+ }
358
+ setIsUploading(true)
359
+ try {
360
+ const result = await uploadMedia(cfg, file)
361
+ if (result.success && result.url) {
362
+ onChange(result.url)
363
+ showToast(STRINGS.media.fileUploaded, 'success')
364
+ } else {
365
+ showToast(result.error || STRINGS.media.uploadFailed, 'error')
366
+ }
367
+ } catch {
368
+ showToast(STRINGS.media.uploadFailed, 'error')
369
+ } finally {
370
+ setIsUploading(false)
371
+ }
372
+ }, [onChange])
373
+
374
+ const handleFileChange = useCallback(async (e: Event) => {
375
+ const target = e.target as HTMLInputElement
376
+ const file = target.files?.[0]
377
+ if (file) await uploadFile(file)
378
+ target.value = ''
379
+ }, [uploadFile])
380
+
381
+ const handleDragOver = useCallback((e: DragEvent) => {
382
+ e.preventDefault()
383
+ if (e.dataTransfer?.types.includes('Files')) setIsDragOver(true)
384
+ }, [])
385
+
386
+ const handleDragLeave = useCallback((e: DragEvent) => {
387
+ if ((e.currentTarget as Node).contains(e.relatedTarget as Node | null)) return
388
+ setIsDragOver(false)
389
+ }, [])
390
+
391
+ const handleDrop = useCallback(async (e: DragEvent) => {
392
+ e.preventDefault()
393
+ setIsDragOver(false)
394
+ const file = e.dataTransfer?.files?.[0]
395
+ if (file) await uploadFile(file)
396
+ }, [uploadFile])
397
+
398
+ const basename = hasFile ? getFileBasename(value) : ''
399
+ const containerClass = cn(
400
+ 'relative w-full rounded-cms-sm overflow-hidden bg-white/5 border group transition-colors focus:outline-none focus:ring-1 focus:ring-white/30',
401
+ isUploading ? 'cursor-wait' : 'cursor-pointer',
402
+ isDragOver ? 'border-cms-primary bg-cms-primary/10' : 'border-white/10 hover:border-white/20',
403
+ )
404
+ const overlayHint = isUploading ? 'Uploading…' : isDragOver ? 'Drop to upload' : hasFile ? 'Click to view' : 'Click or drop file'
405
+
406
+ return (
407
+ <div class="space-y-2 min-w-0">
408
+ <FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
409
+ <input
410
+ ref={fileInputRef}
411
+ type="file"
412
+ accept={accept}
413
+ class="hidden"
414
+ onChange={handleFileChange}
415
+ data-cms-ui
416
+ />
417
+ <div class="w-full max-w-sm space-y-2">
418
+ {hasFile
419
+ ? (
420
+ <a
421
+ href={value}
422
+ target="_blank"
423
+ rel="noopener noreferrer"
424
+ aria-label={`Open ${basename || 'file'} in new tab`}
425
+ onDragOver={handleDragOver}
426
+ onDragEnter={handleDragOver}
427
+ onDragLeave={handleDragLeave}
428
+ onDrop={handleDrop}
429
+ class={cn(containerClass, 'block')}
430
+ data-cms-ui
431
+ >
432
+ <div class="w-full h-20 flex items-center gap-3 px-3 text-white/80">
433
+ <svg class="w-8 h-8 flex-shrink-0 text-white/50" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
434
+ <path
435
+ stroke-linecap="round"
436
+ stroke-linejoin="round"
437
+ d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
438
+ />
439
+ </svg>
440
+ <div class="flex-1 min-w-0">
441
+ <div class="text-sm font-medium truncate" title={basename}>{basename}</div>
442
+ <div class="text-[11px] text-white/40 truncate" title={value}>{value}</div>
443
+ </div>
444
+ </div>
445
+ <div class="absolute top-1.5 right-1.5 flex gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity z-20">
446
+ <button
447
+ type="button"
448
+ onClick={(e) => {
449
+ e.preventDefault()
450
+ e.stopPropagation()
451
+ handleUploadClick()
452
+ }}
453
+ class="w-6 h-6 flex items-center justify-center bg-black/60 hover:bg-white/20 text-white rounded-cms-xs transition-colors cursor-pointer"
454
+ title="Replace file"
455
+ data-cms-ui
456
+ >
457
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
458
+ <path stroke-linecap="round" stroke-linejoin="round" d="M4 4v6h6M20 20v-6h-6M4 10a8 8 0 0114-5M20 14a8 8 0 01-14 5" />
459
+ </svg>
460
+ </button>
461
+ <button
462
+ type="button"
463
+ onClick={(e) => {
464
+ e.preventDefault()
465
+ e.stopPropagation()
466
+ onChange('')
467
+ }}
468
+ class="w-6 h-6 flex items-center justify-center bg-black/60 hover:bg-red-500/80 text-white rounded-cms-xs transition-colors cursor-pointer"
469
+ title="Remove file from this field"
470
+ data-cms-ui
471
+ >
472
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
473
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
474
+ </svg>
475
+ </button>
476
+ </div>
477
+ <div class="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/40 transition-colors pointer-events-none">
478
+ <span class="text-white/90 text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity">{overlayHint}</span>
479
+ </div>
480
+ </a>
481
+ )
482
+ : (
483
+ <div
484
+ role="button"
485
+ tabIndex={isUploading ? -1 : 0}
486
+ aria-label="Upload file — click or drop a file"
487
+ aria-busy={isUploading}
488
+ onClick={isUploading ? undefined : handleUploadClick}
489
+ onKeyDown={(e) => {
490
+ if (isUploading) return
491
+ if (e.key === 'Enter' || e.key === ' ') {
492
+ e.preventDefault()
493
+ handleUploadClick()
494
+ }
495
+ }}
496
+ onDragOver={handleDragOver}
497
+ onDragEnter={handleDragOver}
498
+ onDragLeave={handleDragLeave}
499
+ onDrop={handleDrop}
500
+ class={containerClass}
501
+ data-cms-ui
502
+ >
503
+ <div class="w-full h-20 flex flex-col items-center justify-center gap-1 text-white/25 group-hover:text-white/40 transition-colors">
504
+ <svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
505
+ <path
506
+ stroke-linecap="round"
507
+ stroke-linejoin="round"
508
+ d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
509
+ />
510
+ </svg>
511
+ </div>
512
+ <div class="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/40 transition-colors pointer-events-none">
513
+ <span class="text-white/90 text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity">{overlayHint}</span>
514
+ </div>
515
+ </div>
133
516
  )}
134
- data-cms-ui
135
- />
136
517
  <button
137
518
  type="button"
138
519
  onClick={onBrowse}
139
- class="shrink-0 px-3 py-2 bg-white/10 hover:bg-white/20 border border-white/20 rounded-cms-sm text-sm text-white transition-colors cursor-pointer"
520
+ class="block text-xs text-white/50 hover:text-white underline decoration-white/20 hover:decoration-white underline-offset-2 transition-colors cursor-pointer"
140
521
  data-cms-ui
141
522
  >
142
- Browse
523
+ Choose from library
143
524
  </button>
144
525
  </div>
145
526
  </div>
@@ -186,7 +567,7 @@ export function ColorField({ label, value, placeholder, onChange, isDirty, onRes
186
567
  class={cn(
187
568
  'flex-1 px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
188
569
  isDirty
189
- ? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
570
+ ? 'border-white/30 focus:border-white/40 focus:ring-white/10'
190
571
  : 'border-white/20 focus:border-white/40 focus:ring-white/10',
191
572
  )}
192
573
  data-cms-ui
@@ -212,7 +593,7 @@ export interface SelectFieldProps {
212
593
 
213
594
  export function SelectField({ label, value, options, onChange, isDirty, onReset, allowEmpty = true }: SelectFieldProps) {
214
595
  return (
215
- <div class="space-y-1.5">
596
+ <div class="space-y-2">
216
597
  <FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
217
598
  <select
218
599
  value={value ?? ''}
@@ -220,7 +601,7 @@ export function SelectField({ label, value, options, onChange, isDirty, onReset,
220
601
  class={cn(
221
602
  'w-full px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white focus:outline-none focus:ring-1 transition-colors cursor-pointer',
222
603
  isDirty
223
- ? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
604
+ ? 'border-white/30 focus:border-white/40 focus:ring-white/10'
224
605
  : 'border-white/20 focus:border-white/40 focus:ring-white/10',
225
606
  )}
226
607
  data-cms-ui
@@ -267,9 +648,12 @@ export function ToggleField({ label, value, onChange, isDirty, onReset }: Toggle
267
648
  >
268
649
  <span
269
650
  class={cn(
270
- 'absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform shadow-sm pointer-events-none',
271
- isOn && 'translate-x-4',
651
+ 'absolute top-0.5 left-0.5 w-4 h-4 rounded-full shadow-sm pointer-events-none',
652
+ isOn ? 'translate-x-4 bg-[#404040]' : 'translate-x-0 bg-white',
272
653
  )}
654
+ style={{
655
+ transition: 'transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1), background-color 200ms ease-out',
656
+ }}
273
657
  />
274
658
  </button>
275
659
  </div>
@@ -294,29 +678,63 @@ export interface NumberFieldProps {
294
678
  }
295
679
 
296
680
  export function NumberField({ label, value, placeholder, min, max, step, onChange, isDirty, onReset, required }: NumberFieldProps) {
681
+ const stepValue = step ?? 1
682
+ const adjust = (delta: number) => {
683
+ const current = typeof value === 'number' ? value : 0
684
+ let next = current + delta * stepValue
685
+ if (typeof min === 'number' && next < min) next = min
686
+ if (typeof max === 'number' && next > max) next = max
687
+ onChange(next)
688
+ }
297
689
  return (
298
690
  <div class="space-y-1.5">
299
691
  <FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
300
- <input
301
- type="number"
302
- value={value ?? ''}
303
- placeholder={placeholder}
304
- min={min}
305
- max={max}
306
- step={step}
307
- required={required}
308
- onInput={(e) => {
309
- const val = (e.target as HTMLInputElement).value
310
- onChange(val === '' ? undefined : Number(val))
311
- }}
312
- class={cn(
313
- 'w-full px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
314
- isDirty
315
- ? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
316
- : 'border-white/20 focus:border-white/40 focus:ring-white/10',
317
- )}
318
- data-cms-ui
319
- />
692
+ <div class="relative">
693
+ <input
694
+ type="number"
695
+ value={value ?? ''}
696
+ placeholder={placeholder}
697
+ min={min}
698
+ max={max}
699
+ step={step}
700
+ required={required}
701
+ onInput={(e) => {
702
+ const val = (e.target as HTMLInputElement).value
703
+ onChange(val === '' ? undefined : Number(val))
704
+ }}
705
+ class={cn(
706
+ 'w-full pl-3 pr-12 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
707
+ isDirty
708
+ ? 'border-white/30 focus:border-white/40 focus:ring-white/10'
709
+ : 'border-white/20 focus:border-white/40 focus:ring-white/10',
710
+ )}
711
+ data-cms-ui
712
+ />
713
+ <div class="absolute right-1.5 top-1/2 -translate-y-1/2 flex gap-[3px]">
714
+ <button
715
+ type="button"
716
+ onClick={() => adjust(-1)}
717
+ class="w-5 h-5 flex items-center justify-center bg-cms-primary hover:bg-cms-primary-hover text-cms-dark rounded-cms-xs transition-colors cursor-pointer"
718
+ title="Decrease"
719
+ data-cms-ui
720
+ >
721
+ <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3">
722
+ <path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" />
723
+ </svg>
724
+ </button>
725
+ <button
726
+ type="button"
727
+ onClick={() => adjust(1)}
728
+ class="w-5 h-5 flex items-center justify-center bg-cms-primary hover:bg-cms-primary-hover text-cms-dark rounded-cms-xs transition-colors cursor-pointer"
729
+ title="Increase"
730
+ data-cms-ui
731
+ >
732
+ <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3">
733
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" />
734
+ </svg>
735
+ </button>
736
+ </div>
737
+ </div>
320
738
  </div>
321
739
  )
322
740
  }
@@ -360,6 +778,13 @@ export interface DropdownPanelProps {
360
778
  * Fixed-position dropdown container that escapes parent overflow clipping.
361
779
  * Handles outside-click and Escape-key dismissal.
362
780
  */
781
+ function getCmsPortalTarget(): HTMLElement | null {
782
+ if (typeof document === 'undefined') return null
783
+ const host = document.getElementById('cms-app-host')
784
+ if (!host?.shadowRoot) return null
785
+ return host.shadowRoot.querySelector('.cms-root') as HTMLElement | null
786
+ }
787
+
363
788
  export function DropdownPanel({ triggerRef, isOpen, onClose, maxHeight = 192, children, className, panelRef, exemptRefs }: DropdownPanelProps) {
364
789
  const internalRef = useRef<HTMLDivElement>(null)
365
790
  const ref = panelRef ?? internalRef
@@ -368,16 +793,34 @@ export function DropdownPanel({ triggerRef, isOpen, onClose, maxHeight = 192, ch
368
793
 
369
794
  if (!isOpen) return null
370
795
 
371
- return (
796
+ const dropdown = (
372
797
  <div
373
798
  ref={ref}
374
- class={cn('overflow-y-auto bg-cms-dark shadow-lg', className)}
799
+ class={cn('flex flex-col bg-cms-dark shadow-lg', className)}
375
800
  style={getDropdownPosition(triggerRef.current, maxHeight)}
376
801
  data-cms-ui
377
802
  >
378
- {children}
803
+ <div class="flex justify-end p-1 shrink-0 border-b border-white/5">
804
+ <button
805
+ type="button"
806
+ onClick={onClose}
807
+ class="w-5 h-5 flex items-center justify-center text-white/40 hover:text-white hover:bg-white/10 rounded-cms-xs transition-colors"
808
+ title="Close"
809
+ data-cms-ui
810
+ >
811
+ <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
812
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
813
+ </svg>
814
+ </button>
815
+ </div>
816
+ <div class="overflow-y-auto flex-1 min-h-0">
817
+ {children}
818
+ </div>
379
819
  </div>
380
820
  )
821
+
822
+ const portalTarget = getCmsPortalTarget()
823
+ return portalTarget ? createPortal(dropdown, portalTarget) : dropdown
381
824
  }
382
825
 
383
826
  // ============================================================================
@@ -465,7 +908,7 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
465
908
  class={cn(
466
909
  'w-full px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
467
910
  isDirty
468
- ? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
911
+ ? 'border-white/30 focus:border-white/40 focus:ring-white/10'
469
912
  : 'border-white/20 focus:border-white/40 focus:ring-white/10',
470
913
  )}
471
914
  data-cms-ui
@@ -569,7 +1012,7 @@ export function MultiSelectField({ label, selected, options, onChange, isDirty,
569
1012
  <button
570
1013
  type="button"
571
1014
  onClick={() => toggleOption(val)}
572
- class="text-cms-primary/60 hover:text-cms-primary transition-colors cursor-pointer"
1015
+ class="text-cms-primary/60 hover:text-red-400 transition-colors cursor-pointer"
573
1016
  data-cms-ui
574
1017
  >
575
1018
  <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -634,7 +1077,7 @@ export function MultiSelectField({ label, selected, options, onChange, isDirty,
634
1077
  )}
635
1078
  >
636
1079
  {isSelected && (
637
- <svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1080
+ <svg class="w-3 h-3 text-cms-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
638
1081
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
639
1082
  </svg>
640
1083
  )}