@nuasite/cms 0.40.0 → 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 +7728 -7482
- package/package.json +1 -1
- package/src/collection-scanner.ts +78 -11
- package/src/content-config-ast.ts +91 -24
- package/src/editor/components/fields.tsx +313 -52
- package/src/editor/components/frontmatter-fields.tsx +54 -2
- package/src/editor/components/frontmatter-sidebar.tsx +1 -0
- package/src/field-types.ts +15 -0
- package/src/types.ts +3 -0
|
@@ -168,6 +168,13 @@ export function ImageField({ label, value, onChange, onBrowse, isDirty, onReset
|
|
|
168
168
|
if (file && file.type.startsWith('image/')) await uploadFile(file)
|
|
169
169
|
}, [uploadFile])
|
|
170
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'
|
|
177
|
+
|
|
171
178
|
return (
|
|
172
179
|
<div class="space-y-2 min-w-0">
|
|
173
180
|
<FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
|
|
@@ -180,51 +187,72 @@ export function ImageField({ label, value, onChange, onBrowse, isDirty, onReset
|
|
|
180
187
|
data-cms-ui
|
|
181
188
|
/>
|
|
182
189
|
<div class="w-full max-w-sm space-y-2">
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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>
|
|
221
248
|
<button
|
|
222
249
|
type="button"
|
|
223
250
|
onClick={(e) => {
|
|
251
|
+
e.preventDefault()
|
|
224
252
|
e.stopPropagation()
|
|
225
253
|
onChange('')
|
|
226
254
|
}}
|
|
227
|
-
class="
|
|
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"
|
|
228
256
|
title="Remove image from this field"
|
|
229
257
|
data-cms-ui
|
|
230
258
|
>
|
|
@@ -232,9 +260,34 @@ export function ImageField({ label, value, onChange, onBrowse, isDirty, onReset
|
|
|
232
260
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
233
261
|
</svg>
|
|
234
262
|
</button>
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
+
>
|
|
238
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">
|
|
239
292
|
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
|
240
293
|
<path
|
|
@@ -243,16 +296,224 @@ export function ImageField({ label, value, onChange, onBrowse, isDirty, onReset
|
|
|
243
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"
|
|
244
297
|
/>
|
|
245
298
|
</svg>
|
|
246
|
-
{showFallback && <span class="text-[11px] font-medium" title={value}>Image failed to load</span>}
|
|
247
299
|
</div>
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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"
|
|
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"
|
|
309
|
+
data-cms-ui
|
|
310
|
+
>
|
|
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>
|
|
516
|
+
)}
|
|
256
517
|
<button
|
|
257
518
|
type="button"
|
|
258
519
|
onClick={onBrowse}
|
|
@@ -449,7 +710,7 @@ export function NumberField({ label, value, placeholder, min, max, step, onChang
|
|
|
449
710
|
)}
|
|
450
711
|
data-cms-ui
|
|
451
712
|
/>
|
|
452
|
-
<div class="absolute right-1.5 top-1/
|
|
713
|
+
<div class="absolute right-1.5 top-1/2 -translate-y-1/2 flex gap-[3px]">
|
|
453
714
|
<button
|
|
454
715
|
type="button"
|
|
455
716
|
onClick={() => adjust(-1)}
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from '../signals'
|
|
15
15
|
import { STRINGS } from '../strings'
|
|
16
16
|
import type { CollectionDefinition, FieldDefinition, MarkdownPageEntry } from '../types'
|
|
17
|
-
import { ColorField, ComboBoxField, ImageField, MultiSelectField, NumberField, TextField, ToggleField } from './fields'
|
|
17
|
+
import { ColorField, ComboBoxField, FileField, ImageField, MultiSelectField, NumberField, TextField, ToggleField } from './fields'
|
|
18
18
|
import { groupFields } from './frontmatter-sidebar'
|
|
19
19
|
|
|
20
20
|
function isArrayOfObjects(value: unknown[]): value is Record<string, unknown>[] {
|
|
@@ -235,6 +235,7 @@ export function CreateModeFrontmatter({
|
|
|
235
235
|
onSlugManualEdit,
|
|
236
236
|
}: CreateModeFrontmatterProps) {
|
|
237
237
|
const allFields = fields ?? collectionDefinition.fields
|
|
238
|
+
const allFieldNames = new Set(allFields.map((f) => f.name))
|
|
238
239
|
const urlFieldNames = new Set(allFields.filter((f) => f.type === 'url' || /link|href|url/i.test(f.name)).map((f) => f.name))
|
|
239
240
|
const isOpenInNewTabSibling = (name: string) => {
|
|
240
241
|
if (!name.endsWith('OpenInNewTab')) return false
|
|
@@ -296,6 +297,7 @@ export function CreateModeFrontmatter({
|
|
|
296
297
|
onChange={(newValue) => updateMarkdownFrontmatter({ [field.name]: newValue })}
|
|
297
298
|
collection={collectionDefinition.name}
|
|
298
299
|
entrySlug={page.slug}
|
|
300
|
+
hasOpenInNewTabSibling={allFieldNames.has(`${field.name}OpenInNewTab`)}
|
|
299
301
|
/>
|
|
300
302
|
))}
|
|
301
303
|
</FieldGroupHeader>
|
|
@@ -389,6 +391,7 @@ export function EditModeFrontmatter({
|
|
|
389
391
|
fields,
|
|
390
392
|
}: EditModeFrontmatterProps) {
|
|
391
393
|
const allFields = fields ?? collectionDefinition?.fields ?? []
|
|
394
|
+
const allFieldNames = new Set(allFields.map((f) => f.name))
|
|
392
395
|
const urlFieldNames = new Set(allFields.filter((f) => f.type === 'url' || /link|href|url/i.test(f.name)).map((f) => f.name))
|
|
393
396
|
const isOpenInNewTabSibling = (name: string) => {
|
|
394
397
|
if (!name.endsWith('OpenInNewTab')) return false
|
|
@@ -425,6 +428,7 @@ export function EditModeFrontmatter({
|
|
|
425
428
|
onChange={(newValue) => updateMarkdownFrontmatter({ [field.name]: newValue })}
|
|
426
429
|
collection={collectionDefinition.name}
|
|
427
430
|
entrySlug={page.slug}
|
|
431
|
+
hasOpenInNewTabSibling={allFieldNames.has(`${field.name}OpenInNewTab`)}
|
|
428
432
|
/>
|
|
429
433
|
))}
|
|
430
434
|
</FieldGroupHeader>
|
|
@@ -467,6 +471,8 @@ interface SchemaFrontmatterFieldProps {
|
|
|
467
471
|
/** Required when editing an `astroImage` field — routes uploads to the entry's directory. */
|
|
468
472
|
collection?: string
|
|
469
473
|
entrySlug?: string
|
|
474
|
+
/** True when the schema declares a `${field.name}OpenInNewTab` companion boolean — controls toggle visibility next to URL fields. */
|
|
475
|
+
hasOpenInNewTabSibling?: boolean
|
|
470
476
|
}
|
|
471
477
|
|
|
472
478
|
export function SchemaFrontmatterField({
|
|
@@ -475,6 +481,7 @@ export function SchemaFrontmatterField({
|
|
|
475
481
|
onChange,
|
|
476
482
|
collection,
|
|
477
483
|
entrySlug,
|
|
484
|
+
hasOpenInNewTabSibling,
|
|
478
485
|
}: SchemaFrontmatterFieldProps) {
|
|
479
486
|
const label = field.required ? `${formatFieldLabel(field.name)} *` : formatFieldLabel(field.name)
|
|
480
487
|
const hints = field.hints
|
|
@@ -501,7 +508,7 @@ export function SchemaFrontmatterField({
|
|
|
501
508
|
required={field.required}
|
|
502
509
|
tooltip={linkTooltip}
|
|
503
510
|
/>
|
|
504
|
-
{field.type === 'url' && <OpenInNewTabToggle field={field} />}
|
|
511
|
+
{field.type === 'url' && hasOpenInNewTabSibling && <OpenInNewTabToggle field={field} />}
|
|
505
512
|
</>
|
|
506
513
|
)
|
|
507
514
|
}
|
|
@@ -522,6 +529,22 @@ export function SchemaFrontmatterField({
|
|
|
522
529
|
)
|
|
523
530
|
}
|
|
524
531
|
|
|
532
|
+
case 'file': {
|
|
533
|
+
return (
|
|
534
|
+
<FileField
|
|
535
|
+
label={label}
|
|
536
|
+
value={(value as string) ?? ''}
|
|
537
|
+
accept={hints?.accept as string | undefined}
|
|
538
|
+
onChange={(v) => onChange(v)}
|
|
539
|
+
onBrowse={() => {
|
|
540
|
+
openMediaLibraryWithCallback((url: string) => {
|
|
541
|
+
onChange(url)
|
|
542
|
+
})
|
|
543
|
+
}}
|
|
544
|
+
/>
|
|
545
|
+
)
|
|
546
|
+
}
|
|
547
|
+
|
|
525
548
|
case 'color':
|
|
526
549
|
return (
|
|
527
550
|
<ColorField
|
|
@@ -553,6 +576,7 @@ export function SchemaFrontmatterField({
|
|
|
553
576
|
case 'date':
|
|
554
577
|
case 'datetime':
|
|
555
578
|
case 'time':
|
|
579
|
+
case 'month':
|
|
556
580
|
return (
|
|
557
581
|
<div class="flex flex-col gap-1" data-cms-ui>
|
|
558
582
|
<label class="text-xs text-white/60 font-medium">{label}</label>
|
|
@@ -583,6 +607,28 @@ export function SchemaFrontmatterField({
|
|
|
583
607
|
/>
|
|
584
608
|
)
|
|
585
609
|
|
|
610
|
+
case 'year':
|
|
611
|
+
return (
|
|
612
|
+
<div class="flex flex-col gap-1.5" data-cms-ui>
|
|
613
|
+
<label class="text-xs font-medium text-white/70">{label}</label>
|
|
614
|
+
<input
|
|
615
|
+
type="number"
|
|
616
|
+
value={typeof value === 'number' ? value : ''}
|
|
617
|
+
placeholder={hints?.placeholder ?? String(new Date().getFullYear())}
|
|
618
|
+
min={typeof hints?.min === 'number' ? hints.min : 1900}
|
|
619
|
+
max={typeof hints?.max === 'number' ? hints.max : 2100}
|
|
620
|
+
step={1}
|
|
621
|
+
required={field.required}
|
|
622
|
+
onInput={(e) => {
|
|
623
|
+
const raw = (e.target as HTMLInputElement).value
|
|
624
|
+
onChange(raw === '' ? undefined : Number(raw))
|
|
625
|
+
}}
|
|
626
|
+
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"
|
|
627
|
+
data-cms-ui
|
|
628
|
+
/>
|
|
629
|
+
</div>
|
|
630
|
+
)
|
|
631
|
+
|
|
586
632
|
case 'boolean':
|
|
587
633
|
return (
|
|
588
634
|
<ToggleField
|
|
@@ -952,6 +998,8 @@ export function getPlaceholder(field: FieldDefinition): string {
|
|
|
952
998
|
return 'name@example.com'
|
|
953
999
|
case 'image':
|
|
954
1000
|
return '/images/...'
|
|
1001
|
+
case 'file':
|
|
1002
|
+
return '/files/...'
|
|
955
1003
|
case 'color':
|
|
956
1004
|
return '#000000'
|
|
957
1005
|
case 'date':
|
|
@@ -960,6 +1008,10 @@ export function getPlaceholder(field: FieldDefinition): string {
|
|
|
960
1008
|
return 'YYYY-MM-DDTHH:MM'
|
|
961
1009
|
case 'time':
|
|
962
1010
|
return 'HH:MM'
|
|
1011
|
+
case 'year':
|
|
1012
|
+
return String(new Date().getFullYear())
|
|
1013
|
+
case 'month':
|
|
1014
|
+
return 'YYYY-MM'
|
|
963
1015
|
default:
|
|
964
1016
|
return `Enter ${formatFieldLabel(field.name).toLowerCase()}...`
|
|
965
1017
|
}
|
|
@@ -200,6 +200,7 @@ export function FrontmatterSidebar({ fields, page, collectionDefinition }: Front
|
|
|
200
200
|
onChange={(newValue) => updateMarkdownFrontmatter({ [field.name]: newValue })}
|
|
201
201
|
collection={collectionDefinition?.name}
|
|
202
202
|
entrySlug={page.slug}
|
|
203
|
+
hasOpenInNewTabSibling={schemaFieldNames.has(`${field.name}OpenInNewTab`)}
|
|
203
204
|
/>
|
|
204
205
|
)
|
|
205
206
|
: (
|
package/src/field-types.ts
CHANGED
|
@@ -55,6 +55,10 @@ export interface ImageHints {
|
|
|
55
55
|
accept?: string
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
export interface FileHints {
|
|
59
|
+
accept?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
58
62
|
// --- Internals ---
|
|
59
63
|
|
|
60
64
|
type OrderByDirection = 'asc' | 'desc'
|
|
@@ -122,6 +126,8 @@ export const n = {
|
|
|
122
126
|
},
|
|
123
127
|
/** Image picker (opens media library). Accepts hints for the scanner; no Zod validation applied. */
|
|
124
128
|
image: (_hints?: ImageHints) => withOrderBy(z.string().describe('cms:image')),
|
|
129
|
+
/** File picker (opens media library for any file type — PDFs, docs, etc.). Accepts hints for the scanner; no Zod validation applied. */
|
|
130
|
+
file: (_hints?: FileHints) => withOrderBy(z.string().describe('cms:file')),
|
|
125
131
|
/** URL input */
|
|
126
132
|
url: (hints?: TextHints) => stringField('url', hints),
|
|
127
133
|
/** Email input */
|
|
@@ -130,6 +136,15 @@ export const n = {
|
|
|
130
136
|
tel: (hints?: TextHints) => stringField('tel', hints),
|
|
131
137
|
/** Color picker */
|
|
132
138
|
color: () => withOrderBy(z.string().describe('cms:color')),
|
|
139
|
+
/** Year picker (integer input). Defaults to 1900–2100 when no min/max given. */
|
|
140
|
+
year: (hints?: NumberHints) => {
|
|
141
|
+
let schema = z.number()
|
|
142
|
+
if (hints?.min != null) schema = schema.min(hints.min)
|
|
143
|
+
if (hints?.max != null) schema = schema.max(hints.max)
|
|
144
|
+
return withOrderBy(schema.describe('cms:year'))
|
|
145
|
+
},
|
|
146
|
+
/** Month picker (YYYY-MM). Accepts hints for the scanner; no Zod validation applied. */
|
|
147
|
+
month: (_hints?: DateHints) => withOrderBy(z.string().describe('cms:month')),
|
|
133
148
|
/** Date picker (handles YAML Date coercion → ISO date string). Accepts hints for the scanner; no Zod validation applied. */
|
|
134
149
|
date: (_hints?: DateHints) => withOrderBy(z.preprocess(toISODate, z.string()).describe('cms:date')),
|
|
135
150
|
/** Date + time picker (handles YAML Date coercion → ISO datetime string). Accepts hints for the scanner; no Zod validation applied. */
|