@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.
- package/dist/editor.js +15910 -15027
- package/package.json +1 -1
- package/src/collection-scanner.ts +127 -13
- package/src/content-config-ast.ts +91 -24
- package/src/editor/components/attribute-editor.tsx +0 -1
- package/src/editor/components/bg-image-overlay.tsx +7 -8
- package/src/editor/components/block-editor.tsx +12 -12
- package/src/editor/components/collections-browser.tsx +10 -10
- package/src/editor/components/create-page-modal.tsx +18 -18
- package/src/editor/components/delete-page-dialog.tsx +4 -3
- package/src/editor/components/field-utils.ts +54 -0
- package/src/editor/components/fields.tsx +516 -73
- package/src/editor/components/frontmatter-fields.tsx +188 -55
- package/src/editor/components/frontmatter-sidebar.tsx +56 -58
- package/src/editor/components/link-edit-popover.tsx +10 -5
- package/src/editor/components/markdown-editor-overlay.tsx +100 -39
- package/src/editor/components/markdown-inline-editor.tsx +58 -26
- package/src/editor/components/mdx-block-view.tsx +4 -4
- package/src/editor/components/mdx-component-picker.tsx +2 -2
- package/src/editor/components/media-library.tsx +19 -18
- package/src/editor/components/modal-shell.tsx +16 -3
- package/src/editor/components/prop-editor.tsx +15 -18
- package/src/editor/components/redirects-manager.tsx +42 -35
- package/src/editor/components/reference-picker.tsx +5 -4
- package/src/editor/components/seo-editor.tsx +36 -27
- package/src/editor/components/toolbar.tsx +50 -33
- package/src/editor/dom.ts +13 -2
- package/src/editor/editor.ts +7 -6
- package/src/editor/hooks/useBlockEditorHandlers.ts +7 -6
- package/src/editor/index.tsx +7 -6
- package/src/editor/signals.ts +44 -13
- package/src/editor/strings.ts +123 -0
- package/src/editor/styles.css +75 -2
- package/src/editor/types.ts +8 -0
- package/src/field-types.ts +15 -0
- package/src/index.ts +6 -0
- 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
|
-
<
|
|
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-
|
|
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 (
|
|
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,
|
|
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-
|
|
179
|
+
<div class="space-y-2 min-w-0">
|
|
101
180
|
<FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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="
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
271
|
-
isOn
|
|
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
|
-
<
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
796
|
+
const dropdown = (
|
|
372
797
|
<div
|
|
373
798
|
ref={ref}
|
|
374
|
-
class={cn('
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
)}
|