@nuasite/cms 0.39.1 → 0.40.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 (40) hide show
  1. package/dist/editor.js +14575 -13938
  2. package/package.json +1 -1
  3. package/src/build-processor.ts +1 -1
  4. package/src/collection-scanner.ts +49 -2
  5. package/src/dev-middleware.ts +1 -1
  6. package/src/editor/components/attribute-editor.tsx +0 -1
  7. package/src/editor/components/bg-image-overlay.tsx +7 -8
  8. package/src/editor/components/block-editor.tsx +12 -12
  9. package/src/editor/components/collections-browser.tsx +10 -10
  10. package/src/editor/components/create-page-modal.tsx +18 -18
  11. package/src/editor/components/delete-page-dialog.tsx +4 -3
  12. package/src/editor/components/field-utils.ts +54 -0
  13. package/src/editor/components/fields.tsx +254 -72
  14. package/src/editor/components/frontmatter-fields.tsx +135 -54
  15. package/src/editor/components/frontmatter-sidebar.tsx +55 -58
  16. package/src/editor/components/link-edit-popover.tsx +10 -5
  17. package/src/editor/components/markdown-editor-overlay.tsx +100 -39
  18. package/src/editor/components/markdown-inline-editor.tsx +58 -26
  19. package/src/editor/components/mdx-block-view.tsx +4 -4
  20. package/src/editor/components/mdx-component-picker.tsx +2 -2
  21. package/src/editor/components/media-library.tsx +19 -18
  22. package/src/editor/components/modal-shell.tsx +16 -3
  23. package/src/editor/components/prop-editor.tsx +15 -18
  24. package/src/editor/components/redirects-manager.tsx +42 -35
  25. package/src/editor/components/reference-picker.tsx +5 -4
  26. package/src/editor/components/seo-editor.tsx +36 -27
  27. package/src/editor/components/toolbar.tsx +50 -33
  28. package/src/editor/dom.ts +13 -2
  29. package/src/editor/editor.ts +7 -6
  30. package/src/editor/hooks/useBlockEditorHandlers.ts +7 -6
  31. package/src/editor/index.tsx +7 -6
  32. package/src/editor/signals.ts +44 -13
  33. package/src/editor/strings.ts +123 -0
  34. package/src/editor/styles.css +75 -2
  35. package/src/editor/types.ts +8 -0
  36. package/src/index.ts +6 -0
  37. package/src/source-finder/image-finder.ts +1 -1
  38. package/src/source-finder/search-index.ts +12 -4
  39. package/src/source-finder/snippet-utils.ts +4 -1
  40. package/src/types.ts +4 -0
package/package.json CHANGED
@@ -14,7 +14,7 @@
14
14
  "directory": "packages/astro-cms"
15
15
  },
16
16
  "license": "Apache-2.0",
