@nuasite/cms 0.39.2 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/editor.js +15910 -15027
  2. package/package.json +1 -1
  3. package/src/collection-scanner.ts +127 -13
  4. package/src/content-config-ast.ts +91 -24
  5. package/src/editor/components/attribute-editor.tsx +0 -1
  6. package/src/editor/components/bg-image-overlay.tsx +7 -8
  7. package/src/editor/components/block-editor.tsx +12 -12
  8. package/src/editor/components/collections-browser.tsx +10 -10
  9. package/src/editor/components/create-page-modal.tsx +18 -18
  10. package/src/editor/components/delete-page-dialog.tsx +4 -3
  11. package/src/editor/components/field-utils.ts +54 -0
  12. package/src/editor/components/fields.tsx +516 -73
  13. package/src/editor/components/frontmatter-fields.tsx +188 -55
  14. package/src/editor/components/frontmatter-sidebar.tsx +56 -58
  15. package/src/editor/components/link-edit-popover.tsx +10 -5
  16. package/src/editor/components/markdown-editor-overlay.tsx +100 -39
  17. package/src/editor/components/markdown-inline-editor.tsx +58 -26
  18. package/src/editor/components/mdx-block-view.tsx +4 -4
  19. package/src/editor/components/mdx-component-picker.tsx +2 -2
  20. package/src/editor/components/media-library.tsx +19 -18
  21. package/src/editor/components/modal-shell.tsx +16 -3
  22. package/src/editor/components/prop-editor.tsx +15 -18
  23. package/src/editor/components/redirects-manager.tsx +42 -35
  24. package/src/editor/components/reference-picker.tsx +5 -4
  25. package/src/editor/components/seo-editor.tsx +36 -27
  26. package/src/editor/components/toolbar.tsx +50 -33
  27. package/src/editor/dom.ts +13 -2
  28. package/src/editor/editor.ts +7 -6
  29. package/src/editor/hooks/useBlockEditorHandlers.ts +7 -6
  30. package/src/editor/index.tsx +7 -6
  31. package/src/editor/signals.ts +44 -13
  32. package/src/editor/strings.ts +123 -0
  33. package/src/editor/styles.css +75 -2
  34. package/src/editor/types.ts +8 -0
  35. package/src/field-types.ts +15 -0
  36. package/src/index.ts +6 -0
  37. package/src/types.ts +7 -0
@@ -1,5 +1,6 @@
1
1
  import type { ComponentChildren } from 'preact'
2
2
  import { useEffect, useState } from 'preact/hooks'
3
+ import { cn } from '../lib/cn'
3
4
  import { buildAstroUploadContext, getCollectionEntryOptions } from '../manifest'
4
5
  import { renameMarkdownPage } from '../markdown-api'
