@actuate-media/cms-admin 0.36.1 → 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 (38) 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/Settings.d.ts.map +1 -1
  8. package/dist/views/Settings.js +4 -112
  9. package/dist/views/Settings.js.map +1 -1
  10. package/dist/views/settings/AdminThemeCard.js +1 -1
  11. package/dist/views/settings/BrandPreviewCard.js +1 -1
  12. package/dist/views/settings/BrandingCard.d.ts.map +1 -1
  13. package/dist/views/settings/BrandingCard.js +34 -19
  14. package/dist/views/settings/BrandingCard.js.map +1 -1
  15. package/dist/views/settings/TypographyMotionCard.js +1 -1
  16. package/dist/views/settings/UpdatesTab.d.ts +6 -0
  17. package/dist/views/settings/UpdatesTab.d.ts.map +1 -0
  18. package/dist/views/settings/UpdatesTab.js +80 -0
  19. package/dist/views/settings/UpdatesTab.js.map +1 -0
  20. package/dist/views/settings/components.d.ts +20 -0
  21. package/dist/views/settings/components.d.ts.map +1 -1
  22. package/dist/views/settings/components.js +11 -0
  23. package/dist/views/settings/components.js.map +1 -1
  24. package/dist/views/settings/useUpdates.d.ts +67 -0
  25. package/dist/views/settings/useUpdates.d.ts.map +1 -0
  26. package/dist/views/settings/useUpdates.js +127 -0
  27. package/dist/views/settings/useUpdates.js.map +1 -0
  28. package/package.json +2 -2
  29. package/src/__tests__/views/appearance-settings.render.test.tsx +4 -4
  30. package/src/__tests__/views/updates-tab.render.test.tsx +161 -0
  31. package/src/views/Settings.tsx +2 -438
  32. package/src/views/settings/AdminThemeCard.tsx +1 -1
  33. package/src/views/settings/BrandPreviewCard.tsx +1 -1
  34. package/src/views/settings/BrandingCard.tsx +179 -180
  35. package/src/views/settings/TypographyMotionCard.tsx +1 -1
  36. package/src/views/settings/UpdatesTab.tsx +545 -0
  37. package/src/views/settings/components.tsx +51 -0
  38. 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
  }
@@ -14,7 +14,7 @@ export function TypographyMotionCard({
14
14
  return (
15
15
  <section className="border-border bg-card rounded-lg border p-6">
16
16
  <header className="mb-4">
17
- <h3 className="text-foreground text-lg font-medium">Typography &amp; Motion</h3>
17
+ <h3 className="text-foreground text-lg font-medium">Typography &amp; motion</h3>
18
18
  <p className="text-muted-foreground mt-0.5 text-sm">
19
19
  Admin UI font and motion preferences.
20
20
  </p>