17
- "version": "0.39.1",
17
+ "version": "0.40.0",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -382,7 +382,7 @@ async function processFile(
382
382
  // The sourcePath from HTML attributes may point to a shared Image component
383
383
  // rather than the file that actually uses the component with the src value
384
384
  if (entry.imageMetadata?.src) {
385
- const preferredLocation = entry.sourcePath
385
+ const preferredLocation = entry.sourcePath || entry.imageMetadata.srcOccurrence !== undefined
386
386
  ? {
387
387
  file: entry.sourcePath,
388
388
  line: entry.sourceLine,
@@ -50,6 +50,25 @@ const FREE_TEXT_FIELD_NAMES = new Set([
50
50
  'caption',
51
51
  ])
52
52
 
53
+ /** Normalized names (lowercased, underscores/hyphens stripped) that mark a field as the publish toggle. */
54
+ const PUBLISH_TOGGLE_NAMES = new Set(['draft', 'isdraft', 'published', 'ispublished', 'unpublished'])
55
+
56
+ /** Normalized names that mark a field as the publish/release date anchor. */
57
+ const PUBLISH_DATE_NAMES = new Set([
58
+ 'date',
59
+ 'pubdate',
60
+ 'publishdate',
61
+ 'publisheddate',
62
+ 'publishedate',
63
+ 'publishedat',
64
+ 'datepublished',
65
+ ])
66
+
67
+ /** Normalize a field name for case- and separator-insensitive matching against the *_NAMES sets above. */
68
+ function normalizeFieldName(name: string): string {
69
+ return name.toLowerCase().replace(/[_-]/g, '')
70
+ }
71
+
53
72
  /**
54
73
  * Observed values for a single field across multiple files
55
74
  */
@@ -121,7 +140,7 @@ function assignFieldMetadata(
121
140
  ): void {
122
141
  for (const field of fields) {
123
142
  // Scanner defaults: well-known fields go to sidebar
124
- if (SIDEBAR_FIELD_NAMES.has(field.name.toLowerCase()) || field.type === 'image' || field.type === 'boolean') {
143
+ if (SIDEBAR_FIELD_NAMES.has(normalizeFieldName(field.name)) || field.type === 'image' || field.type === 'boolean') {
125
144
  field.position = 'sidebar'
126
145
  } else {
127
146
  field.position = 'header'
@@ -228,7 +247,7 @@ function mergeFieldObservations(observations: FieldObservation[]): FieldDefiniti
228
247
  }
229
248
 
230
249
  // For text fields, check if we should treat as select (limited unique values)
231
- if (fieldType === 'text' && !FREE_TEXT_FIELD_NAMES.has(obs.name.toLowerCase())) {
250
+ if (fieldType === 'text' && !FREE_TEXT_FIELD_NAMES.has(normalizeFieldName(obs.name))) {
232
251
  const uniqueValues = [...new Set(nonNullValues.map(v => String(v)))]
233
252
  const uniqueRatio = uniqueValues.length / nonNullValues.length
234
253
  // Only treat as select if unique values are limited AND not nearly all unique
@@ -583,6 +602,33 @@ function detectReferenceFieldsBySlugMatch(collections: Record<string, Collection
583
602
  }
584
603
  }
585
604
 
605
+ /**
606
+ * Tag fields with semantic roles so the editor UI can position them without
607
+ * matching on Astro-specific field names. Detection lives here — the layer
608
+ * that already knows it's parsing Astro content collections.
609
+ */
610
+ function assignSemanticRoles(collections: Record<string, CollectionDefinition>): void {
611
+ for (const def of Object.values(collections)) {
612
+ let toggle: FieldDefinition | undefined
613
+ let dateByName: FieldDefinition | undefined
614
+ let dateByType: FieldDefinition | undefined
615
+ for (const field of def.fields) {
616
+ if (field.hidden || field.role) continue
617
+ const normalized = normalizeFieldName(field.name)
618
+ if (!toggle && field.type === 'boolean' && PUBLISH_TOGGLE_NAMES.has(normalized)) {
619
+ toggle = field
620
+ } else if (!dateByName && PUBLISH_DATE_NAMES.has(normalized)) {
621
+ dateByName = field
622
+ } else if (!dateByType && (field.type === 'date' || field.type === 'datetime')) {
623
+ dateByType = field
624
+ }
625
+ }
626
+ if (toggle) toggle.role = 'publish-toggle'
627
+ const date = dateByName ?? dateByType
628
+ if (date) date.role = 'publish-date'
629
+ }
630
+ }
631
+
586
632
  /** Suffixes that indicate a field is a derived href/url/slug companion */
587
633
  const HREF_SUFFIXES = ['href', 'url', 'link', 'slug', 'path'] as const
588
634
 
@@ -749,6 +795,7 @@ export async function scanCollections(contentDir: string = 'src/content'): Promi
749
795
  applyParsedConfig(collections, parsed)
750
796
  detectReferenceFields(collections, parsed)
751
797
  detectDerivedHrefFields(collections)
798
+ assignSemanticRoles(collections)
752
799
  applyCollectionOrderBy(collections, parsed)
753
800
 
754
801
  return collections
@@ -598,7 +598,7 @@ export async function enhanceManifestInBackground(
598
598
  for (const entry of Object.values(enhanced)) {
599
599
  if (entry.sourceSnippet || entry.sourcePath) continue
600
600
  if (entry.imageMetadata?.src) {
601
- const preferredLocation = entry.sourcePath
601
+ const preferredLocation = entry.sourcePath || entry.imageMetadata.srcOccurrence !== undefined
602
602
  ? {
603
603
  file: entry.sourcePath,
604
604
  line: entry.sourceLine,
@@ -329,7 +329,6 @@ function AttributeField({ attrName, currentAttr, originalAttr, pages, onUpdate,
329
329
  <ImageField
330
330
  label={config.label}
331
331
  value={currentValue || undefined}
332
- placeholder={config.placeholder}
333
332
  onChange={(v) => onUpdate(v)}
334
333
  onBrowse={onOpenMediaLibrary}
335
334
  isDirty={isDirty}
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
1
+ import { useCallback, useEffect, useRef } from 'preact/hooks'
2
2
  import { Z_INDEX } from '../constants'
3
3
  import { isApplyingUndoRedo, recordChange } from '../history'
4
4
  import { cn } from '../lib/cn'
@@ -80,14 +80,14 @@ const REPEAT_OPTIONS = [
80
80
  * Shows a floating badge on hover and opens a right-side settings panel on click.
81
81
  */
82
82
  export function BgImageOverlay({ visible, rect, element, cmsId }: BgImageOverlayProps) {
83
- const [panelOpen, setPanelOpen] = useState(false)
83
+ const panelOpen = signals.isBgImageOverlayOpen.value
84
84
  // Capture target when panel opens so it stays stable when hover moves away
85
85
  const panelTargetRef = useRef<{ cmsId: string; element: HTMLElement } | null>(null)
86
86
 
87
87
  // Close panel when hovering a different bg-image element
88
88
  useEffect(() => {
89
89
  if (cmsId && panelTargetRef.current && cmsId !== panelTargetRef.current.cmsId) {
90
- setPanelOpen(false)
90
+ signals.isBgImageOverlayOpen.value = false
91
91
  panelTargetRef.current = null
92
92
  }
93
93
  }, [cmsId])
@@ -99,7 +99,7 @@ export function BgImageOverlay({ visible, rect, element, cmsId }: BgImageOverlay
99
99
  const handleClickOutside = (e: MouseEvent) => {
100
100
  const target = e.target as HTMLElement
101
101
  if (target.closest('[data-cms-ui]')) return
102
- setPanelOpen(false)
102
+ signals.isBgImageOverlayOpen.value = false
103
103
  panelTargetRef.current = null
104
104
  }
105
105
 
@@ -117,16 +117,16 @@ export function BgImageOverlay({ visible, rect, element, cmsId }: BgImageOverlay
117
117
  e.preventDefault()
118
118
  e.stopPropagation()
119
119
  if (panelOpen) {
120
- setPanelOpen(false)
120
+ signals.isBgImageOverlayOpen.value = false
121
121
  panelTargetRef.current = null
122
122
  } else if (cmsId && element) {
123
- setPanelOpen(true)
123
+ signals.isBgImageOverlayOpen.value = true
124
124
  panelTargetRef.current = { cmsId, element }
125
125
  }
126
126
  }, [panelOpen, cmsId, element])
127
127
 
128
128
  const handleClose = useCallback(() => {
129
- setPanelOpen(false)
129
+ signals.isBgImageOverlayOpen.value = false
130
130
  panelTargetRef.current = null
131
131
  }, [])
132
132
 
@@ -243,7 +243,6 @@ export function BgImageOverlay({ visible, rect, element, cmsId }: BgImageOverlay
243
243
  <ImageField
244
244
  label="Image URL"
245
245
  value={currentUrl || undefined}
246
- placeholder="/assets/image.png"
247
246
  onChange={handleImageUrlChange}
248
247
  onBrowse={handleBrowse}
249
248
  isDirty={isImageDirty}
@@ -374,13 +374,13 @@ export function BlockEditor({
374
374
  <div class="flex gap-2">
375
375
  <button
376
376
  onClick={() => handleStartInsert('before')}
377
- class="flex-1 py-2.5 px-3 bg-white/10 text-white/80 rounded-cms-md cursor-pointer text-[13px] font-medium flex items-center justify-center gap-1.5 hover:bg-white/20 hover:text-white transition-colors"
377
+ class="flex-1 py-2.5 px-3 bg-white/10 text-white/80 rounded-cms-sm cursor-pointer text-[13px] font-medium flex items-center justify-center gap-1.5 hover:bg-white/20 hover:text-white transition-colors"
378
378
  >
379
379
  <span class="text-base">↑</span> {isArrayItem ? 'Add item before' : 'Insert before'}
380
380
  </button>
381
381
  <button
382
382
  onClick={() => handleStartInsert('after')}
383
- class="flex-1 py-2.5 px-3 bg-white/10 text-white/80 rounded-cms-md cursor-pointer text-[13px] font-medium flex items-center justify-center gap-1.5 hover:bg-white/20 hover:text-white transition-colors"
383
+ class="flex-1 py-2.5 px-3 bg-white/10 text-white/80 rounded-cms-sm cursor-pointer text-[13px] font-medium flex items-center justify-center gap-1.5 hover:bg-white/20 hover:text-white transition-colors"
384
384
  >
385
385
  <span class="text-base">↓</span> {isArrayItem ? 'Add item after' : 'Insert after'}
386
386
  </button>
@@ -388,20 +388,20 @@ export function BlockEditor({
388
388
  <div class="flex gap-2 justify-between">
389
389
  <button
390
390
  onClick={() => setMode('confirm-remove')}
391
- class="px-4 py-2.5 bg-cms-error text-white rounded-cms-pill cursor-pointer hover:bg-red-600 transition-colors font-medium"
391
+ class="px-5 py-2.5 bg-cms-error text-white rounded-cms-pill cursor-pointer hover:bg-red-600 transition-colors font-medium"
392
392
  >
393
393
  {isArrayItem ? 'Remove item' : 'Remove'}
394
394
  </button>
395
395
  <div class="flex gap-2">
396
396
  <button
397
397
  onClick={onClose}
398
- class="px-4 py-2.5 bg-white/10 text-white/80 rounded-cms-pill cursor-pointer hover:bg-white/20 hover:text-white transition-colors font-medium"
398
+ class="px-5 py-2.5 bg-white/10 text-white/80 rounded-cms-pill cursor-pointer hover:bg-white/20 hover:text-white transition-colors font-medium"
399
399
  >
400
400
  Cancel
401
401
  </button>
402
402
  <button
403
403
  onClick={handleSave}
404
- class="px-4 py-2.5 bg-cms-primary text-cms-primary-text rounded-cms-pill cursor-pointer hover:bg-cms-primary-hover transition-all font-medium"
404
+ class="px-5 py-2.5 bg-cms-primary text-cms-primary-text rounded-cms-pill cursor-pointer hover:bg-cms-primary-hover transition-all font-medium"
405
405
  >
406
406
  Save
407
407
  </button>
@@ -413,7 +413,7 @@ export function BlockEditor({
413
413
  : mode === 'confirm-remove'
414
414
  ? (
415
415
  <div class="text-center p-5">
416
- <div class="px-4 py-3 bg-red-500/10 border border-red-500/30 rounded-cms-md mb-5 text-[13px] text-white">
416
+ <div class="px-4 py-3 bg-red-500/10 border border-red-500/30 rounded-cms-sm mb-5 text-[13px] text-white">
417
417
  {isArrayItem
418
418
  ? (
419
419
  <>
@@ -429,7 +429,7 @@ export function BlockEditor({
429
429
  <div class="flex gap-2 justify-end pt-4 border-t border-white/10 mt-4">
430
430
  <button
431
431
  onClick={handleBackToEdit}
432
- class="px-4 py-2.5 bg-white/10 text-white/80 rounded-cms-pill cursor-pointer hover:bg-white/20 hover:text-white transition-colors font-medium"
432
+ class="px-5 py-2.5 bg-white/10 text-white/80 rounded-cms-pill cursor-pointer hover:bg-white/20 hover:text-white transition-colors font-medium"
433
433
  >
434
434
  Cancel
435
435
  </button>
@@ -440,7 +440,7 @@ export function BlockEditor({
440
440
  onClose()
441
441
  }
442
442
  }}
443
- class="px-4 py-2.5 bg-cms-error text-white rounded-cms-pill cursor-pointer hover:bg-red-600 transition-colors font-medium"
443
+ class="px-5 py-2.5 bg-cms-error text-white rounded-cms-pill cursor-pointer hover:bg-red-600 transition-colors font-medium"
444
444
  >
445
445
  {isArrayItem ? 'Confirm remove item' : 'Confirm remove'}
446
446
  </button>
@@ -452,7 +452,7 @@ export function BlockEditor({
452
452
  <div class="p-5">
453
453
  {/* New component props */}
454
454
  <div class="mb-5">
455
- <div class="px-4 py-3 bg-white/10 rounded-cms-md mb-4 text-[13px] text-white">
455
+ <div class="px-4 py-3 bg-white/10 rounded-cms-sm mb-4 text-[13px] text-white">
456
456
  {isArrayItem
457
457
  ? (
458
458
  <>
@@ -478,13 +478,13 @@ export function BlockEditor({
478
478
  <div class="flex gap-2 justify-end pt-4 border-t border-white/10 mt-4">
479
479
  <button
480
480
  onClick={() => isArrayItem ? handleBackToEdit() : setMode('insert-picker')}
481
- class="px-4 py-2.5 bg-white/10 text-white/80 rounded-cms-pill cursor-pointer hover:bg-white/20 hover:text-white transition-colors font-medium"
481
+ class="px-5 py-2.5 bg-white/10 text-white/80 rounded-cms-pill cursor-pointer hover:bg-white/20 hover:text-white transition-colors font-medium"
482
482
  >
483
483
  Back
484
484
  </button>
485
485
  <button
486
486
  onClick={handleConfirmInsert}
487
- class="px-4 py-2.5 bg-cms-primary text-cms-primary-text rounded-cms-pill cursor-pointer hover:bg-cms-primary-hover transition-all font-medium"
487
+ class="px-5 py-2.5 bg-cms-primary text-cms-primary-text rounded-cms-pill cursor-pointer hover:bg-cms-primary-hover transition-all font-medium"
488
488
  >
489
489
  {isArrayItem ? 'Add item' : 'Insert component'}
490
490
  </button>
@@ -510,7 +510,7 @@ export function BlockEditor({
510
510
  <div class="mt-5 pt-4 border-t border-white/10">
511
511
  <button
512
512
  onClick={handleBackToEdit}
513
- class="w-full px-4 py-2.5 bg-white/10 text-white/80 rounded-cms-pill cursor-pointer hover:bg-white/20 hover:text-white transition-colors font-medium"
513
+ class="w-full px-4 py-2 bg-white/10 text-white/80 rounded-cms-pill cursor-pointer hover:bg-white/20 hover:text-white transition-colors font-medium"
514
514
  >
515
515
  Back to edit
516
516
  </button>
@@ -97,7 +97,7 @@ export function CollectionsBrowser() {
97
97
 
98
98
  return (
99
99
  <ModalBackdrop onClose={handleClose} extraClass="flex flex-col max-h-[80vh]">
100
- <div class="flex items-center justify-between p-5 border-b border-white/10 shrink-0">
100
+ <div class="flex items-center justify-between px-5 py-4 border-b border-white/10 shrink-0">
101
101
  <div class="flex items-center gap-3">
102
102
  <button
103
103
  type="button"
@@ -122,8 +122,8 @@ export function CollectionsBrowser() {
122
122
 
123
123
  {entries.length > 0 && (
124
124
  <div class="px-5 pt-4 pb-2 shrink-0">
125
- <div class="relative">
126
- <svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
125
+ <label class="flex items-center gap-2 px-3 py-2 bg-white/5 border border-white/10 rounded-cms-lg focus-within:border-white/40">
126
+ <svg class="w-4 h-4 text-white/30 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
127
127
  <circle cx="11" cy="11" r="8" />
128
128
  <path stroke-linecap="round" stroke-width="2" d="m21 21-4.3-4.3" />
129
129
  </svg>
@@ -132,11 +132,11 @@ export function CollectionsBrowser() {
132
132
  placeholder="Search..."
133
133
  value={search}
134
134
  onInput={(e) => setSearch((e.target as HTMLInputElement).value)}
135
- class="w-full pl-9 pr-3 py-2 text-sm text-white bg-white/5 border border-white/10 rounded-cms-lg placeholder:text-white/30 focus:outline-none focus:border-white/20"
135
+ class="flex-1 bg-transparent text-sm text-white placeholder:text-white/30 focus:outline-none"
136
136
  data-cms-ui
137
137
  />
138
- </div>
139
- <div class="text-white/30 text-xs mt-2">
138
+ </label>
139
+ <div class="text-white/30 text-xs mt-2 ml-4">
140
140
  {search
141
141
  ? `${filteredEntries.length} of ${entries.length}`
142
142
  : `${entries.length} ${entries.length === 1 ? 'entry' : 'entries'}`}
@@ -186,7 +186,7 @@ export function CollectionsBrowser() {
186
186
  <button
187
187
  type="button"
188
188
  onClick={() => handleEntryClick(entry.slug, entry.sourcePath)}
189
- class="w-full flex items-center gap-3 px-4 py-3 hover:bg-white/10 rounded-cms-lg transition-colors text-left group"
189
+ class="w-full flex items-center gap-3 px-4 py-3 hover:bg-white/10 rounded-cms-md transition-colors text-left group"
190
190
  data-cms-ui
191
191
  >
192
192
  <div class="flex-1 min-w-0">
@@ -210,7 +210,7 @@ export function CollectionsBrowser() {
210
210
  <TrashIcon />
211
211
  </button>
212
212
  <svg
213
- class="w-4 h-4 text-white/20 group-hover:text-white/40 shrink-0 transition-colors"
213
+ class="w-4 h-4 text-white/20 group-hover:text-cms-primary shrink-0 transition-colors"
214
214
  fill="none"
215
215
  stroke="currentColor"
216
216
  viewBox="0 0 24 24"
@@ -251,10 +251,10 @@ export function CollectionsBrowser() {
251
251
  key={col.name}
252
252
  type="button"
253
253
  onClick={() => selectBrowserCollection(col.name)}
254
- class="w-full flex items-center gap-4 p-4 bg-white/5 hover:bg-white/10 rounded-cms-lg border border-white/10 hover:border-white/20 transition-colors text-left"
254
+ class="group w-full flex items-center gap-4 p-4 bg-white/5 hover:bg-white/10 rounded-cms-lg border border-white/10 hover:border-white/20 transition-colors text-left cursor-pointer"
255
255
  data-cms-ui
256
256
  >
257
- <div class="shrink-0 w-10 h-10 bg-cms-primary/20 rounded-cms-md flex items-center justify-center">
257
+ <div class="shrink-0 w-10 h-10 bg-cms-primary/20 rounded-cms-sm flex items-center justify-center">
258
258
  <CollectionIcon />
259
259
  </div>
260
260
  <div class="flex-1 min-w-0">
@@ -75,10 +75,10 @@ function ModeCard({ icon, title, description, onClick }: {
75
75
  <button
76
76
  type="button"
77
77
  onClick={onClick}
78
- class="w-full flex items-center gap-4 p-4 bg-white/5 hover:bg-white/10 rounded-cms-lg border border-white/10 hover:border-white/20 transition-colors text-left cursor-pointer"
78
+ class="group w-full flex items-center gap-4 p-4 bg-white/5 hover:bg-white/10 rounded-cms-lg border border-white/10 hover:border-white/20 transition-colors text-left cursor-pointer"
79
79
  data-cms-ui
80
80
  >
81
- <div class="shrink-0 w-10 h-10 bg-cms-primary/20 rounded-cms-md flex items-center justify-center">
81
+ <div class="shrink-0 w-10 h-10 bg-cms-primary/20 rounded-cms-sm flex items-center justify-center">
82
82
  {icon}
83
83
  </div>
84
84
  <div class="flex-1 min-w-0">
@@ -222,25 +222,25 @@ function NewPageForm() {
222
222
  onInput={(e) => form.handleTitleChange((e.target as HTMLInputElement).value)}
223
223
  placeholder="My New Page"
224
224
  required
225
- class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md text-white placeholder:text-white/30 focus:outline-none focus:border-cms-primary/50"
225
+ class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md text-white placeholder:text-white/30 focus:outline-none focus:border-white/40"
226
226
  autoFocus
227
227
  data-cms-ui
228
228
  />
229
229
  </Field>
230
230
 
231
231
  <Field label="URL Path" error={form.slugError} checking={form.slugChecking}>
232
- <div class="flex items-center gap-1">
233
- <span class="text-white/40 text-sm">/</span>
232
+ <label class="flex items-center gap-1 px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md focus-within:border-white/40">
233
+ <span class="text-white/40 text-sm shrink-0">/</span>
234
234
  <input
235
235
  type="text"
236
236
  value={form.slug}
237
237
  onInput={(e) => form.handleSlugChange((e.target as HTMLInputElement).value)}
238
238
  placeholder="my-new-page"
239
239
  required
240
- class="flex-1 px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md text-white placeholder:text-white/30 focus:outline-none focus:border-cms-primary/50"
240
+ class="flex-1 bg-transparent text-white placeholder:text-white/30 focus:outline-none"
241
241
  data-cms-ui
242
242
  />
243
- </div>
243
+ </label>
244
244
  </Field>
245
245
 
246
246
  {layouts.length > 0 && (
@@ -248,7 +248,7 @@ function NewPageForm() {
248
248
  <select
249
249
  value={selectedLayout}
250
250
  onChange={(e) => setSelectedLayout((e.target as HTMLSelectElement).value || undefined)}
251
- class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md text-white focus:outline-none focus:border-cms-primary/50"
251
+ class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md text-white focus:outline-none focus:border-white/40"
252
252
  data-cms-ui
253
253
  >
254
254
  {layouts.map((l) => <option key={l.path} value={l.path}>{l.name}</option>)}
@@ -325,7 +325,7 @@ function DuplicatePageForm() {
325
325
  form.resetSlugManual()
326
326
  }}
327
327
  required
328
- class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md text-white focus:outline-none focus:border-cms-primary/50"
328
+ class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md text-white focus:outline-none focus:border-white/40"
329
329
  data-cms-ui
330
330
  >
331
331
  {pages.map((p) => (
@@ -342,24 +342,24 @@ function DuplicatePageForm() {
342
342
  value={form.title}
343
343
  onInput={(e) => form.handleTitleChange((e.target as HTMLInputElement).value)}
344
344
  placeholder="Page title"
345
- class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md text-white placeholder:text-white/30 focus:outline-none focus:border-cms-primary/50"
345
+ class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md text-white placeholder:text-white/30 focus:outline-none focus:border-white/40"
346
346
  data-cms-ui
347
347
  />
348
348
  </Field>
349
349
 
350
350
  <Field label="New URL Path" error={form.slugError} checking={form.slugChecking}>
351
- <div class="flex items-center gap-1">
352
- <span class="text-white/40 text-sm">/</span>
351
+ <label class="flex items-center gap-1 px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md focus-within:border-white/40">
352
+ <span class="text-white/40 text-sm shrink-0">/</span>
353
353
  <input
354
354
  type="text"
355
355
  value={form.slug}
356
356
  onInput={(e) => form.handleSlugChange((e.target as HTMLInputElement).value)}
357
357
  placeholder="new-page-slug"
358
358
  required
359
- class="flex-1 px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md text-white placeholder:text-white/30 focus:outline-none focus:border-cms-primary/50"
359
+ class="flex-1 bg-transparent text-white placeholder:text-white/30 focus:outline-none"
360
360
  data-cms-ui
361
361
  />
362
- </div>
362
+ </label>
363
363
  </Field>
364
364
 
365
365
  <label class="flex items-center gap-2.5 cursor-pointer" data-cms-ui>
@@ -441,16 +441,16 @@ function CollectionPicker() {
441
441
  key={col.name}
442
442
  type="button"
443
443
  onClick={() => handleSelectCollection(col.name)}
444
- class="w-full flex items-center gap-4 p-4 bg-white/5 hover:bg-white/10 rounded-cms-lg border border-white/10 hover:border-white/20 transition-colors text-left cursor-pointer"
444
+ class="group w-full flex items-center gap-4 p-4 bg-white/5 hover:bg-white/10 rounded-cms-lg border border-white/10 hover:border-white/20 transition-colors text-left cursor-pointer"
445
445
  data-cms-ui
446
446
  >
447
- <div class="shrink-0 w-10 h-10 bg-cms-primary/20 rounded-cms-md flex items-center justify-center">
447
+ <div class="shrink-0 w-10 h-10 bg-cms-primary/20 rounded-cms-sm flex items-center justify-center">
448
448
  <CollectionIcon />
449
449
  </div>
450
450
  <div class="flex-1 min-w-0">
451
451
  <div class="text-white font-medium">{col.label}</div>
452
452
  <div class="text-white/50 text-sm">
453
- {col.entryCount} {col.entryCount === 1 ? 'entry' : 'entries'} &middot; {col.fields.length} fields
453
+ {col.entryCount} {col.entryCount === 1 ? 'entry' : 'entries'}
454
454
  </div>
455
455
  </div>
456
456
  <ChevronRightIcon />
@@ -588,7 +588,7 @@ export function CollectionIcon() {
588
588
 
589
589
  export function ChevronRightIcon() {
590
590
  return (
591
- <svg class="w-5 h-5 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
591
+ <svg class="w-5 h-5 text-white/40 group-hover:text-cms-primary transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
592
592
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
593
593
  </svg>
594
594
  )
@@ -10,6 +10,7 @@ import {
10
10
  setDeletingPage,
11
11
  showToast,
12
12
  } from '../signals'
13
+ import { STRINGS } from '../strings'
13
14
  import { CancelButton, ModalBackdrop, ModalFooter, ModalHeader } from './modal-shell'
14
15
 
15
16
  export function DeletePageDialog() {
@@ -35,10 +36,10 @@ export function DeletePageDialog() {
35
36
 
36
37
  if (result.success) {
37
38
  resetDeletePageState()
38
- showToast('Page deleted', 'success')
39
+ showToast(STRINGS.page.deleted, 'success')
39
40
  window.location.href = currentState.createRedirect && currentState.redirectTo ? currentState.redirectTo : '/'
40
41
  } else {
41
- showToast(result.error || 'Failed to delete page', 'error')
42
+ showToast(result.error || STRINGS.page.deleteFailed, 'error')
42
43
  }
43
44
  }, [])
44
45
 
@@ -75,7 +76,7 @@ export function DeletePageDialog() {
75
76
  value={state.redirectTo}
76
77
  onInput={(e) => setDeletePageRedirectTo((e.target as HTMLInputElement).value)}
77
78
  placeholder="/"
78
- class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md text-white placeholder:text-white/30 focus:outline-none focus:border-cms-primary/50"
79
+ class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md text-white placeholder:text-white/30 focus:outline-none focus:border-white/40"
79
80
  data-cms-ui
80
81
  />
81
82
  </div>
@@ -0,0 +1,54 @@
1
+ import type { FieldDefinition } from '../types'
2
+
3
+ export function partitionFields(fields: FieldDefinition[]): { sidebar: FieldDefinition[]; header: FieldDefinition[] } {
4
+ const sidebar: FieldDefinition[] = []
5
+ const header: FieldDefinition[] = []
6
+ let toggleField: FieldDefinition | null = null
7
+ for (const field of fields) {
8
+ if (field.hidden) continue
9
+ if (field.role === 'publish-toggle' && field.position !== 'header') {
10
+ toggleField = field
11
+ continue
12
+ }
13
+ if (field.position === 'sidebar') {
14
+ sidebar.push(field)
15
+ } else {
16
+ header.push(field)
17
+ }
18
+ }
19
+ if (toggleField) {
20
+ const dateIdx = sidebar.findIndex((f) => f.role === 'publish-date')
21
+ if (dateIdx >= 0) {
22
+ sidebar.splice(dateIdx, 0, toggleField)
23
+ } else {
24
+ sidebar.unshift(toggleField)
25
+ }
26
+ }
27
+ return { sidebar, header }
28
+ }
29
+
30
+ export interface FieldGroup {
31
+ group: string | null
32
+ fields: FieldDefinition[]
33
+ }
34
+
35
+ export function groupFields(fields: FieldDefinition[]): FieldGroup[] {
36
+ const groups: FieldGroup[] = []
37
+ const groupMap = new Map<string | null, FieldDefinition[]>()
38
+ const order: (string | null)[] = []
39
+
40
+ for (const field of fields) {
41
+ const key = field.group ?? null
42
+ if (!groupMap.has(key)) {
43
+ groupMap.set(key, [])
44
+ order.push(key)
45
+ }
46
+ groupMap.get(key)!.push(field)
47
+ }
48
+
49
+ for (const key of order) {
50
+ groups.push({ group: key, fields: groupMap.get(key)! })
51
+ }
52
+
53
+ return groups
54
+ }