5
6
  import {
@@ -11,14 +12,46 @@ import {
11
12
  updateMarkdownFrontmatter,
12
13
  updateMarkdownPageMeta,
13
14
  } from '../signals'
15
+ import { STRINGS } from '../strings'
14
16
  import type { CollectionDefinition, FieldDefinition, MarkdownPageEntry } from '../types'
15
- import { ColorField, ComboBoxField, ImageField, MultiSelectField, NumberField, TextField, ToggleField } from './fields'
17
+ import { ColorField, ComboBoxField, FileField, ImageField, MultiSelectField, NumberField, TextField, ToggleField } from './fields'
16
18
  import { groupFields } from './frontmatter-sidebar'
17
19
 
18
20
  function isArrayOfObjects(value: unknown[]): value is Record<string, unknown>[] {
19
21
  return value.length > 0 && typeof value[0] === 'object' && value[0] !== null
20
22
  }
21
23
 
24
+ /** Checkbox below a URL field that toggles opening the link in a new tab. */
25
+ function OpenInNewTabToggle({ field }: { field: FieldDefinition }) {
26
+ const fm = markdownEditorState.value.currentPage?.frontmatter ?? {}
27
+ const targetKey = `${field.name}OpenInNewTab`
28
+ const isChecked = fm[targetKey] === true
29
+ return (
30
+ <label class="flex items-center gap-2 mt-2 text-xs text-white/70 cursor-pointer w-fit" data-cms-ui>
31
+ <input
32
+ type="checkbox"
33
+ checked={isChecked}
34
+ onChange={(e) => updateMarkdownFrontmatter({ [targetKey]: (e.target as HTMLInputElement).checked })}
35
+ class="sr-only peer"
36
+ data-cms-ui
37
+ />
38
+ <span
39
+ class={cn(
40
+ 'w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors',
41
+ isChecked ? 'bg-cms-primary border-cms-primary' : 'border-white/30 bg-white/5',
42
+ )}
43
+ >
44
+ {isChecked && (
45
+ <svg class="w-3 h-3 text-cms-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
46
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
47
+ </svg>
48
+ )}
49
+ </span>
50
+ Open in new tab
51
+ </label>
52
+ )
53
+ }
54
+
22
55
  function FieldGroupHeader({ group, children }: { group: string | null; children: ComponentChildren }) {
23
56
  return (
24
57
  <>
@@ -66,9 +99,21 @@ export function FrontmatterField({
66
99
  type="checkbox"
67
100
  checked={value}
68
101
  onChange={(e) => onChange((e.target as HTMLInputElement).checked)}
69
- class="w-4 h-4 rounded border-white/20 bg-white/10 text-cms-primary focus:ring-cms-primary focus:ring-offset-0 cursor-pointer"
102
+ class="sr-only peer"
70
103
  data-cms-ui
71
104
  />
105
+ <span
106
+ class={cn(
107
+ 'w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors',
108
+ value ? 'bg-cms-primary border-cms-primary' : 'border-white/30 bg-white/5',
109
+ )}
110
+ >
111
+ {value && (
112
+ <svg class="w-3 h-3 text-cms-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
113
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
114
+ </svg>
115
+ )}
116
+ </span>
72
117
  {label}
73
118
  </label>
74
119
  )
@@ -83,7 +128,7 @@ export function FrontmatterField({
83
128
  type="date"
84
129
  value={typeof value === 'string' ? value.split('T')[0] : ''}
85
130
  onChange={(e) => onChange((e.target as HTMLInputElement).value)}
86
- class="px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-cms-sm text-white focus:outline-none focus:border-cms-primary"
131
+ 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"
87
132
  data-cms-ui
88
133
  />
89
134
  </div>
@@ -119,7 +164,7 @@ export function FrontmatterField({
119
164
  onChange(arrayValue)
120
165
  }}
121
166
  placeholder={`Enter ${label.toLowerCase()} separated by commas`}
122
- class="px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-cms-sm text-white placeholder-white/30 focus:outline-none focus:border-cms-primary"
167
+ class="px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-cms-sm text-white placeholder-white/30 focus:outline-none focus:border-white/40"
123
168
  data-cms-ui
124
169
  />
125
170
  </div>
@@ -150,7 +195,7 @@ export function FrontmatterField({
150
195
  value={typeof value === 'string' ? value : ''}
151
196
  onChange={(e) => onChange((e.target as HTMLTextAreaElement).value)}
152
197
  rows={3}
153
- class="px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-cms-sm text-white placeholder-white/30 focus:outline-none focus:border-cms-primary resize-none"
198
+ class="px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-cms-sm text-white placeholder-white/30 focus:outline-none focus:border-white/40 resize-none"
154
199
  data-cms-ui
155
200
  />
156
201
  </div>
@@ -165,7 +210,7 @@ export function FrontmatterField({
165
210
  type="text"
166
211
  value={typeof value === 'string' ? value : String(value ?? '')}
167
212
  onChange={(e) => onChange((e.target as HTMLInputElement).value)}
168
- class="px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-cms-sm text-white placeholder-white/30 focus:outline-none focus:border-cms-primary"
213
+ class="px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-cms-sm text-white placeholder-white/30 focus:outline-none focus:border-white/40"
169
214
  data-cms-ui
170
215
  />
171
216
  </div>
@@ -190,38 +235,54 @@ export function CreateModeFrontmatter({
190
235
  onSlugManualEdit,
191
236
  }: CreateModeFrontmatterProps) {
192
237
  const allFields = fields ?? collectionDefinition.fields
238
+ const allFieldNames = new Set(allFields.map((f) => f.name))
239
+ const urlFieldNames = new Set(allFields.filter((f) => f.type === 'url' || /link|href|url/i.test(f.name)).map((f) => f.name))
240
+ const isOpenInNewTabSibling = (name: string) => {
241
+ if (!name.endsWith('OpenInNewTab')) return false
242
+ const base = name.slice(0, -'OpenInNewTab'.length)
243
+ return urlFieldNames.has(base)
244
+ }
193
245
  // In create mode, skip complex fields (arrays, objects) — they can be edited after creation
194
- const displayFields = allFields.filter(f => f.type !== 'array' && f.type !== 'object')
246
+ // Draft is always rendered in the sidebar never inline in the header.
247
+ // `*OpenInNewTab` siblings are handled by the OpenInNewTabToggle next to the URL field.
248
+ const displayFields = allFields.filter(f => f.type !== 'array' && f.type !== 'object' && f.name !== 'draft' && !isOpenInNewTabSibling(f.name))
195
249
  const groups = groupFields(displayFields)
196
250
 
197
251
  return (
198
252
  <div class="space-y-4">
199
253
  {/* Slug field */}
200
254
  <div>
201
- <label class="block text-xs font-medium text-white/70 mb-1.5">
202
- URL Slug
255
+ <div class="flex items-center gap-1.5 mb-1.5">
256
+ <label class="block text-xs font-medium text-white/70">URL Slug</label>
257
+ <span class="relative group/tt inline-flex" data-cms-ui>
258
+ <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">
259
+ <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" />
260
+ </svg>
261
+ <span class="absolute left-0 top-full mt-1 w-72 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 break-all">
262
+ Will be saved to: src/content/{collectionDefinition.name}/{page.slug || 'your-slug'}.{collectionDefinition.fileExtension}
263
+ </span>
264
+ </span>
265
+ </div>
266
+ <label class="flex items-center gap-1 px-3 py-2 bg-white/10 border border-white/20 rounded-cms-sm focus-within:border-white/40 focus-within:ring-1 focus-within:ring-white/10">
267
+ <span class="text-white/40 text-sm shrink-0">/</span>
268
+ <input
269
+ type="text"
270
+ value={page.slug}
271
+ onInput={(e) => {
272
+ onSlugManualEdit()
273
+ const slug = (e.target as HTMLInputElement).value
274
+ markdownEditorState.value = {
275
+ ...markdownEditorState.value,
276
+ currentPage: markdownEditorState.value.currentPage
277
+ ? { ...markdownEditorState.value.currentPage, slug }
278
+ : null,
279
+ }
280
+ }}
281
+ placeholder="url-friendly-slug"
282
+ class="flex-1 bg-transparent text-sm text-white placeholder-white/40 focus:outline-none"
283
+ data-cms-ui
284
+ />
203
285
  </label>
204
- <input
205
- type="text"
206
- value={page.slug}
207
- onInput={(e) => {
208
- onSlugManualEdit()
209
- const slug = (e.target as HTMLInputElement).value
210
- markdownEditorState.value = {
211
- ...markdownEditorState.value,
212
- currentPage: markdownEditorState.value.currentPage
213
- ? { ...markdownEditorState.value.currentPage, slug }
214
- : null,
215
- }
216
- }}
217
- placeholder="url-friendly-slug"
218
- class="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-cms-sm text-sm text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10"
219
- data-cms-ui
220
- />
221
- <p class="mt-1 text-xs text-white/40">
222
- Will be saved to: src/content/{collectionDefinition.name}/
223
- {page.slug || 'your-slug'}.{collectionDefinition.fileExtension}
224
- </p>
225
286
  </div>
226
287
 
227
288
  {/* Schema fields */}
@@ -236,6 +297,7 @@ export function CreateModeFrontmatter({
236
297
  onChange={(newValue) => updateMarkdownFrontmatter({ [field.name]: newValue })}
237
298
  collection={collectionDefinition.name}
238
299
  entrySlug={page.slug}
300
+ hasOpenInNewTabSibling={allFieldNames.has(`${field.name}OpenInNewTab`)}
239
301
  />
240
302
  ))}
241
303
  </FieldGroupHeader>
@@ -271,13 +333,13 @@ function SlugField({ page }: { page: MarkdownPageEntry }) {
271
333
  if (result.success && result.newSlug && result.newFilePath) {
272
334
  updateMarkdownPageMeta({ slug: result.newSlug, filePath: result.newFilePath })
273
335
  setLocalSlug(result.newSlug)
274
- showToast('Slug updated', 'success')
336
+ showToast(STRINGS.slug.updated, 'success')
275
337
  } else {
276
- showToast(result.error || 'Failed to rename', 'error')
338
+ showToast(result.error || STRINGS.slug.renameFailed, 'error')
277
339
  setLocalSlug(page.slug)
278
340
  }
279
341
  } catch {
280
- showToast('Failed to rename', 'error')
342
+ showToast(STRINGS.slug.renameFailed, 'error')
281
343
  setLocalSlug(page.slug)
282
344
  } finally {
283
345
  setIsRenaming(false)
@@ -289,7 +351,14 @@ function SlugField({ page }: { page: MarkdownPageEntry }) {
289
351
  <label class="block text-xs font-medium text-white/70 mb-1.5">
290
352
  URL Slug
291
353
  </label>
292
- <div class="flex gap-2">
354
+ <label
355
+ class={cn(
356
+ 'flex items-center gap-1 px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white focus-within:border-white/40',
357
+ isDirty ? 'border-white/30' : 'border-white/20',
358
+ isRenaming && 'opacity-60',
359
+ )}
360
+ >
361
+ <span class="text-white/40 text-sm shrink-0">/</span>
293
362
  <input
294
363
  type="text"
295
364
  value={localSlug}
@@ -301,13 +370,11 @@ function SlugField({ page }: { page: MarkdownPageEntry }) {
301
370
  ;(e.target as HTMLInputElement).blur()
302
371
  }
303
372
  }}
304
- class={`flex-1 px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white focus:outline-none focus:border-white/40 ${
305
- isDirty ? 'border-cms-primary' : 'border-white/20'
306
- }`}
373
+ class="flex-1 bg-transparent focus:outline-none"
307
374
  disabled={isRenaming}
308
375
  data-cms-ui
309
376
  />
310
- </div>
377
+ </label>
311
378
  </div>
312
379
  )
313
380
  }
@@ -323,14 +390,23 @@ export function EditModeFrontmatter({
323
390
  collectionDefinition,
324
391
  fields,
325
392
  }: EditModeFrontmatterProps) {
326
- const displayFields = fields ?? collectionDefinition?.fields ?? []
393
+ const allFields = fields ?? collectionDefinition?.fields ?? []
394
+ const allFieldNames = new Set(allFields.map((f) => f.name))
395
+ const urlFieldNames = new Set(allFields.filter((f) => f.type === 'url' || /link|href|url/i.test(f.name)).map((f) => f.name))
396
+ const isOpenInNewTabSibling = (name: string) => {
397
+ if (!name.endsWith('OpenInNewTab')) return false
398
+ const base = name.slice(0, -'OpenInNewTab'.length)
399
+ return urlFieldNames.has(base)
400
+ }
401
+ const displayFields = allFields.filter((f) => f.name !== 'draft' && !isOpenInNewTabSibling(f.name))
327
402
  // Collect schema field names for filtering extra keys
328
403
  const schemaFieldNames = new Set(
329
404
  collectionDefinition?.fields.map((f) => f.name) ?? [],
330
405
  )
331
- // Frontmatter keys not covered by the schema (user-added fields)
406
+ // Frontmatter keys not covered by the schema (user-added fields). Draft and the
407
+ // `*OpenInNewTab` siblings are rendered separately, so exclude them here.
332
408
  const extraKeys = Object.keys(page.frontmatter).filter(
333
- (key) => !schemaFieldNames.has(key),
409
+ (key) => !schemaFieldNames.has(key) && key !== 'draft' && !isOpenInNewTabSibling(key),
334
410
  )
335
411
  const groups = groupFields(displayFields)
336
412
 
@@ -352,6 +428,7 @@ export function EditModeFrontmatter({
352
428
  onChange={(newValue) => updateMarkdownFrontmatter({ [field.name]: newValue })}
353
429
  collection={collectionDefinition.name}
354
430
  entrySlug={page.slug}
431
+ hasOpenInNewTabSibling={allFieldNames.has(`${field.name}OpenInNewTab`)}
355
432
  />
356
433
  ))}
357
434
  </FieldGroupHeader>
@@ -394,6 +471,8 @@ interface SchemaFrontmatterFieldProps {
394
471
  /** Required when editing an `astroImage` field — routes uploads to the entry's directory. */
395
472
  collection?: string
396
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
397
476
  }
398
477
 
399
478
  export function SchemaFrontmatterField({
@@ -402,6 +481,7 @@ export function SchemaFrontmatterField({
402
481
  onChange,
403
482
  collection,
404
483
  entrySlug,
484
+ hasOpenInNewTabSibling,
405
485
  }: SchemaFrontmatterFieldProps) {
406
486
  const label = field.required ? `${formatFieldLabel(field.name)} *` : formatFieldLabel(field.name)
407
487
  const hints = field.hints
@@ -409,19 +489,29 @@ export function SchemaFrontmatterField({
409
489
  switch (field.type) {
410
490
  case 'text':
411
491
  case 'url':
412
- case 'email':
492
+ case 'email': {
493
+ const isLinkLike = field.type === 'url'
494
+ || /link|href|url/i.test(field.name)
495
+ const linkTooltip = isLinkLike
496
+ ? 'Use https://... for external links, or /path for internal pages.'
497
+ : undefined
413
498
  return (
414
- <TextField
415
- label={label}
416
- value={(value as string) ?? ''}
417
- placeholder={hints?.placeholder ?? getPlaceholder(field)}
418
- maxLength={hints?.maxLength as number | undefined}
419
- minLength={hints?.minLength as number | undefined}
420
- onChange={(v) => onChange(v)}
421
- inputType={field.type === 'text' ? undefined : field.type}
422
- required={field.required}
423
- />
499
+ <>
500
+ <TextField
501
+ label={label}
502
+ value={(value as string) ?? ''}
503
+ placeholder={hints?.placeholder ?? getPlaceholder(field)}
504
+ maxLength={hints?.maxLength as number | undefined}
505
+ minLength={hints?.minLength as number | undefined}
506
+ onChange={(v) => onChange(v)}
507
+ inputType={field.type === 'text' ? undefined : field.type}
508
+ required={field.required}
509
+ tooltip={linkTooltip}
510
+ />
511
+ {field.type === 'url' && hasOpenInNewTabSibling && <OpenInNewTabToggle field={field} />}
512
+ </>
424
513
  )
514
+ }
425
515
 
426
516
  case 'image': {
427
517
  const astroContext = buildAstroUploadContext(field, collection, entrySlug)
@@ -429,14 +519,28 @@ export function SchemaFrontmatterField({
429
519
  <ImageField
430
520
  label={label}
431
521
  value={(value as string) ?? ''}
432
- placeholder={getPlaceholder(field)}
433
522
  onChange={(v) => onChange(v)}
434
523
  onBrowse={() => {
435
524
  openMediaLibraryWithCallback((url: string) => {
436
525
  onChange(url)
437
526
  }, astroContext)
438
527
  }}
439
- required={field.required}
528
+ />
529
+ )
530
+ }
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
+ }}
440
544
  />
441
545
  )
442
546
  }
@@ -472,6 +576,7 @@ export function SchemaFrontmatterField({
472
576
  case 'date':
473
577
  case 'datetime':
474
578
  case 'time':
579
+ case 'month':
475
580
  return (
476
581
  <div class="flex flex-col gap-1" data-cms-ui>
477
582
  <label class="text-xs text-white/60 font-medium">{label}</label>
@@ -502,6 +607,28 @@ export function SchemaFrontmatterField({
502
607
  />
503
608
  )
504
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
+
505
632
  case 'boolean':
506
633
  return (
507
634
  <ToggleField
@@ -822,7 +949,7 @@ function ObjectFields({ label, value, onChange, schemaFields, extraKeys }: Objec
822
949
  }
823
950
  }}
824
951
  placeholder="New field name..."
825
- class="flex-1 px-2 py-1 text-xs bg-white/5 border border-white/10 rounded-cms-sm text-white placeholder-white/30 focus:outline-none focus:border-white/30"
952
+ class="flex-1 px-2 py-1 text-xs bg-white/5 border border-white/10 rounded-cms-sm text-white placeholder-white/30 focus:outline-none focus:border-white/40"
826
953
  data-cms-ui
827
954
  />
828
955
  <button
@@ -871,6 +998,8 @@ export function getPlaceholder(field: FieldDefinition): string {
871
998
  return 'name@example.com'
872
999
  case 'image':
873
1000
  return '/images/...'
1001
+ case 'file':
1002
+ return '/files/...'
874
1003
  case 'color':
875
1004
  return '#000000'
876
1005
  case 'date':
@@ -879,6 +1008,10 @@ export function getPlaceholder(field: FieldDefinition): string {
879
1008
  return 'YYYY-MM-DDTHH:MM'
880
1009
  case 'time':
881
1010
  return 'HH:MM'
1011
+ case 'year':
1012
+ return String(new Date().getFullYear())
1013
+ case 'month':
1014
+ return 'YYYY-MM'
882
1015
  default:
883
1016
  return `Enter ${formatFieldLabel(field.name).toLowerCase()}...`
884
1017
  }
@@ -2,51 +2,10 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
2
2
  import { cn } from '../lib/cn'
3
3
  import { updateMarkdownFrontmatter } from '../signals'
4
4
  import type { CollectionDefinition, FieldDefinition, MarkdownPageEntry } from '../types'
5
+ import { groupFields, partitionFields } from './field-utils'
5
6
  import { formatFieldLabel, FrontmatterField, SchemaFrontmatterField } from './frontmatter-fields'
6
7
 
7
- // ============================================================================
8
- // Field Utilities
9
- // ============================================================================
10
-
11
- export function partitionFields(fields: FieldDefinition[]): { sidebar: FieldDefinition[]; header: FieldDefinition[] } {
12
- const sidebar: FieldDefinition[] = []
13
- const header: FieldDefinition[] = []
14
- for (const field of fields) {
15
- if (field.hidden) continue
16
- if (field.position === 'sidebar') {
17
- sidebar.push(field)
18
- } else {
19
- header.push(field)
20
- }
21
- }
22
- return { sidebar, header }
23
- }
24
-
25
- export interface FieldGroup {
26
- group: string | null
27
- fields: FieldDefinition[]
28
- }
29
-
30
- export function groupFields(fields: FieldDefinition[]): FieldGroup[] {
31
- const groups: FieldGroup[] = []
32
- const groupMap = new Map<string | null, FieldDefinition[]>()
33
- const order: (string | null)[] = []
34
-
35
- for (const field of fields) {
36
- const key = field.group ?? null
37
- if (!groupMap.has(key)) {
38
- groupMap.set(key, [])
39
- order.push(key)
40
- }
41
- groupMap.get(key)!.push(field)
42
- }
43
-
44
- for (const key of order) {
45
- groups.push({ group: key, fields: groupMap.get(key)! })
46
- }
47
-
48
- return groups
49
- }
8
+ export { groupFields, partitionFields } from './field-utils'
50
9
 
51
10
  // ============================================================================
52
11
  // Group Header
@@ -94,9 +53,11 @@ interface FrontmatterSidebarProps {
94
53
 
95
54
  export function FrontmatterSidebar({ fields, page, collectionDefinition }: FrontmatterSidebarProps) {
96
55
  const [state, setState] = useState(loadSidebarState)
56
+ const [isAnimating, setIsAnimating] = useState(false)
97
57
  const isResizing = useRef(false)
98
58
  const startX = useRef(0)
99
59
  const startWidth = useRef(0)
60
+ const animationTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
100
61
 
101
62
  const { width, collapsed } = state
102
63
 
@@ -108,8 +69,17 @@ export function FrontmatterSidebar({ fields, page, collectionDefinition }: Front
108
69
  })
109
70
  }, [])
110
71
 
72
+ const toggleCollapsed = useCallback(() => {
73
+ if (animationTimeout.current) clearTimeout(animationTimeout.current)
74
+ setIsAnimating(true)
75
+ updateState({ collapsed: !collapsed })
76
+ animationTimeout.current = setTimeout(() => setIsAnimating(false), 300)
77
+ }, [collapsed, updateState])
78
+
111
79
  const handleMouseDown = useCallback((e: MouseEvent) => {
112
80
  e.preventDefault()
81
+ if (animationTimeout.current) clearTimeout(animationTimeout.current)
82
+ setIsAnimating(false)
113
83
  isResizing.current = true
114
84
  startX.current = e.clientX
115
85
  startWidth.current = width
@@ -151,7 +121,11 @@ export function FrontmatterSidebar({ fields, page, collectionDefinition }: Front
151
121
 
152
122
  return (
153
123
  <div
154
- class={cn('relative shrink-0 border-l border-white/10 bg-white/5 flex', collapsed && 'w-8')}
124
+ class={cn(
125
+ 'relative shrink-0 border-l border-white/10 bg-white/5 flex overflow-hidden',
126
+ isAnimating && 'transition-[width] duration-300 ease-out',
127
+ collapsed && 'w-8',
128
+ )}
155
129
  style={collapsed ? undefined : { width: `${width}px` }}
156
130
  data-cms-ui
157
131
  >
@@ -166,27 +140,50 @@ export function FrontmatterSidebar({ fields, page, collectionDefinition }: Front
166
140
  {/* Collapse toggle */}
167
141
  <button
168
142
  type="button"
169
- onClick={() => updateState({ collapsed: !collapsed })}
170
- class="absolute top-2 left-0 -translate-x-1/2 z-20 w-5 h-5 rounded-full bg-cms-dark border border-white/20 flex items-center justify-center text-white/50 hover:text-white hover:border-white/40 transition-colors"
143
+ onClick={toggleCollapsed}
144
+ class="absolute top-2 left-1.5 -translate-x-1/2 z-20 w-5 h-5 rounded-cms-xs bg-white/10 hover:bg-white/20 border border-white/20 hover:border-white/40 flex items-center justify-center text-white/50 hover:text-white transition-colors"
171
145
  title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
172
146
  data-cms-ui
173
147
  >
174
- <svg
175
- class={cn('w-3 h-3 transition-transform', collapsed && 'rotate-180')}
176
- viewBox="0 0 24 24"
177
- fill="none"
178
- stroke="currentColor"
179
- stroke-width="2"
180
- stroke-linecap="round"
181
- stroke-linejoin="round"
182
- >
183
- <path d="m15 18-6-6 6-6" />
184
- </svg>
148
+ <span class="relative w-3 h-3 inline-block">
149
+ <svg
150
+ class={cn(
151
+ 'absolute inset-0 w-3 h-3 transition-opacity duration-200 ease-out',
152
+ collapsed ? 'opacity-0' : 'opacity-100',
153
+ )}
154
+ viewBox="0 0 24 24"
155
+ fill="none"
156
+ stroke="currentColor"
157
+ stroke-width="2"
158
+ stroke-linecap="round"
159
+ stroke-linejoin="round"
160
+ >
161
+ <path d="M3 5v14" />
162
+ <path d="M21 12H7" />
163
+ <path d="m15 18 6-6-6-6" />
164
+ </svg>
165
+ <svg
166
+ class={cn(
167
+ 'absolute inset-0 w-3 h-3 transition-opacity duration-200 ease-out',
168
+ collapsed ? 'opacity-100' : 'opacity-0',
169
+ )}
170
+ viewBox="0 0 24 24"
171
+ fill="none"
172
+ stroke="currentColor"
173
+ stroke-width="2"
174
+ stroke-linecap="round"
175
+ stroke-linejoin="round"
176
+ >
177
+ <path d="M3 19V5" />
178
+ <path d="m13 6-6 6 6 6" />
179
+ <path d="M7 12h14" />
180
+ </svg>
181
+ </span>
185
182
  </button>
186
183
 
187
184
  {/* Sidebar content */}
188
185
  {!collapsed && (
189
- <div class="flex-1 overflow-y-auto p-4 space-y-3 min-w-0">
186
+ <div class="flex-1 overflow-y-auto p-3 pt-10 space-y-3 min-w-0" style={{ scrollbarGutter: 'stable' }}>
190
187
  {groups.map((group, gi) => (
191
188
  <div key={gi} data-cms-ui>
192
189
  {group.group && <GroupHeader label={group.group} />}
@@ -203,6 +200,7 @@ export function FrontmatterSidebar({ fields, page, collectionDefinition }: Front
203
200
  onChange={(newValue) => updateMarkdownFrontmatter({ [field.name]: newValue })}
204
201
  collection={collectionDefinition?.name}
205
202
  entrySlug={page.slug}
203
+ hasOpenInNewTabSibling={schemaFieldNames.has(`${field.name}OpenInNewTab`)}
206
204
  />
207
205
  )
208
206
  : (
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
2
+ import { cn } from '../lib/cn'
2
3
  import { HighlightMatch } from './fields'
3
4
  import { PrimaryButton } from './modal-shell'
4
5
 
@@ -131,7 +132,10 @@ export function LinkEditPopover({ initialUrl, suggestions, onApply, onRemove, on
131
132
  >
132
133
  <form
133
134
  onSubmit={handleSubmit}
134
- class={`flex items-center gap-2 ${inline ? 'py-1.5' : 'px-4 py-2.5 bg-cms-dark border-b border-white/10'}`}
135
+ class={cn(
136
+ 'flex items-center gap-2',
137
+ inline ? 'py-1.5' : 'px-4 py-2 bg-cms-dark border-b border-white/10',
138
+ )}
135
139
  >
136
140
  <svg class="w-4 h-4 text-white/40 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
137
141
  <path
@@ -152,7 +156,7 @@ export function LinkEditPopover({ initialUrl, suggestions, onApply, onRemove, on
152
156
  onBlur={handleBlur}
153
157
  onKeyDown={handleKeyDown}
154
158
  autocomplete="off"
155
- class="w-full bg-white/5 border border-white/10 rounded-cms-sm px-2.5 py-1.5 text-[13px] text-white placeholder:text-white/30 outline-none focus:border-cms-primary/50 transition-colors"
159
+ class="w-full bg-white/5 border border-white/10 rounded-cms-sm px-2.5 py-1.5 text-[13px] text-white placeholder:text-white/30 outline-none focus:border-white/40 transition-colors"
156
160
  data-cms-ui
157
161
  />
158
162
  {showDropdown && (
@@ -169,11 +173,12 @@ export function LinkEditPopover({ initialUrl, suggestions, onApply, onRemove, on
169
173
  e.preventDefault()
170
174
  selectOption(opt.value)
171
175
  }}
172
- class={`w-full text-left px-3 py-2 text-xs transition-colors cursor-pointer ${
176
+ class={cn(
177
+ 'w-full text-left px-3 py-2 text-xs transition-colors cursor-pointer',
173
178
  i === highlightedIndex
174
179
  ? 'bg-white/15 text-white'
175
- : 'text-white/70 hover:bg-white/10 hover:text-white'
176
- }`}
180
+ : 'text-white/70 hover:bg-white/10 hover:text-white',
181
+ )}
177
182
  data-cms-ui
178
183
  >
179
184
  <span class="block truncate font-medium">