@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
@@ -32,7 +32,7 @@ export function ModalHeader({ title, onBack, onClose }: {
32
32
  onClose: () => void
33
33
  }) {
34
34
  return (
35
- <div class="flex items-center gap-3 p-5 border-b border-white/10">
35
+ <div class="flex items-center gap-3 px-5 py-4 border-b border-white/10">
36
36
  {onBack && (
37
37
  <button
38
38
  type="button"
@@ -53,7 +53,20 @@ export function ModalHeader({ title, onBack, onClose }: {
53
53
 
54
54
  export function ModalFooter({ children }: { children: ComponentChildren }) {
55
55
  return (
56
- <div class="flex items-center justify-end gap-2 p-5 border-t border-white/10 bg-white/5 rounded-b-cms-xl">
56
+ <div class="flex items-center justify-end gap-2 py-3.5 px-4 border-t border-white/10 bg-white/5 rounded-b-cms-xl">
57
+ {children}
58
+ </div>
59
+ )
60
+ }
61
+
62
+ export function Section({ title, children, className }: {
63
+ title: string
64
+ children: ComponentChildren
65
+ className?: string
66
+ }) {
67
+ return (
68
+ <div class={cn('space-y-4', className)}>
69
+ <h3 class="text-base font-semibold text-white/90">{title}</h3>
57
70
  {children}
58
71
  </div>
59
72
  )
@@ -80,7 +93,7 @@ export function CancelButton({ onClick, label = 'Cancel', className }: { onClick
80
93
  type="button"
81
94
  onClick={onClick}
82
95
  class={cn(
83
- 'px-4 py-2.5 text-sm text-white/80 font-medium rounded-cms-pill hover:bg-white/10 hover:text-white transition-colors cursor-pointer',
96
+ 'py-2.5 px-3.5 text-sm text-white/80 font-medium rounded-cms-pill hover:bg-white/10 hover:text-white transition-colors cursor-pointer',
84
97
  className,
85
98
  )}
86
99
  data-cms-ui
@@ -68,7 +68,7 @@ function renderPropInput(prop: ComponentProp, value: string, onChange: (value: s
68
68
  <select
69
69
  value={value}
70
70
  onChange={(e) => onChange((e.target as HTMLSelectElement).value)}
71
- class="w-full px-4 py-2.5 bg-white/10 border border-white/20 text-[13px] text-white outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-md"
71
+ class="w-full px-4 py-2 bg-white/10 border border-white/20 text-[13px] text-white outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-sm"
72
72
  >
73
73
  {!prop.required && <option value="">— None —</option>}
74
74
  {unionOptions.map((opt) => <option key={opt} value={opt}>{opt}</option>)}
@@ -84,7 +84,7 @@ function renderPropInput(prop: ComponentProp, value: string, onChange: (value: s
84
84
  value={value}
85
85
  onInput={(e) => onChange((e.target as HTMLInputElement).value)}
86
86
  placeholder={prop.defaultValue || 'Select an image...'}
87
- class="flex-1 px-4 py-2.5 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-md"
87
+ class="flex-1 px-4 py-2 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-sm"
88
88
  />
89
89
  <button
90
90
  type="button"
@@ -93,7 +93,7 @@ function renderPropInput(prop: ComponentProp, value: string, onChange: (value: s
93
93
  onChange(url)
94
94
  })
95
95
  }}
96
- class="px-3 py-2.5 bg-white/10 border border-white/20 text-white/70 hover:text-white hover:bg-white/15 rounded-cms-md transition-colors shrink-0"
96
+ class="px-3 py-2.5 bg-white/10 border border-white/20 text-white/70 hover:text-white hover:bg-white/15 rounded-cms-sm transition-colors shrink-0"
97
97
  title="Browse media"
98
98
  >
99
99
  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
@@ -115,14 +115,14 @@ function renderPropInput(prop: ComponentProp, value: string, onChange: (value: s
115
115
  type="color"
116
116
  value={value || '#000000'}
117
117
  onInput={(e) => onChange((e.target as HTMLInputElement).value)}
118
- class="w-10 h-10 rounded-cms-md border border-white/20 bg-transparent cursor-pointer"
118
+ class="w-10 h-10 rounded-cms-sm border border-white/20 bg-transparent cursor-pointer"
119
119
  />
120
120
  <input
121
121
  type="text"
122
122
  value={value}
123
123
  onInput={(e) => onChange((e.target as HTMLInputElement).value)}
124
124
  placeholder={prop.defaultValue || '#000000'}
125
- class="flex-1 px-4 py-2.5 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-md font-mono"
125
+ class="flex-1 px-4 py-2 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-sm font-mono"
126
126
  />
127
127
  </div>
128
128
  )
@@ -135,7 +135,7 @@ function renderPropInput(prop: ComponentProp, value: string, onChange: (value: s
135
135
  onInput={(e) => onChange((e.target as HTMLTextAreaElement).value)}
136
136
  placeholder={prop.defaultValue || `Enter ${prop.name}...`}
137
137
  rows={3}
138
- class="w-full px-4 py-2.5 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-md resize-y"
138
+ class="w-full px-4 py-2 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-sm resize-y"
139
139
  />
140
140
  )
141
141
  }
@@ -146,14 +146,14 @@ function renderPropInput(prop: ComponentProp, value: string, onChange: (value: s
146
146
  value={value}
147
147
  onInput={(e) => onChange((e.target as HTMLInputElement).value)}
148
148
  placeholder={prop.defaultValue || `Enter ${prop.name}...`}
149
- class="w-full px-4 py-2.5 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-md"
149
+ class="w-full px-4 py-2 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-sm"
150
150
  />
151
151
  )
152
152
  }
153
153
 
154
154
  export function PropEditor({ prop, value, onChange }: PropEditorProps) {
155
155
  return (
156
- <div class="mb-4">
156
+ <div class="mb-2">
157
157
  <label class="block text-[13px] font-medium text-white mb-1.5">
158
158
  {prop.name}
159
159
  {prop.required && <span class="text-cms-error ml-1">*</span>}
@@ -164,9 +164,6 @@ export function PropEditor({ prop, value, onChange }: PropEditorProps) {
164
164
  </div>
165
165
  )}
166
166
  {renderPropInput(prop, value, onChange)}
167
- <div class="text-[10px] text-white/40 mt-1.5 font-mono">
168
- {prop.type}
169
- </div>
170
167
  </div>
171
168
  )
172
169
  }
@@ -232,7 +229,7 @@ function ReferenceSelect({ collection, value, required, onChange }: {
232
229
  const slug = slugify(newName.trim())
233
230
  return (
234
231
  <form
235
- class="p-3 bg-white/5 border border-white/15 rounded-cms-md space-y-3"
232
+ class="p-3 bg-white/5 border border-white/15 rounded-cms-sm space-y-3"
236
233
  onSubmit={(e) => {
237
234
  e.preventDefault()
238
235
  handleCreate()
@@ -256,7 +253,7 @@ function ReferenceSelect({ collection, value, required, onChange }: {
256
253
  onInput={(e) => setNewName((e.target as HTMLInputElement).value)}
257
254
  placeholder="Enter name..."
258
255
  required
259
- class="w-full px-4 py-2.5 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-md"
256
+ class="w-full px-4 py-2 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-sm"
260
257
  autoFocus
261
258
  />
262
259
  <div class="text-[11px] text-white/40 font-mono">
@@ -279,13 +276,13 @@ function ReferenceSelect({ collection, value, required, onChange }: {
279
276
  <button
280
277
  type="button"
281
278
  onClick={resetCreateForm}
282
- class="px-3 py-1.5 text-[12px] text-white/60 hover:text-white bg-white/5 hover:bg-white/10 border border-white/10 rounded-cms-md transition-colors"
279
+ class="px-3 py-1.5 text-[12px] text-white/60 hover:text-white bg-white/5 hover:bg-white/10 border border-white/10 rounded-cms-sm transition-colors"
283
280
  >
284
281
  Cancel
285
282
  </button>
286
283
  <button
287
284
  type="submit"
288
- class="px-3 py-1.5 text-[12px] bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover rounded-cms-md transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
285
+ class="px-3 py-1.5 text-[12px] bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover rounded-cms-sm transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
289
286
  >
290
287
  Create
291
288
  </button>
@@ -301,7 +298,7 @@ function ReferenceSelect({ collection, value, required, onChange }: {
301
298
  value={value}
302
299
  onInput={(e) => onChange((e.target as HTMLInputElement).value)}
303
300
  placeholder={`Enter ${collection} entry ID...`}
304
- class="w-full px-4 py-2.5 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-md"
301
+ class="w-full px-4 py-2 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-sm"
305
302
  />
306
303
  )
307
304
  }
@@ -319,14 +316,14 @@ function ReferenceSelect({ collection, value, required, onChange }: {
319
316
  onFocus={() => setIsOpen(true)}
320
317
  onBlur={() => setTimeout(closeDropdown, 150)}
321
318
  placeholder={`Select ${collection} entry...`}
322
- class="w-full px-4 py-2.5 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-md"
319
+ class="w-full px-4 py-2 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-sm"
323
320
  />
324
321
  <DropdownPanel
325
322
  triggerRef={inputRef}
326
323
  isOpen={isOpen}
327
324
  onClose={closeDropdown}
328
325
  maxHeight={192}
329
- className="border border-white/20 rounded-cms-md"
326
+ className="border border-white/20 rounded-cms-sm"
330
327
  >
331
328
  {!required && (
332
329
  <button
@@ -9,8 +9,9 @@ import {
9
9
  setRedirectsManagerRules,
10
10
  showToast,
11
11
  } from '../signals'
12
+ import { STRINGS } from '../strings'
12
13
  import type { RedirectRule } from '../types'
13
- import { ModalBackdrop, ModalHeader } from './modal-shell'
14
+ import { ModalBackdrop, ModalHeader, Section } from './modal-shell'
14
15
 
15
16
  export function RedirectsManager() {
16
17
  const visible = isRedirectsManagerOpen.value
@@ -24,27 +25,33 @@ export function RedirectsManager() {
24
25
  <ModalBackdrop onClose={() => closeRedirectsManager()} maxWidth="max-w-2xl" extraClass="max-h-[80vh] flex flex-col">
25
26
  <ModalHeader title="Redirects" onClose={() => closeRedirectsManager()} />
26
27
 
27
- <div class="flex-1 overflow-y-auto p-5 space-y-3">
28
- {state.isLoading && <div class="text-center py-8 text-white/50">Loading redirects...</div>}
28
+ <div class="flex-1 overflow-y-auto p-5">
29
+ <Section title="Redirects">
30
+ <div class="space-y-3">
31
+ {state.isLoading && <div class="text-center py-8 text-white/50">Loading redirects...</div>}
29
32
 
30
- {!state.isLoading && state.rules.length === 0 && (
31
- <div class="text-center py-8">
32
- <p class="text-white/50 mb-2">No redirects configured</p>
33
- <p class="text-white/30 text-sm">Redirects are stored in src/_redirects</p>
34
- </div>
35
- )}
33
+ {!state.isLoading && state.rules.length === 0 && (
34
+ <div class="text-center py-8">
35
+ <p class="text-white/50 mb-2">No redirects configured</p>
36
+ <p class="text-white/30 text-sm">Redirects are stored in src/_redirects</p>
37
+ </div>
38
+ )}
36
39
 
37
- {!state.isLoading && state.rules.map((rule) => (
38
- <RedirectRow
39
- key={rule.lineIndex}
40
- rule={rule}
41
- isEditing={state.editingIndex === rule.lineIndex}
42
- />
43
- ))}
40
+ {!state.isLoading && state.rules.map((rule) => (
41
+ <RedirectRow
42
+ key={rule.lineIndex}
43
+ rule={rule}
44
+ isEditing={state.editingIndex === rule.lineIndex}
45
+ />
46
+ ))}
47
+ </div>
48
+ </Section>
44
49
  </div>
45
50
 
46
- <div class="shrink-0 p-5 border-t border-white/10 bg-white/5 rounded-b-cms-xl">
47
- <AddRedirectForm />
51
+ <div class="shrink-0 p-5 pb-6 border-t border-white/10 bg-white/5 rounded-b-cms-xl">
52
+ <Section title="Add redirect">
53
+ <AddRedirectForm />
54
+ </Section>
48
55
  </div>
49
56
  </ModalBackdrop>
50
57
  )
@@ -72,9 +79,9 @@ function RedirectRow({ rule, isEditing }: { rule: RedirectRule; isEditing: boole
72
79
  if (result.success) {
73
80
  setRedirectsManagerEditing(null)
74
81
  await refreshRedirects()
75
- showToast('Redirect updated', 'success')
82
+ showToast(STRINGS.redirects.updated, 'success')
76
83
  } else {
77
- showToast(result.error || 'Failed to update', 'error')
84
+ showToast(result.error || STRINGS.redirects.updateFailed, 'error')
78
85
  }
79
86
  }, [rule.lineIndex, source, destination, statusCode])
80
87
 
@@ -85,21 +92,21 @@ function RedirectRow({ rule, isEditing }: { rule: RedirectRule; isEditing: boole
85
92
  const result = await deleteRedirect(cfg, { lineIndex: rule.lineIndex })
86
93
  if (result.success) {
87
94
  await refreshRedirects()
88
- showToast('Redirect deleted', 'success')
95
+ showToast(STRINGS.redirects.deleted, 'success')
89
96
  } else {
90
- showToast(result.error || 'Failed to delete', 'error')
97
+ showToast(result.error || STRINGS.redirects.deleteFailed, 'error')
91
98
  }
92
99
  }, [rule.lineIndex])
93
100
 
94
101
  if (isEditing) {
95
102
  return (
96
- <div class="flex flex-col gap-2 p-3 bg-white/5 rounded-cms-lg border border-white/10">
103
+ <div class="flex flex-col gap-2 p-3 bg-white/5 rounded-cms-sm border border-white/10">
97
104
  <div class="flex gap-2">
98
105
  <input
99
106
  type="text"
100
107
  value={source}
101
108
  onInput={(e) => setSource((e.target as HTMLInputElement).value)}
102
- class="flex-1 px-2.5 py-1.5 bg-white/5 border border-white/10 rounded-cms-md text-white text-sm focus:outline-none focus:border-cms-primary/50"
109
+ class="flex-1 px-2.5 py-1.5 bg-white/5 border border-white/10 rounded-cms-sm text-white text-sm focus:outline-none focus:border-white/40"
103
110
  placeholder="/old-path"
104
111
  data-cms-ui
105
112
  />
@@ -108,7 +115,7 @@ function RedirectRow({ rule, isEditing }: { rule: RedirectRule; isEditing: boole
108
115
  type="text"
109
116
  value={destination}
110
117
  onInput={(e) => setDestination((e.target as HTMLInputElement).value)}
111
- class="flex-1 px-2.5 py-1.5 bg-white/5 border border-white/10 rounded-cms-md text-white text-sm focus:outline-none focus:border-cms-primary/50"
118
+ class="flex-1 px-2.5 py-1.5 bg-white/5 border border-white/10 rounded-cms-sm text-white text-sm focus:outline-none focus:border-white/40"
112
119
  placeholder="/new-path"
113
120
  data-cms-ui
114
121
  />
@@ -116,7 +123,7 @@ function RedirectRow({ rule, isEditing }: { rule: RedirectRule; isEditing: boole
116
123
  type="text"
117
124
  value={statusCode}
118
125
  onInput={(e) => setStatusCode((e.target as HTMLInputElement).value)}
119
- class="w-16 px-2.5 py-1.5 bg-white/5 border border-white/10 rounded-cms-md text-white text-sm text-center focus:outline-none focus:border-cms-primary/50"
126
+ class="w-16 px-2.5 py-1.5 bg-white/5 border border-white/10 rounded-cms-sm text-white text-sm text-center focus:outline-none focus:border-white/40"
120
127
  placeholder="307"
121
128
  data-cms-ui
122
129
  />
@@ -125,7 +132,7 @@ function RedirectRow({ rule, isEditing }: { rule: RedirectRule; isEditing: boole
125
132
  <button
126
133
  type="button"
127
134
  onClick={() => setRedirectsManagerEditing(null)}
128
- class="px-3 py-1.5 text-xs text-white/60 hover:text-white hover:bg-white/10 rounded-cms-md transition-colors cursor-pointer"
135
+ class="px-3 py-1.5 text-xs text-white/60 hover:text-white hover:bg-white/10 rounded-cms-sm transition-colors cursor-pointer"
129
136
  data-cms-ui
130
137
  >
131
138
  Cancel
@@ -134,7 +141,7 @@ function RedirectRow({ rule, isEditing }: { rule: RedirectRule; isEditing: boole
134
141
  type="button"
135
142
  onClick={handleSave}
136
143
  disabled={isSaving}
137
- class="px-3 py-1.5 text-xs font-medium bg-cms-primary text-cms-primary-text rounded-cms-md hover:bg-cms-primary-hover transition-colors cursor-pointer disabled:opacity-40"
144
+ class="px-3 py-1.5 text-xs font-medium bg-cms-primary text-cms-primary-text rounded-cms-sm hover:bg-cms-primary-hover transition-colors cursor-pointer disabled:opacity-40"
138
145
  data-cms-ui
139
146
  >
140
147
  {isSaving ? 'Saving...' : 'Save'}
@@ -145,14 +152,14 @@ function RedirectRow({ rule, isEditing }: { rule: RedirectRule; isEditing: boole
145
152
  }
146
153
 
147
154
  return (
148
- <div class="flex items-center gap-3 p-3 bg-white/5 rounded-cms-lg border border-white/10 group">
155
+ <div class="flex items-center gap-3 p-3 bg-white/5 rounded-cms-sm border border-white/10 group h-[34px]">
149
156
  <div class="flex-1 min-w-0 flex items-center gap-2 text-sm">
150
157
  <span class="text-white/80 truncate">{rule.source}</span>
151
158
  <span class="text-white/30 shrink-0">→</span>
152
159
  <span class="text-white/60 truncate">{rule.destination}</span>
153
160
  </div>
154
161
  <span class="text-xs text-white/30 tabular-nums shrink-0">{rule.statusCode}</span>
155
- <div class="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
162
+ <div class="flex gap-1 shrink-0 -ml-3 max-w-0 overflow-hidden opacity-0 group-hover:ml-0 group-hover:max-w-[80px] group-hover:opacity-100 transition-all duration-200 ease-out">
156
163
  <button
157
164
  type="button"
158
165
  onClick={() => setRedirectsManagerEditing(rule.lineIndex)}
@@ -211,9 +218,9 @@ function AddRedirectForm() {
211
218
  setSource('')
212
219
  setDestination('')
213
220
  await refreshRedirects()
214
- showToast('Redirect added', 'success')
221
+ showToast(STRINGS.redirects.added, 'success')
215
222
  } else {
216
- showToast(result.error || 'Failed to add redirect', 'error')
223
+ showToast(result.error || STRINGS.redirects.addFailed, 'error')
217
224
  }
218
225
  }, [source, destination])
219
226
 
@@ -226,7 +233,7 @@ function AddRedirectForm() {
226
233
  value={source}
227
234
  onInput={(e) => setSource((e.target as HTMLInputElement).value)}
228
235
  placeholder="/old-path"
229
- class="w-full px-2.5 py-1.5 bg-white/5 border border-white/10 rounded-cms-md text-white text-sm placeholder:text-white/30 focus:outline-none focus:border-cms-primary/50"
236
+ class="w-full px-2.5 py-1.5 bg-white/5 border border-white/10 rounded-cms-sm text-white text-sm placeholder:text-white/30 focus:outline-none focus:border-white/40"
230
237
  data-cms-ui
231
238
  />
232
239
  </div>
@@ -237,7 +244,7 @@ function AddRedirectForm() {
237
244
  value={destination}
238
245
  onInput={(e) => setDestination((e.target as HTMLInputElement).value)}
239
246
  placeholder="/new-path"
240
- class="w-full px-2.5 py-1.5 bg-white/5 border border-white/10 rounded-cms-md text-white text-sm placeholder:text-white/30 focus:outline-none focus:border-cms-primary/50"
247
+ class="w-full px-2.5 py-1.5 bg-white/5 border border-white/10 rounded-cms-sm text-white text-sm placeholder:text-white/30 focus:outline-none focus:border-white/40"
241
248
  data-cms-ui
242
249
  />
243
250
  </div>
@@ -245,7 +252,7 @@ function AddRedirectForm() {
245
252
  type="button"
246
253
  onClick={handleAdd}
247
254
  disabled={isAdding || !source.trim() || !destination.trim()}
248
- class="px-4 py-1.5 text-sm font-medium bg-cms-primary text-cms-primary-text rounded-cms-md hover:bg-cms-primary-hover transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
255
+ class="px-4 py-1.5 text-sm font-medium bg-cms-primary text-cms-primary-text rounded-cms-sm hover:bg-cms-primary-hover transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
249
256
  data-cms-ui
250
257
  >
251
258
  {isAdding ? 'Adding...' : 'Add'}
@@ -5,6 +5,7 @@ import { useSearchFilter } from '../hooks/useSearchFilter'
5
5
  import { getCollectionEntryOptions } from '../manifest'
6
6
  import { updateMarkdownPage } from '../markdown-api'
7
7
  import { closeReferencePicker, config, manifest, referencePickerState, showToast } from '../signals'
8
+ import { STRINGS } from '../strings'
8
9
  import { Spinner } from './spinner'
9
10
 
10
11
  const PANEL_WIDTH = 320
@@ -46,12 +47,12 @@ export function ReferencePicker() {
46
47
  frontmatter: { [state.fieldName]: value },
47
48
  })
48
49
  if (result.success) {
49
- showToast('Reference updated', 'success')
50
+ showToast(STRINGS.reference.updated, 'success')
50
51
  } else {
51
- showToast(result.error || 'Failed to update reference', 'error')
52
+ showToast(result.error || STRINGS.reference.updateFailed, 'error')
52
53
  }
53
54
  } catch {
54
- showToast('Failed to update reference', 'error')
55
+ showToast(STRINGS.reference.updateFailed, 'error')
55
56
  }
56
57
  closeReferencePicker()
57
58
  }, [state.fieldName, state.ownerPath])
@@ -153,7 +154,7 @@ export function ReferencePicker() {
153
154
  }`}
154
155
  >
155
156
  {isSelected && (
156
- <svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
157
+ <svg class="w-3 h-3 text-cms-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
157
158
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
158
159
  </svg>
159
160
  )}
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useState } from 'preact/hooks'
2
2
  import { saveBatchChanges } from '../api'
3
3
  import { isApplyingUndoRedo, recordChange } from '../history'
4
+ import { cn } from '../lib/cn'
4
5
  import {
5
6
  clearPendingSeoChanges,
6
7
  closeSeoEditor,
@@ -14,9 +15,10 @@ import {
14
15
  setPendingSeoChange,
15
16
  showToast,
16
17
  } from '../signals'
18
+ import { STRINGS } from '../strings'
17
19
  import type { ChangePayload, PageSeoData, PendingSeoChange } from '../types'
18
20
  import { ColorField, ComboBoxField, ImageField } from './fields'
19
- import { CancelButton, CloseButton, ModalBackdrop } from './modal-shell'
21
+ import { CancelButton, CloseButton, ModalBackdrop, Section } from './modal-shell'
20
22
  import { Spinner } from './spinner'
21
23
 
22
24
  const OG_TYPE_OPTIONS = [
@@ -55,10 +57,11 @@ interface SeoFieldProps {
55
57
  value: string | undefined
56
58
  placeholder?: string
57
59
  multiline?: boolean
60
+ tooltip?: string
58
61
  onChange: (id: string, value: string, originalValue: string) => void
59
62
  }
60
63
 
61
- function SeoField({ label, id, value, placeholder, multiline, onChange }: SeoFieldProps) {
64
+ function SeoField({ label, id, value, placeholder, multiline, tooltip, onChange }: SeoFieldProps) {
62
65
  const pendingChange = id ? getPendingSeoChange(id) : undefined
63
66
  const currentValue = pendingChange?.newValue ?? value ?? ''
64
67
  const isDirty = pendingChange?.isDirty ?? false
@@ -75,7 +78,19 @@ function SeoField({ label, id, value, placeholder, multiline, onChange }: SeoFie
75
78
  return (
76
79
  <div class="space-y-1.5">
77
80
  <div class="flex items-center justify-between">
78
- <label class="text-sm font-medium text-white/80">{label}</label>
81
+ <div class="flex items-center gap-1.5">
82
+ <label class="text-sm font-medium text-white/80">{label}</label>
83
+ {tooltip && (
84
+ <span class="relative group/tooltip inline-flex" data-cms-ui>
85
+ <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">
86
+ <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" />
87
+ </svg>
88
+ <span class="absolute left-0 top-full mt-1 w-64 p-2 bg-black/90 text-white text-xs rounded-cms-sm opacity-0 invisible group-hover/tooltip:opacity-100 group-hover/tooltip:visible transition-all z-10 pointer-events-none">
89
+ {tooltip}
90
+ </span>
91
+ </span>
92
+ )}
93
+ </div>
79
94
  {isDirty && <span class="text-xs text-cms-primary font-medium">Modified</span>}
80
95
  </div>
81
96
  <InputComponent
@@ -84,11 +99,12 @@ function SeoField({ label, id, value, placeholder, multiline, onChange }: SeoFie
84
99
  placeholder={placeholder ?? `Enter ${label.toLowerCase()}...`}
85
100
  onInput={handleChange}
86
101
  disabled={!id}
87
- class={`w-full px-4 py-2.5 bg-white/10 border rounded-cms-md text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors ${
88
- isDirty
89
- ? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
90
- : 'border-white/20 focus:border-white/40 focus:ring-white/10'
91
- } ${!id ? 'opacity-50 cursor-not-allowed' : ''} ${multiline ? 'min-h-20 resize-y' : ''}`}
102
+ class={cn(
103
+ 'w-full p-2.5 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors focus:border-white/40 focus:ring-white/10',
104
+ isDirty ? 'border-white/30' : 'border-white/20',
105
+ !id && 'opacity-50 cursor-not-allowed',
106
+ multiline && 'min-h-20 resize-y',
107
+ )}
92
108
  data-cms-ui
93
109
  />
94
110
  </div>
@@ -107,19 +123,13 @@ function useSeoMeta(tag: { id?: string; content: string } | undefined) {
107
123
  }
108
124
  }
109
125
 
110
- interface SeoSectionProps {
111
- title: string
112
- children: preact.ComponentChildren
113
- }
114
-
115
- function SeoSection({ title, children }: SeoSectionProps) {
126
+ function SeoSection({ title, children }: { title: string; children: preact.ComponentChildren }) {
116
127
  return (
117
- <div class="space-y-4">
118
- <h3 class="text-sm font-semibold text-white/60 uppercase tracking-wider">{title}</h3>
128
+ <Section title={title}>
119
129
  <div class="space-y-4">
120
130
  {children}
121
131
  </div>
122
- </div>
132
+ </Section>
123
133
  )
124
134
  }
125
135
 
@@ -222,14 +232,14 @@ export function SeoEditor() {
222
232
 
223
233
  if (result.errors && result.errors.length > 0) {
224
234
  const details = result.errors.map(e => e.error).join('; ')
225
- showToast(`SEO save failed: ${details}`, 'error')
235
+ showToast(STRINGS.seo.saveFailed(details), 'error')
226
236
  } else {
227
- showToast(`Saved ${result.updated} SEO change(s) successfully!`, 'success')
237
+ showToast(STRINGS.seo.saveSuccess(result.updated), 'success')
228
238
  clearPendingSeoChanges()
229
239
  closeSeoEditor()
230
240
  }
231
241
  } catch (error) {
232
- showToast(error instanceof Error ? error.message : 'Failed to save SEO changes', 'error')
242
+ showToast(error instanceof Error ? error.message : STRINGS.seo.saveFailedFallback, 'error')
233
243
  } finally {
234
244
  setIsSaving(false)
235
245
  }
@@ -260,7 +270,7 @@ export function SeoEditor() {
260
270
  return (
261
271
  <ModalBackdrop onClose={handleClose} maxWidth="max-w-2xl" extraClass="max-h-[85vh] flex flex-col">
262
272
  {/* Header */}
263
- <div class="flex items-center justify-between p-5 border-b border-white/10">
273
+ <div class="flex items-center justify-between px-5 py-4 border-b border-white/10">
264
274
  <div class="flex items-center gap-3">
265
275
  <h2 class="text-lg font-semibold text-white">SEO Settings</h2>
266
276
  {dirtyCount > 0 && (
@@ -327,6 +337,7 @@ export function SeoEditor() {
327
337
  id={seoData.canonical.id}
328
338
  value={seoData.canonical.href}
329
339
  placeholder="https://example.com/page"
340
+ tooltip="The preferred URL for this page. Tells search engines which version to index when the same content is reachable from multiple URLs (e.g. with/without query parameters)."
330
341
  onChange={handleFieldChange}
331
342
  />
332
343
  )}
@@ -376,7 +387,6 @@ export function SeoEditor() {
376
387
  <ImageField
377
388
  label={label}
378
389
  value={currentValue}
379
- placeholder="/favicon.svg"
380
390
  onChange={(v) => {
381
391
  if (faviconId) handleFieldChange(faviconId, v, originalValue)
382
392
  }}
@@ -419,7 +429,6 @@ export function SeoEditor() {
419
429
  <ImageField
420
430
  label="OG Image"
421
431
  value={ogImage.current}
422
- placeholder="/images/og-image.jpg"
423
432
  onChange={(v) => {
424
433
  if (ogImage.id) handleFieldChange(ogImage.id, v, ogImage.original)
425
434
  }}
@@ -502,7 +511,6 @@ export function SeoEditor() {
502
511
  <ImageField
503
512
  label="Twitter Image"
504
513
  value={twitterImage.current}
505
- placeholder="/images/twitter-image.jpg"
506
514
  onChange={(v) => {
507
515
  if (twitterImage.id) handleFieldChange(twitterImage.id, v, twitterImage.original)
508
516
  }}
@@ -555,11 +563,12 @@ export function SeoEditor() {
555
563
  type="button"
556
564
  onClick={handleSaveAll}
557
565
  disabled={dirtyCount === 0 || isSaving}
558
- class={`px-5 py-2 text-sm font-medium rounded-cms-pill transition-colors flex items-center gap-2 ${
566
+ class={cn(
567
+ 'px-5 py-2 text-sm font-medium rounded-cms-pill transition-colors flex items-center gap-2',
559
568
  dirtyCount > 0 && !isSaving
560
569
  ? 'bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover'
561
- : 'bg-white/10 text-white/40 cursor-not-allowed'
562
- }`}
570
+ : 'bg-white/10 text-white/40 cursor-not-allowed',
571
+ )}
563
572
  data-cms-ui
564
573
  >
565
574
  {isSaving && <Spinner />}