@nuasite/cms 0.40.0 → 0.42.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
@@ -550,9 +573,38 @@ export function SchemaFrontmatterField({
550
573
  </div>
551
574
  )
552
575
 
553
- case 'date':
576
+ case 'date': {
577
+ // A `date` field's value is often a full datetime (e.g. "2026-04-14T08:35:00"),
578
+ // which an <input type="date"> can't display (it needs YYYY-MM-DD). Show only the
579
+ // date part, but preserve the original time component on change so editing the date
580
+ // doesn't silently drop the time.
581
+ const raw = value == null ? '' : String(value)
582
+ // Preserve the full time component on change — including fractional seconds and any
583
+ // timezone designator (Z or ±HH:MM) — so editing only the date never drops them.
584
+ const timeSuffix = raw.match(/T\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?/)?.[0] ?? ''
585
+ return (
586
+ <div class="flex flex-col gap-1" data-cms-ui>
587
+ <label class="text-xs text-white/60 font-medium">{label}</label>
588
+ <input
589
+ type="date"
590
+ value={raw.slice(0, 10)}
591
+ min={hints?.min != null ? String(hints.min) : undefined}
592
+ max={hints?.max != null ? String(hints.max) : undefined}
593
+ required={field.required}
594
+ onInput={(e) => {
595
+ const d = (e.target as HTMLInputElement).value
596
+ onChange(d ? `${d}${timeSuffix}` : '')
597
+ }}
598
+ class="px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-cms-sm text-white focus:outline-none focus:border-white/40"
599
+ data-cms-ui
600
+ />
601
+ </div>
602
+ )
603
+ }
604
+
554
605
  case 'datetime':
555
606
  case 'time':
607
+ case 'month':
556
608
  return (
557
609
  <div class="flex flex-col gap-1" data-cms-ui>
558
610
  <label class="text-xs text-white/60 font-medium">{label}</label>
@@ -583,6 +635,28 @@ export function SchemaFrontmatterField({
583
635
  />
584
636
  )
585
637
 
638
+ case 'year':
639
+ return (
640
+ <div class="flex flex-col gap-1.5" data-cms-ui>
641
+ <label class="text-xs font-medium text-white/70">{label}</label>
642
+ <input
643
+ type="number"
644
+ value={typeof value === 'number' ? value : ''}
645
+ placeholder={hints?.placeholder ?? String(new Date().getFullYear())}
646
+ min={typeof hints?.min === 'number' ? hints.min : 1900}
647
+ max={typeof hints?.max === 'number' ? hints.max : 2100}
648
+ step={1}
649
+ required={field.required}
650
+ onInput={(e) => {
651
+ const raw = (e.target as HTMLInputElement).value
652
+ onChange(raw === '' ? undefined : Number(raw))
653
+ }}
654
+ 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"
655
+ data-cms-ui
656
+ />
657
+ </div>
658
+ )
659
+
586
660
  case 'boolean':
587
661
  return (
588
662
  <ToggleField
@@ -952,6 +1026,8 @@ export function getPlaceholder(field: FieldDefinition): string {
952
1026
  return 'name@example.com'
953
1027
  case 'image':
954
1028
  return '/images/...'
1029
+ case 'file':
1030
+ return '/files/...'
955
1031
  case 'color':
956
1032
  return '#000000'
957
1033
  case 'date':
@@ -960,6 +1036,10 @@ export function getPlaceholder(field: FieldDefinition): string {
960
1036
  return 'YYYY-MM-DDTHH:MM'
961
1037
  case 'time':
962
1038
  return 'HH:MM'
1039
+ case 'year':
1040
+ return String(new Date().getFullYear())
1041
+ case 'month':
1042
+ return 'YYYY-MM'
963
1043
  default:
964
1044
  return `Enter ${formatFieldLabel(field.name).toLowerCase()}...`
965
1045
  }
@@ -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
  : (
@@ -60,7 +60,7 @@ export function MarkdownEditorOverlay() {
60
60
  : activeCollectionDef?.fileExtension === 'mdx'
61
61
 
62
62
  const [isSaving, setIsSaving] = useState(false)
63
- const [showFrontmatter, setShowFrontmatter] = useState(isCreateMode || isDataCollection)
63
+ const [showFrontmatter, setShowFrontmatter] = useState(isCreateMode || isDataCollection || config.value.openMetadataByDefault === true)
64
64
  // Track whether the user has manually edited the slug (disables auto-slug from title)
65
65
  const [slugManuallyEdited, setSlugManuallyEdited] = useState(false)
66
66
  // Preview mode state