@actuate-media/cms-admin 0.36.0 → 0.37.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 (100) hide show
  1. package/dist/__tests__/views/appearance-settings.render.test.js +4 -4
  2. package/dist/__tests__/views/updates-tab.render.test.d.ts +2 -0
  3. package/dist/__tests__/views/updates-tab.render.test.d.ts.map +1 -0
  4. package/dist/__tests__/views/updates-tab.render.test.js +132 -0
  5. package/dist/__tests__/views/updates-tab.render.test.js.map +1 -0
  6. package/dist/actuate-admin.css +1 -1
  7. package/dist/views/ApiKeys.d.ts.map +1 -1
  8. package/dist/views/ApiKeys.js +14 -13
  9. package/dist/views/ApiKeys.js.map +1 -1
  10. package/dist/views/Profile.d.ts.map +1 -1
  11. package/dist/views/Profile.js +1 -1
  12. package/dist/views/Profile.js.map +1 -1
  13. package/dist/views/Settings.d.ts.map +1 -1
  14. package/dist/views/Settings.js +16 -119
  15. package/dist/views/Settings.js.map +1 -1
  16. package/dist/views/Users.js +1 -1
  17. package/dist/views/Users.js.map +1 -1
  18. package/dist/views/settings/AISettingsTab.js +40 -42
  19. package/dist/views/settings/AISettingsTab.js.map +1 -1
  20. package/dist/views/settings/AdminThemeCard.js +1 -1
  21. package/dist/views/settings/AiFeaturesCard.d.ts.map +1 -1
  22. package/dist/views/settings/AiFeaturesCard.js +2 -3
  23. package/dist/views/settings/AiFeaturesCard.js.map +1 -1
  24. package/dist/views/settings/BrandPreviewCard.js +1 -1
  25. package/dist/views/settings/BrandVoiceCard.d.ts.map +1 -1
  26. package/dist/views/settings/BrandVoiceCard.js +2 -4
  27. package/dist/views/settings/BrandVoiceCard.js.map +1 -1
  28. package/dist/views/settings/BrandingCard.d.ts.map +1 -1
  29. package/dist/views/settings/BrandingCard.js +34 -19
  30. package/dist/views/settings/BrandingCard.js.map +1 -1
  31. package/dist/views/settings/GeneralSettingsTab.js +1 -1
  32. package/dist/views/settings/LanguageRegionCard.d.ts.map +1 -1
  33. package/dist/views/settings/LanguageRegionCard.js +2 -1
  34. package/dist/views/settings/LanguageRegionCard.js.map +1 -1
  35. package/dist/views/settings/MonthlyUsageCard.d.ts.map +1 -1
  36. package/dist/views/settings/MonthlyUsageCard.js +3 -3
  37. package/dist/views/settings/MonthlyUsageCard.js.map +1 -1
  38. package/dist/views/settings/ProviderConnectionCard.js +1 -1
  39. package/dist/views/settings/ProviderConnectionCard.js.map +1 -1
  40. package/dist/views/settings/SecurityStatusCard.d.ts.map +1 -1
  41. package/dist/views/settings/SecurityStatusCard.js +2 -3
  42. package/dist/views/settings/SecurityStatusCard.js.map +1 -1
  43. package/dist/views/settings/SecurityTab.js +1 -1
  44. package/dist/views/settings/SeoRobotsDefaultsCard.d.ts.map +1 -1
  45. package/dist/views/settings/SeoRobotsDefaultsCard.js +3 -4
  46. package/dist/views/settings/SeoRobotsDefaultsCard.js.map +1 -1
  47. package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
  48. package/dist/views/settings/SeoSettingsTab.js +5 -8
  49. package/dist/views/settings/SeoSettingsTab.js.map +1 -1
  50. package/dist/views/settings/SiteIndexStatusCard.d.ts.map +1 -1
  51. package/dist/views/settings/SiteIndexStatusCard.js +3 -2
  52. package/dist/views/settings/SiteIndexStatusCard.js.map +1 -1
  53. package/dist/views/settings/SiteInformationCard.d.ts.map +1 -1
  54. package/dist/views/settings/SiteInformationCard.js +4 -4
  55. package/dist/views/settings/SiteInformationCard.js.map +1 -1
  56. package/dist/views/settings/TypographyMotionCard.js +1 -1
  57. package/dist/views/settings/UpdatesTab.d.ts +6 -0
  58. package/dist/views/settings/UpdatesTab.d.ts.map +1 -0
  59. package/dist/views/settings/UpdatesTab.js +80 -0
  60. package/dist/views/settings/UpdatesTab.js.map +1 -0
  61. package/dist/views/settings/components.d.ts +51 -1
  62. package/dist/views/settings/components.d.ts.map +1 -1
  63. package/dist/views/settings/components.js +33 -0
  64. package/dist/views/settings/components.js.map +1 -1
  65. package/dist/views/settings/securityPrimitives.d.ts +11 -3
  66. package/dist/views/settings/securityPrimitives.d.ts.map +1 -1
  67. package/dist/views/settings/securityPrimitives.js +10 -3
  68. package/dist/views/settings/securityPrimitives.js.map +1 -1
  69. package/dist/views/settings/useUpdates.d.ts +67 -0
  70. package/dist/views/settings/useUpdates.d.ts.map +1 -0
  71. package/dist/views/settings/useUpdates.js +127 -0
  72. package/dist/views/settings/useUpdates.js.map +1 -0
  73. package/package.json +2 -2
  74. package/src/__tests__/views/appearance-settings.render.test.tsx +4 -4
  75. package/src/__tests__/views/updates-tab.render.test.tsx +161 -0
  76. package/src/views/ApiKeys.tsx +190 -154
  77. package/src/views/Profile.tsx +4 -2
  78. package/src/views/Settings.tsx +26 -451
  79. package/src/views/Users.tsx +1 -1
  80. package/src/views/settings/AISettingsTab.tsx +64 -76
  81. package/src/views/settings/AdminThemeCard.tsx +1 -1
  82. package/src/views/settings/AiFeaturesCard.tsx +6 -12
  83. package/src/views/settings/BrandPreviewCard.tsx +1 -1
  84. package/src/views/settings/BrandVoiceCard.tsx +6 -19
  85. package/src/views/settings/BrandingCard.tsx +179 -180
  86. package/src/views/settings/GeneralSettingsTab.tsx +1 -1
  87. package/src/views/settings/LanguageRegionCard.tsx +5 -7
  88. package/src/views/settings/MonthlyUsageCard.tsx +6 -10
  89. package/src/views/settings/ProviderConnectionCard.tsx +1 -1
  90. package/src/views/settings/SecurityStatusCard.tsx +8 -11
  91. package/src/views/settings/SecurityTab.tsx +1 -1
  92. package/src/views/settings/SeoRobotsDefaultsCard.tsx +7 -14
  93. package/src/views/settings/SeoSettingsTab.tsx +17 -26
  94. package/src/views/settings/SiteIndexStatusCard.tsx +10 -22
  95. package/src/views/settings/SiteInformationCard.tsx +3 -9
  96. package/src/views/settings/TypographyMotionCard.tsx +1 -1
  97. package/src/views/settings/UpdatesTab.tsx +545 -0
  98. package/src/views/settings/components.tsx +123 -1
  99. package/src/views/settings/securityPrimitives.tsx +14 -9
  100. package/src/views/settings/useUpdates.ts +183 -0
