@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.
@@ -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
- <div
184
- role="button"
185
- tabIndex={isUploading ? -1 : 0}
186
- aria-label={hasImage ? 'Replace image — click to upload or drop a file' : 'Upload image — click or drop a file'}
187
- aria-busy={isUploading}
188
- onClick={isUploading ? undefined : handleUploadClick}
189
- onKeyDown={(e) => {
190
- if (isUploading) return
191
- if (e.key === 'Enter' || e.key === ' ') {
192
- e.preventDefault()
193
- handleUploadClick()
194
- }
195
- }}
196
- onDragOver={handleDragOver}
197
- onDragEnter={handleDragOver}
198
- onDragLeave={handleDragLeave}
199
- onDrop={handleDrop}
200
- class={cn(
201
- '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',
202
- isUploading ? 'cursor-wait' : 'cursor-pointer',
203
- isDragOver ? 'border-cms-primary bg-cms-primary/10' : 'border-white/10 hover:border-white/20',
204
- )}
205
- data-cms-ui
206
- >
207
- {hasImage && !showFallback
208
- ? (
209
- <>
210
- <img
211
- src={value}
212
- alt={label}
213
- class="w-full h-32 object-contain"
214
- onError={() => setFailedSrc(value ?? null)}
215
- />
216
- <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">
217
- <span class="block text-white text-[11px] font-medium truncate" title={value}>
218
- {value}
219
- </span>
220
- </div>
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="absolute top-1.5 right-1.5 w-6 h-6 flex items-center justify-center bg-black/60 hover:bg-red-500/80 text-white rounded-cms-xs opacity-0 group-hover:opacity-100 focus:opacity-100 transition-all cursor-pointer z-20"
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
- {/* Hover overlay decorative hint (pointer-events-none, click goes to the parent role=button) */}
250
- <div class="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/40 transition-colors pointer-events-none">
251
- <span class="text-white/90 text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity">
252
- {isUploading ? 'Uploading…' : isDragOver ? 'Drop to upload' : hasImage ? 'Replace image' : 'Click or drop file'}
253
- </span>
254
- </div>
255
- </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"
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/4 -translate-y-1/2 flex gap-[3px]">
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
  : (
@@ -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. */
package/src/types.ts CHANGED
@@ -244,9 +244,12 @@ export type FieldType =
244
244
  | 'date'
245
245
  | 'datetime'
246
246
  | 'time'
247
+ | 'year'
248
+ | 'month'
247
249
  | 'boolean'
248
250
  | 'number'
249
251
  | 'image'
252
+ | 'file'
250
253
  | 'url'
251
254
  | 'email'
252
255
  | 'tel'