@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
@@ -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
+ }