@@ -11,6 +11,7 @@ import {
11
11
  } from 'lucide-react'
12
12
  import { useRef } from 'react'
13
13
 
14
+ import { SettingsCard, SettingsControlRow, SettingsSubsection } from './components.js'
14
15
  import type { BrandAssetField, UseAppearanceSettings } from './useAppearanceSettings.js'
15
16
 
16
17
  const LOGO_ACCEPT = 'image/svg+xml,image/png'
@@ -54,82 +55,131 @@ function LogoField({
54
55
  const uploading = uploadingField === field
55
56
  const inputId = `brand-${field}`
56
57
 
57
- return (
58
- <div className="flex items-start gap-4">
59
- <div
60
- className={`flex shrink-0 items-center justify-center overflow-hidden rounded-md border p-2 ${previewSurface(
61
- Boolean(darkSurface),
62
- )} ${square ? 'h-12 w-12' : 'h-12 w-20'}`}
63
- aria-hidden={!asset}
58
+ const thumbnail = (
59
+ <div
60
+ className={`flex shrink-0 items-center justify-center overflow-hidden rounded-md border p-2 ${previewSurface(
61
+ Boolean(darkSurface),
62
+ )} ${square ? 'h-12 w-12' : 'h-12 w-20'}`}
63
+ aria-hidden={!asset}
64
+ >
65
+ {asset?.url ? (
66
+ // Never stretch/crop — contain preserves aspect ratio + transparency.
67
+ <img
68
+ src={asset.url}
69
+ alt={`${label} preview`}
70
+ className="max-h-full max-w-full object-contain"
71
+ />
72
+ ) : (
73
+ <ImageIcon size={18} className="text-muted-foreground" aria-hidden="true" />
74
+ )}
75
+ </div>
76
+ )
77
+
78
+ const controls = (
79
+ <>
80
+ <input
81
+ ref={inputRef}
82
+ id={inputId}
83
+ type="file"
84
+ accept={accept}
85
+ aria-label={label}
86
+ className="sr-only"
87
+ disabled={!canEdit || uploading}
88
+ onChange={(e) => {
89
+ const file = e.target.files?.[0]
90
+ if (file) onUpload(field, file)
91
+ if (inputRef.current) inputRef.current.value = ''
92
+ }}
93
+ />
94
+ {asset && (
95
+ <button
96
+ type="button"
97
+ onClick={() => onRemove(field)}
98
+ disabled={!canEdit}
99
+ className="text-muted-foreground hover:text-destructive inline-flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50"
100
+ aria-label={`Remove ${label.toLowerCase()}`}
101
+ >
102
+ <Trash2 size={14} aria-hidden="true" /> Remove
103
+ </button>
104
+ )}
105
+ <button
106
+ type="button"
107
+ onClick={() => inputRef.current?.click()}
108
+ disabled={!canEdit || uploading}
109
+ aria-label={`${asset ? 'Replace' : 'Upload'} ${label.toLowerCase()}`}
110
+ className="border-border text-foreground hover:bg-accent inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50"
64
111
  >
65
- {asset?.url ? (
66
- // Never stretch/crop contain preserves aspect ratio + transparency.
67
- <img
68
- src={asset.url}
69
- alt={`${label} preview`}
70
- className="max-h-full max-w-full object-contain"
71
- />
112
+ {uploading ? (
113
+ <Loader2 size={14} className="animate-spin" aria-hidden="true" />
72
114
  ) : (
73
- <ImageIcon size={18} className="text-muted-foreground" aria-hidden="true" />
115
+ <Upload size={14} aria-hidden="true" />
74
116
  )}
75
- </div>
117
+ {asset ? 'Replace' : 'Upload'}
118
+ </button>
119
+ </>
120
+ )
76
121
 
77
- <div className="min-w-0 flex-1">
78
- <label htmlFor={inputId} className="text-foreground block text-base font-medium">
79
- {label}
80
- </label>
81
- <p className="text-muted-foreground mt-0.5 text-sm">{helper}</p>
122
+ return (
123
+ <div>
124
+ <SettingsControlRow
125
+ leading={thumbnail}
126
+ label={label}
127
+ description={helper}
128
+ control={controls}
129
+ />
130
+ {warning && (
131
+ <p
132
+ className="text-warning mt-2 flex items-start gap-1.5 text-sm"
133
+ role="status"
134
+ aria-live="polite"
135
+ >
136
+ <AlertTriangle size={14} className="mt-0.5 shrink-0" aria-hidden="true" /> {warning}
137
+ </p>
138
+ )}
139
+ </div>
140
+ )
141
+ }
82
142
 
83
- <div className="mt-2 flex items-center gap-2">
84
- <input
85
- ref={inputRef}
86
- id={inputId}
87
- type="file"
88
- accept={accept}
89
- className="sr-only"
90
- disabled={!canEdit || uploading}
91
- onChange={(e) => {
92
- const file = e.target.files?.[0]
93
- if (file) onUpload(field, file)
94
- if (inputRef.current) inputRef.current.value = ''
95
- }}
96
- />
143
+ function AdminBrandSourceToggle({
144
+ mode,
145
+ canEdit,
146
+ onChange,
147
+ }: {
148
+ mode: UseAppearanceSettings['brand']['mode']
149
+ canEdit: boolean
150
+ onChange: (mode: 'inheritPublicBrand' | 'customAdminBrand') => void
151
+ }) {
152
+ return (
153
+ <div
154
+ role="radiogroup"
155
+ aria-label="Brand source"
156
+ className="border-border bg-muted inline-flex rounded-md border p-0.5"
157
+ >
158
+ {(
159
+ [
160
+ ['inheritPublicBrand', 'Inherit public brand'],
161
+ ['customAdminBrand', 'Custom admin logo'],
162
+ ] as const
163
+ ).map(([value, text]) => {
164
+ const active = mode === value
165
+ return (
97
166
  <button
167
+ key={value}
98
168
  type="button"
99
- onClick={() => inputRef.current?.click()}
100
- disabled={!canEdit || uploading}
101
- className="border-border text-foreground hover:bg-accent inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50"
169
+ role="radio"
170
+ aria-checked={active}
171
+ disabled={!canEdit}
172
+ onClick={() => onChange(value)}
173
+ className={`rounded px-3 py-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
174
+ active
175
+ ? 'bg-card text-foreground shadow-sm'
176
+ : 'text-muted-foreground hover:text-foreground'
177
+ }`}
102
178
  >
103
- {uploading ? (
104
- <Loader2 size={14} className="animate-spin" aria-hidden="true" />
105
- ) : (
106
- <Upload size={14} aria-hidden="true" />
107
- )}
108
- {asset ? 'Replace' : `Upload ${label.toLowerCase()}`}
179
+ {text}
109
180
  </button>
110
- {asset && (
111
- <button
112
- type="button"
113
- onClick={() => onRemove(field)}
114
- disabled={!canEdit}
115
- className="text-muted-foreground hover:text-destructive inline-flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50"
116
- aria-label={`Remove ${label}`}
117
- >
118
- <Trash2 size={14} aria-hidden="true" /> Remove
119
- </button>
120
- )}
121
- </div>
122
-
123
- {warning && (
124
- <p
125
- className="text-warning mt-2 flex items-start gap-1.5 text-sm"
126
- role="status"
127
- aria-live="polite"
128
- >
129
- <AlertTriangle size={14} className="mt-0.5 shrink-0" aria-hidden="true" /> {warning}
130
- </p>
131
- )}
132
- </div>
181
+ )
182
+ })}
133
183
  </div>
134
184
  )
135
185
  }
@@ -140,46 +190,40 @@ export function BrandingCard({ s, canEdit }: { s: UseAppearanceSettings; canEdit
140
190
 
141
191
  const custom = s.brand.mode === 'customAdminBrand'
142
192
 
143
- return (
144
- <section className="border-border bg-card rounded-lg border p-6">
145
- <header className="mb-4">
146
- <h3 className="text-foreground text-lg font-medium">Branding</h3>
147
- <p className="text-muted-foreground mt-0.5 text-sm">
148
- Logos shown in the admin sidebar and on the public site.
149
- </p>
150
- </header>
193
+ const logoFieldProps = {
194
+ accept: LOGO_ACCEPT,
195
+ brand: s.brand,
196
+ assets: s.assets,
197
+ uploadingField: s.uploadingField,
198
+ canEdit,
199
+ onUpload: s.uploadAsset,
200
+ onRemove: s.removeAsset,
201
+ }
151
202
 
203
+ return (
204
+ <SettingsCard
205
+ title="Branding"
206
+ description="Logos shown in the admin sidebar and on the public site."
207
+ >
152
208
  <div className="space-y-5">
153
209
  <LogoField
154
- label="Primary Logo"
210
+ label="Primary logo"
155
211
  helper="SVG or PNG, up to 1MB. Best for the public header and full admin sidebar."
156
212
  field="publicPrimaryLogoAssetId"
157
- accept={LOGO_ACCEPT}
158
- brand={s.brand}
159
- assets={s.assets}
160
- uploadingField={s.uploadingField}
161
- canEdit={canEdit}
162
213
  warning={warningFor('publicPrimaryLogoAssetId')}
163
- onUpload={s.uploadAsset}
164
- onRemove={s.removeAsset}
214
+ {...logoFieldProps}
165
215
  />
166
216
 
167
217
  <LogoField
168
- label="Favicon / App Icon Source"
218
+ label="Favicon / app icon source"
169
219
  helper="32×32 PNG or SVG. A larger square image is fine — it scales down cleanly."
170
220
  field="faviconSourceAssetId"
171
- accept={LOGO_ACCEPT}
172
221
  square
173
- brand={s.brand}
174
- assets={s.assets}
175
- uploadingField={s.uploadingField}
176
- canEdit={canEdit}
177
222
  warning={warningFor('faviconSourceAssetId')}
178
- onUpload={s.uploadAsset}
179
- onRemove={s.removeAsset}
223
+ {...logoFieldProps}
180
224
  />
181
225
 
182
- {/* Advanced logo variants disclosure (Logo Mark + Dark Mode Logo). */}
226
+ {/* Advanced logo variants disclosure (Logo mark + Dark mode logo). */}
183
227
  <div className="border-border border-t pt-4">
184
228
  <button
185
229
  type="button"
@@ -198,109 +242,64 @@ export function BrandingCard({ s, canEdit }: { s: UseAppearanceSettings; canEdit
198
242
  {s.showAdvanced && (
199
243
  <div className="mt-4 space-y-5">
200
244
  <LogoField
201
- label="Logo Mark"
245
+ label="Logo mark"
202
246
  helper="Square icon or mark. Used for the collapsed sidebar, compact UI, and app icons."
203
247
  field="publicLogoMarkAssetId"
204
- accept={LOGO_ACCEPT}
205
248
  square
206
- brand={s.brand}
207
- assets={s.assets}
208
- uploadingField={s.uploadingField}
209
- canEdit={canEdit}
210
249
  warning={warningFor('publicLogoMarkAssetId')}
211
- onUpload={s.uploadAsset}
212
- onRemove={s.removeAsset}
250
+ {...logoFieldProps}
213
251
  />
214
252
  <LogoField
215
- label="Dark Mode Logo"
253
+ label="Dark mode logo"
216
254
  helper="Optional. Used on dark backgrounds or the dark admin theme."
217
255
  field="publicDarkLogoAssetId"
218
- accept={LOGO_ACCEPT}
219
256
  darkSurface
220
- brand={s.brand}
221
- assets={s.assets}
222
- uploadingField={s.uploadingField}
223
- canEdit={canEdit}
224
257
  warning={warningFor('publicDarkLogoAssetId')}
225
- onUpload={s.uploadAsset}
226
- onRemove={s.removeAsset}
258
+ {...logoFieldProps}
227
259
  />
228
260
  </div>
229
261
  )}
230
262
  </div>
231
263
 
232
- {/* Admin branding mode — admin inherits the public brand by default. */}
233
- <div className="border-border border-t pt-4">
234
- <div className="flex items-center gap-2">
235
- <span className="text-foreground text-base font-medium">Admin branding</span>
236
- </div>
237
- <p className="text-muted-foreground mt-0.5 text-sm">
238
- Admin uses the public brand by default.
239
- </p>
240
- <div
241
- role="radiogroup"
242
- aria-label="Admin branding mode"
243
- className="border-border bg-muted mt-2 inline-flex rounded-md border p-0.5"
244
- >
245
- {(
246
- [
247
- ['inheritPublicBrand', 'Inherit public brand'],
248
- ['customAdminBrand', 'Custom admin logo'],
249
- ] as const
250
- ).map(([value, text]) => {
251
- const active = s.brand.mode === value
252
- return (
253
- <button
254
- key={value}
255
- type="button"
256
- role="radio"
257
- aria-checked={active}
258
- disabled={!canEdit}
259
- onClick={() => s.setBrandingMode(value)}
260
- className={`rounded px-3 py-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
261
- active
262
- ? 'bg-card text-foreground shadow-sm'
263
- : 'text-muted-foreground hover:text-foreground'
264
- }`}
265
- >
266
- {text}
267
- </button>
268
- )
269
- })}
270
- </div>
264
+ {/* Admin branding — admin inherits the public brand by default. */}
265
+ <SettingsSubsection
266
+ title="Admin branding"
267
+ description="By default the admin reuses your public brand. Override it for a distinct internal look."
268
+ >
269
+ <div className="mt-4 space-y-5">
270
+ <SettingsControlRow
271
+ label="Brand source"
272
+ description="Choose whether the admin inherits the public brand or uses a custom logo."
273
+ control={
274
+ <AdminBrandSourceToggle
275
+ mode={s.brand.mode}
276
+ canEdit={canEdit}
277
+ onChange={s.setBrandingMode}
278
+ />
279
+ }
280
+ />
271
281
 
272
- {custom && (
273
- <div className="mt-4 space-y-5">
274
- <LogoField
275
- label="Admin Primary Logo"
276
- helper="Shown in the admin sidebar instead of the public logo."
277
- field="adminPrimaryLogoAssetId"
278
- accept={LOGO_ACCEPT}
279
- brand={s.brand}
280
- assets={s.assets}
281
- uploadingField={s.uploadingField}
282
- canEdit={canEdit}
283
- warning={warningFor('adminPrimaryLogoAssetId')}
284
- onUpload={s.uploadAsset}
285
- onRemove={s.removeAsset}
286
- />
287
- <LogoField
288
- label="Admin Logo Mark"
289
- helper="Square mark for the collapsed admin sidebar."
290
- field="adminLogoMarkAssetId"
291
- accept={LOGO_ACCEPT}
292
- square
293
- brand={s.brand}
294
- assets={s.assets}
295
- uploadingField={s.uploadingField}
296
- canEdit={canEdit}
297
- warning={warningFor('adminLogoMarkAssetId')}
298
- onUpload={s.uploadAsset}
299
- onRemove={s.removeAsset}
300
- />
301
- </div>
302
- )}
303
- </div>
282
+ {custom && (
283
+ <div className="space-y-5">
284
+ <LogoField
285
+ label="Admin primary logo"
286
+ helper="Shown in the admin sidebar instead of the public logo."
287
+ field="adminPrimaryLogoAssetId"
288
+ warning={warningFor('adminPrimaryLogoAssetId')}
289
+ {...logoFieldProps}
290
+ />
291
+ <LogoField
292
+ label="Admin logo mark"
293
+ helper="Square mark for the collapsed admin sidebar."
294
+ field="adminLogoMarkAssetId"
295
+ square
296
+ warning={warningFor('adminLogoMarkAssetId')}
297
+ {...logoFieldProps}
298
+ />
299
+ </div>
300
+ )}
301
+ </div>
302
+ </SettingsSubsection>
304
303
 
305
304
  {/* Default alt text */}
306
305
  <div className="border-border border-t pt-4">
@@ -327,6 +326,6 @@ export function BrandingCard({ s, canEdit }: { s: UseAppearanceSettings; canEdit
327
326
  {s.saveError}
328
327
  </p>
329
328
  )}
330
- </section>
329
+ </SettingsCard>
331
330
  )
332
331
  }
@@ -25,7 +25,7 @@ export function GeneralSettingsTab({ onNavigate, role }: GeneralSettingsTabProps
25
25
  <div className="space-y-4" aria-busy="true" aria-live="polite">
26
26
  <span className="sr-only">Loading settings…</span>
27
27
  {[0, 1, 2, 3].map((i) => (
28
- <div key={i} className="border-border bg-card rounded-lg border p-4">
28
+ <div key={i} className="border-border bg-card rounded-lg border p-6">
29
29
  <div className="bg-muted mb-4 h-4 w-40 animate-pulse rounded" />
30
30
  <div className="space-y-3">
31
31
  <div className="bg-muted h-9 w-full animate-pulse rounded" />
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { useId, useMemo } from 'react'
4
+ import { SettingsCard } from './components.js'
4
5
  import type { GeneralSettingsForm, ValidationIssue } from './useGeneralSettings.js'
5
6
 
6
7
  const INPUT_CLASS =
@@ -58,13 +59,10 @@ export function LanguageRegionCard({ form, setField, issues, canEdit }: Language
58
59
  }, [form.timezone])
59
60
 
60
61
  return (
61
- <section
62
- aria-labelledby={`${langId}-heading`}
63
- className="border-border bg-card rounded-lg border p-4"
62
+ <SettingsCard
63
+ title="Language & Region"
64
+ description="Default language and timezone applied across the admin and public site."
64
65
  >
65
- <h3 id={`${langId}-heading`} className="text-foreground mb-4 text-base font-medium">
66
- Language &amp; Region
67
- </h3>
68
66
  <div className="space-y-4">
69
67
  <div>
70
68
  <label htmlFor={langId} className={LABEL_CLASS}>
@@ -114,6 +112,6 @@ export function LanguageRegionCard({ form, setField, issues, canEdit }: Language
114
112
  )}
115
113
  </div>
116
114
  </div>
117
- </section>
115
+ </SettingsCard>
118
116
  )
119
117
  }
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { useId } from 'react'
4
+ import { SettingsCard } from './components.js'
4
5
  import type { AiUsageView } from './useAiSettings.js'
5
6
 
6
7
  const INPUT_CLASS =
@@ -31,7 +32,6 @@ export function MonthlyUsageCard({
31
32
  budgetError: string | null
32
33
  canEdit: boolean
33
34
  }) {
34
- const headingId = useId()
35
35
  const limitId = useId()
36
36
 
37
37
  const used = usage?.totalTokens ?? 0
@@ -44,14 +44,10 @@ export function MonthlyUsageCard({
44
44
  : 'This month'
45
45
 
46
46
  return (
47
- <section aria-labelledby={headingId} className="border-border bg-card rounded-lg border p-4">
48
- <h3 id={headingId} className="text-foreground mb-1 text-base font-medium">
49
- Monthly Usage
50
- </h3>
51
- <p className="text-muted-foreground mb-4 text-sm">
52
- Token usage for {periodLabel}. Aggregated from completed AI runs.
53
- </p>
54
-
47
+ <SettingsCard
48
+ title="Monthly Usage"
49
+ description={`Token usage for ${periodLabel}. Aggregated from completed AI runs.`}
50
+ >
55
51
  <div className="space-y-4">
56
52
  <div>
57
53
  <div className="mb-1 flex items-center justify-between text-sm">
@@ -113,6 +109,6 @@ export function MonthlyUsageCard({
113
109
  )}
114
110
  </div>
115
111
  </div>
116
- </section>
112
+ </SettingsCard>
117
113
  )
118
114
  }
@@ -94,7 +94,7 @@ export function ProviderConnectionCard({
94
94
  }
95
95
 
96
96
  return (
97
- <div className="border-border bg-card rounded-lg border p-4">
97
+ <div className="border-border bg-background rounded-lg border p-4">
98
98
  <div className="mb-3 flex items-start justify-between gap-3">
99
99
  <div>
100
100
  <div className="flex items-center gap-2">
@@ -1,8 +1,8 @@
1
1
  'use client'
2
2
 
3
- import { useId } from 'react'
4
3
  import { Shield, ShieldAlert, ShieldCheck, ShieldX } from 'lucide-react'
5
4
  import type { SecurityStatus, SecurityWarning } from '@actuate-media/cms-core/security-center'
5
+ import { SettingsCard } from './components.js'
6
6
  import { CheckIcon } from './securityPrimitives.js'
7
7
 
8
8
  const LEVEL_STYLES: Record<
@@ -40,16 +40,14 @@ export function SecurityStatusCard({
40
40
  status: SecurityStatus
41
41
  onNavigate?: (path: string) => void
42
42
  }) {
43
- const headingId = useId()
44
43
  const style = LEVEL_STYLES[status.status]
45
44
  const { Icon } = style
46
45
 
47
46
  return (
48
- <section aria-labelledby={headingId} className="border-border bg-card rounded-lg border p-4">
49
- <div className="mb-1 flex items-start justify-between gap-3">
50
- <h3 id={headingId} className="text-foreground text-base font-medium">
51
- Security Status
52
- </h3>
47
+ <SettingsCard
48
+ title="Security Status"
49
+ description={status.description}
50
+ actions={
53
51
  <span
54
52
  className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-sm font-medium ${style.pill}`}
55
53
  >
@@ -57,9 +55,8 @@ export function SecurityStatusCard({
57
55
  {/* Visible text label means the status never relies on color alone. */}
58
56
  {style.word}
59
57
  </span>
60
- </div>
61
- <p className="text-muted-foreground mb-4 text-sm">{status.description}</p>
62
-
58
+ }
59
+ >
63
60
  <div className="grid grid-cols-1 gap-x-6 gap-y-1 sm:grid-cols-2">
64
61
  {status.checks.map((check) => (
65
62
  <button
@@ -105,6 +102,6 @@ export function SecurityStatusCard({
105
102
  ))}
106
103
  </div>
107
104
  )}
108
- </section>
105
+ </SettingsCard>
109
106
  )
110
107
  }
@@ -12,7 +12,7 @@ import { useSecuritySettings } from './useSecuritySettings.js'
12
12
 
13
13
  function CardSkeleton() {
14
14
  return (
15
- <div className="border-border bg-card animate-pulse rounded-lg border p-4">
15
+ <div className="border-border bg-card animate-pulse rounded-lg border p-6">
16
16
  <div className="bg-muted mb-3 h-4 w-40 rounded" />
17
17
  <div className="bg-muted mb-4 h-3 w-72 rounded" />
18
18
  <div className="space-y-3">
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
- import { useId, useState } from 'react'
4
- import { ConfirmDangerousSettingDialog, SettingsToggleRow } from './components.js'
3
+ import { useState } from 'react'
4
+ import { ConfirmDangerousSettingDialog, SettingsCard, SettingsToggleRow } from './components.js'
5
5
  import type { GeneralSettingsForm, SiteIndexStatus } from './useGeneralSettings.js'
6
6
 
7
7
  interface SeoRobotsDefaultsCardProps {
@@ -29,7 +29,6 @@ export function SeoRobotsDefaultsCard({
29
29
  canEdit,
30
30
  onNavigate,
31
31
  }: SeoRobotsDefaultsCardProps) {
32
- const headingId = useId()
33
32
  const [pending, setPending] = useState<PendingChange | null>(null)
34
33
 
35
34
  // Some toggles can take the whole production site out of search results, so
@@ -65,16 +64,10 @@ export function SeoRobotsDefaultsCard({
65
64
  const sitemapStatus = sitemapCheck ? (sitemapCheck.value === 'Yes' ? 'Enabled' : 'Disabled') : '—'
66
65
 
67
66
  return (
68
- <section aria-labelledby={headingId} className="border-border bg-card rounded-lg border p-4">
69
- <h3 id={headingId} className="text-foreground mb-1 text-base font-medium">
70
- SEO &amp; Robots Defaults
71
- </h3>
72
- <p className="text-muted-foreground mb-4 text-sm">
73
- Set the site-wide default for search engine indexing. Individual pages can inherit or
74
- override these rules in their SEO panel. These share the same configuration as the SEO
75
- section.
76
- </p>
77
-
67
+ <SettingsCard
68
+ title="SEO & Robots Defaults"
69
+ description="Set the site-wide default for search engine indexing. Individual pages can inherit or override these rules in their SEO panel. These share the same configuration as the SEO section."
70
+ >
78
71
  <div className="space-y-4">
79
72
  <SettingsToggleRow
80
73
  label="Default No Index"
@@ -133,7 +126,7 @@ export function SeoRobotsDefaultsCard({
133
126
  }}
134
127
  onCancel={() => setPending(null)}
135
128
  />
136
- </section>
129
+ </SettingsCard>
137
130
  )
138
131
  }
139
132