@actuate-media/cms-admin 0.36.1 → 0.37.1

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 (69) hide show
  1. package/dist/__tests__/layout/sidebar-brand.render.test.d.ts +2 -0
  2. package/dist/__tests__/layout/sidebar-brand.render.test.d.ts.map +1 -0
  3. package/dist/__tests__/layout/sidebar-brand.render.test.js +44 -0
  4. package/dist/__tests__/layout/sidebar-brand.render.test.js.map +1 -0
  5. package/dist/__tests__/views/appearance-settings.render.test.js +4 -4
  6. package/dist/__tests__/views/login-branding.render.test.d.ts +2 -0
  7. package/dist/__tests__/views/login-branding.render.test.d.ts.map +1 -0
  8. package/dist/__tests__/views/login-branding.render.test.js +80 -0
  9. package/dist/__tests__/views/login-branding.render.test.js.map +1 -0
  10. package/dist/__tests__/views/login-mfa.render.test.js +6 -0
  11. package/dist/__tests__/views/login-mfa.render.test.js.map +1 -1
  12. package/dist/__tests__/views/updates-tab.render.test.d.ts +2 -0
  13. package/dist/__tests__/views/updates-tab.render.test.d.ts.map +1 -0
  14. package/dist/__tests__/views/updates-tab.render.test.js +132 -0
  15. package/dist/__tests__/views/updates-tab.render.test.js.map +1 -0
  16. package/dist/actuate-admin.css +1 -1
  17. package/dist/components/LogoPlaceholder.d.ts +17 -0
  18. package/dist/components/LogoPlaceholder.d.ts.map +1 -0
  19. package/dist/components/LogoPlaceholder.js +13 -0
  20. package/dist/components/LogoPlaceholder.js.map +1 -0
  21. package/dist/components/ui/Badge.d.ts +1 -1
  22. package/dist/components/ui/Button.d.ts +1 -1
  23. package/dist/components/ui/Card.d.ts +2 -2
  24. package/dist/components/ui/Input.d.ts +1 -1
  25. package/dist/components/ui/Select.d.ts +1 -1
  26. package/dist/layout/Sidebar.d.ts.map +1 -1
  27. package/dist/layout/Sidebar.js +13 -10
  28. package/dist/layout/Sidebar.js.map +1 -1
  29. package/dist/views/Login.d.ts.map +1 -1
  30. package/dist/views/Login.js +66 -8
  31. package/dist/views/Login.js.map +1 -1
  32. package/dist/views/Settings.d.ts.map +1 -1
  33. package/dist/views/Settings.js +4 -112
  34. package/dist/views/Settings.js.map +1 -1
  35. package/dist/views/settings/AdminThemeCard.js +1 -1
  36. package/dist/views/settings/BrandPreviewCard.js +1 -1
  37. package/dist/views/settings/BrandingCard.d.ts.map +1 -1
  38. package/dist/views/settings/BrandingCard.js +34 -19
  39. package/dist/views/settings/BrandingCard.js.map +1 -1
  40. package/dist/views/settings/TypographyMotionCard.js +1 -1
  41. package/dist/views/settings/UpdatesTab.d.ts +6 -0
  42. package/dist/views/settings/UpdatesTab.d.ts.map +1 -0
  43. package/dist/views/settings/UpdatesTab.js +80 -0
  44. package/dist/views/settings/UpdatesTab.js.map +1 -0
  45. package/dist/views/settings/components.d.ts +20 -0
  46. package/dist/views/settings/components.d.ts.map +1 -1
  47. package/dist/views/settings/components.js +11 -0
  48. package/dist/views/settings/components.js.map +1 -1
  49. package/dist/views/settings/useUpdates.d.ts +67 -0
  50. package/dist/views/settings/useUpdates.d.ts.map +1 -0
  51. package/dist/views/settings/useUpdates.js +127 -0
  52. package/dist/views/settings/useUpdates.js.map +1 -0
  53. package/package.json +2 -2
  54. package/src/__tests__/layout/sidebar-brand.render.test.tsx +58 -0
  55. package/src/__tests__/views/appearance-settings.render.test.tsx +4 -4
  56. package/src/__tests__/views/login-branding.render.test.tsx +96 -0
  57. package/src/__tests__/views/login-mfa.render.test.tsx +7 -0
  58. package/src/__tests__/views/updates-tab.render.test.tsx +161 -0
  59. package/src/components/LogoPlaceholder.tsx +55 -0
  60. package/src/layout/Sidebar.tsx +14 -24
  61. package/src/views/Login.tsx +70 -9
  62. package/src/views/Settings.tsx +2 -438
  63. package/src/views/settings/AdminThemeCard.tsx +1 -1
  64. package/src/views/settings/BrandPreviewCard.tsx +1 -1
  65. package/src/views/settings/BrandingCard.tsx +179 -180
  66. package/src/views/settings/TypographyMotionCard.tsx +1 -1
  67. package/src/views/settings/UpdatesTab.tsx +545 -0
  68. package/src/views/settings/components.tsx +51 -0
  69. package/src/views/settings/useUpdates.ts +183 -0
@@ -0,0 +1,545 @@
1
+ 'use client'
2
+
3
+ import {
4
+ AlertTriangle,
5
+ ArrowUpCircle,
6
+ CheckCircle2,
7
+ Database,
8
+ ExternalLink,
9
+ Eye,
10
+ EyeOff,
11
+ GitPullRequest,
12
+ Loader2,
13
+ Lock,
14
+ Puzzle,
15
+ RefreshCw,
16
+ ShieldAlert,
17
+ } from 'lucide-react'
18
+ import { useState, type ReactNode } from 'react'
19
+
20
+ import { SettingsCard, SettingsControlRow, SettingsSaveBar } from './components.js'
21
+ import {
22
+ useUpdates,
23
+ type UpdateChangeType,
24
+ type UpdateChangelogItem,
25
+ type UpdateInfo,
26
+ } from './useUpdates.js'
27
+
28
+ const SEVERITY: Record<
29
+ NonNullable<UpdateInfo['severity']>,
30
+ { banner: string; text: string; label: string }
31
+ > = {
32
+ patch: { banner: 'border-brand/30 bg-brand/10', text: 'text-brand', label: 'Patch' },
33
+ minor: { banner: 'border-warning/30 bg-warning/10', text: 'text-warning', label: 'Minor' },
34
+ major: {
35
+ banner: 'border-destructive/30 bg-destructive/10',
36
+ text: 'text-destructive',
37
+ label: 'Major',
38
+ },
39
+ security: {
40
+ banner: 'border-destructive/40 bg-destructive/15',
41
+ text: 'text-destructive',
42
+ label: 'Security',
43
+ },
44
+ }
45
+
46
+ const CHANGE_TYPE: Record<UpdateChangeType, { label: string; className: string }> = {
47
+ feature: { label: 'Feature', className: 'border-brand/30 bg-brand/10 text-brand' },
48
+ improvement: {
49
+ label: 'Improvement',
50
+ className: 'border-border bg-muted text-muted-foreground',
51
+ },
52
+ fix: { label: 'Fix', className: 'border-border bg-muted text-muted-foreground' },
53
+ breaking: {
54
+ label: 'Breaking',
55
+ className: 'border-destructive/30 bg-destructive/10 text-destructive',
56
+ },
57
+ security: {
58
+ label: 'Security',
59
+ className: 'border-destructive/40 bg-destructive/15 text-destructive',
60
+ },
61
+ deprecation: { label: 'Deprecated', className: 'border-warning/30 bg-warning/10 text-warning' },
62
+ }
63
+
64
+ const ghInputClass =
65
+ 'w-full rounded-md border border-border bg-input-background px-3 py-2 text-base text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none'
66
+
67
+ /** Group a flat changelog list into per-version buckets (order preserved). */
68
+ function groupByVersion(
69
+ changelog: UpdateChangelogItem[],
70
+ ): Array<{ version: string; date: string; items: UpdateChangelogItem[] }> {
71
+ const groups: Array<{ version: string; date: string; items: UpdateChangelogItem[] }> = []
72
+ for (const item of changelog) {
73
+ const last = groups[groups.length - 1]
74
+ if (last && last.version === item.version) {
75
+ last.items.push(item)
76
+ } else {
77
+ groups.push({ version: item.version, date: item.date, items: [item] })
78
+ }
79
+ }
80
+ return groups
81
+ }
82
+
83
+ function ChangeTypeBadge({ type }: { type?: UpdateChangeType }) {
84
+ if (!type) return null
85
+ const meta = CHANGE_TYPE[type]
86
+ return (
87
+ <span
88
+ className={`inline-flex shrink-0 items-center rounded border px-1.5 py-0.5 text-xs font-medium ${meta.className}`}
89
+ >
90
+ {meta.label}
91
+ </span>
92
+ )
93
+ }
94
+
95
+ /** A bordered notice row used for migration / breaking-change callouts. */
96
+ function Notice({
97
+ tone,
98
+ icon,
99
+ title,
100
+ children,
101
+ }: {
102
+ tone: 'warning' | 'destructive'
103
+ icon: ReactNode
104
+ title: string
105
+ children?: ReactNode
106
+ }) {
107
+ const toneClass =
108
+ tone === 'destructive'
109
+ ? 'border-destructive/30 bg-destructive/10 text-destructive'
110
+ : 'border-warning/30 bg-warning/10 text-warning'
111
+ return (
112
+ <div className={`mt-3 flex items-start gap-2.5 rounded-md border p-3 ${toneClass}`}>
113
+ <span className="mt-0.5 shrink-0" aria-hidden="true">
114
+ {icon}
115
+ </span>
116
+ <div className="min-w-0 text-sm">
117
+ <p className="font-medium">{title}</p>
118
+ {children && <div className="text-foreground/80 mt-1 space-y-1">{children}</div>}
119
+ </div>
120
+ </div>
121
+ )
122
+ }
123
+
124
+ export interface UpdatesTabProps {
125
+ /** Current admin role — gates the write actions (config + Create PR). */
126
+ role?: string
127
+ }
128
+
129
+ export function UpdatesTab({ role }: UpdatesTabProps) {
130
+ const {
131
+ info,
132
+ checking,
133
+ hasChecked,
134
+ error,
135
+ applying,
136
+ prResult,
137
+ canManage,
138
+ form,
139
+ setField,
140
+ dirty,
141
+ repoError,
142
+ hasErrors,
143
+ saveState,
144
+ save,
145
+ reset,
146
+ check,
147
+ apply,
148
+ } = useUpdates({ role })
149
+
150
+ const [showToken, setShowToken] = useState(false)
151
+
152
+ const severity = info?.updateAvailable ? SEVERITY[info.severity ?? 'patch'] : null
153
+ const changelogGroups = info?.changelog ? groupByVersion(info.changelog) : []
154
+
155
+ return (
156
+ <>
157
+ {/* Current version */}
158
+ <SettingsCard
159
+ title="Actuate CMS"
160
+ description="Keep your CMS up to date with the latest features and security patches."
161
+ actions={
162
+ <button
163
+ type="button"
164
+ onClick={check}
165
+ disabled={checking}
166
+ className="border-border text-foreground hover:bg-accent inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50"
167
+ >
168
+ <RefreshCw size={16} className={checking ? 'animate-spin' : ''} aria-hidden="true" />
169
+ {checking ? 'Checking…' : 'Check for Updates'}
170
+ </button>
171
+ }
172
+ >
173
+ <p className="text-muted-foreground text-sm">
174
+ Current version:{' '}
175
+ <span className="text-foreground font-mono font-medium">{info?.current ?? '…'}</span>
176
+ </p>
177
+ </SettingsCard>
178
+
179
+ {/* Error */}
180
+ {error && (
181
+ <div
182
+ role="alert"
183
+ className="border-destructive/30 bg-destructive/10 text-destructive flex items-center gap-3 rounded-lg border p-3 text-sm"
184
+ >
185
+ <AlertTriangle size={20} className="shrink-0" aria-hidden="true" />
186
+ <span className="flex-1">{error}</span>
187
+ <button
188
+ type="button"
189
+ onClick={check}
190
+ className="border-destructive/40 rounded-md border px-3 py-1 transition-colors hover:opacity-80"
191
+ >
192
+ Retry
193
+ </button>
194
+ </div>
195
+ )}
196
+
197
+ {/* Up to date */}
198
+ {hasChecked && info && !info.updateAvailable && !error && (
199
+ <div className="border-success/30 bg-success/10 flex items-center gap-3 rounded-lg border p-4">
200
+ <CheckCircle2 size={24} className="text-success shrink-0" aria-hidden="true" />
201
+ <div>
202
+ <h3 className="text-foreground font-medium">You&apos;re up to date!</h3>
203
+ <p className="text-muted-foreground mt-0.5 text-sm">
204
+ Actuate CMS <span className="font-mono">{info.current}</span> is the latest version.
205
+ </p>
206
+ </div>
207
+ </div>
208
+ )}
209
+
210
+ {/* Update available */}
211
+ {info?.updateAvailable && severity && (
212
+ <div className={`rounded-lg border p-4 ${severity.banner}`}>
213
+ <div className="flex items-start gap-3">
214
+ {info.severity === 'security' ? (
215
+ <ShieldAlert
216
+ size={24}
217
+ className={`mt-0.5 shrink-0 ${severity.text}`}
218
+ aria-hidden="true"
219
+ />
220
+ ) : (
221
+ <ArrowUpCircle
222
+ size={24}
223
+ className={`mt-0.5 shrink-0 ${severity.text}`}
224
+ aria-hidden="true"
225
+ />
226
+ )}
227
+ <div className="flex-1">
228
+ <div className="flex flex-wrap items-center gap-2">
229
+ <h3 className={`font-medium ${severity.text}`}>
230
+ {info.severity === 'security' ? 'Security update available' : 'Update available'}
231
+ </h3>
232
+ <span
233
+ className={`inline-flex items-center rounded border px-2 py-0.5 text-xs font-medium ${severity.banner} ${severity.text}`}
234
+ >
235
+ {severity.label}
236
+ </span>
237
+ </div>
238
+ <p className="text-muted-foreground mt-1 text-sm">
239
+ <span className="font-mono">{info.current}</span> &rarr;{' '}
240
+ <span className="text-foreground font-mono font-medium">{info.latest}</span>
241
+ {info.releaseDate && (
242
+ <span className="ml-2 text-xs opacity-70">Released {info.releaseDate}</span>
243
+ )}
244
+ </p>
245
+ </div>
246
+ </div>
247
+
248
+ {info.hasMigrations && (
249
+ <Notice
250
+ tone="warning"
251
+ icon={<Database size={16} />}
252
+ title="Database migrations required"
253
+ >
254
+ <p>
255
+ Run{' '}
256
+ <code className="bg-muted rounded px-1 font-mono text-xs">
257
+ npx prisma migrate deploy
258
+ </code>{' '}
259
+ after upgrading. On Vercel this runs automatically at build time.
260
+ </p>
261
+ </Notice>
262
+ )}
263
+
264
+ {info.breakingChanges && info.breakingChanges.length > 0 && (
265
+ <Notice
266
+ tone="destructive"
267
+ icon={<AlertTriangle size={16} />}
268
+ title="Breaking changes in this release"
269
+ >
270
+ <ul className="list-disc space-y-1 pl-4">
271
+ {info.breakingChanges.map((c, i) => (
272
+ <li key={i}>{c}</li>
273
+ ))}
274
+ </ul>
275
+ </Notice>
276
+ )}
277
+
278
+ <div className="mt-4 flex flex-wrap items-center gap-3">
279
+ {canManage ? (
280
+ <button
281
+ type="button"
282
+ onClick={apply}
283
+ disabled={applying}
284
+ className="bg-brand text-brand-foreground inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-opacity hover:opacity-90 disabled:opacity-50"
285
+ >
286
+ {applying ? (
287
+ <>
288
+ <Loader2 size={16} className="animate-spin" aria-hidden="true" />
289
+ Creating PR…
290
+ </>
291
+ ) : (
292
+ <>
293
+ <GitPullRequest size={16} aria-hidden="true" />
294
+ Create Update PR
295
+ </>
296
+ )}
297
+ </button>
298
+ ) : (
299
+ <span className="text-muted-foreground inline-flex items-center gap-1.5 text-xs">
300
+ <Lock size={14} aria-hidden="true" />
301
+ Ask an administrator to apply this update.
302
+ </span>
303
+ )}
304
+ {canManage && (
305
+ <span className="text-muted-foreground text-xs">
306
+ Opens a pull request on your repository
307
+ </span>
308
+ )}
309
+ </div>
310
+
311
+ {info.updateCommand && (
312
+ <div className="border-border bg-card mt-3 rounded-md border p-3">
313
+ <p className="text-muted-foreground mb-1 text-xs">Or update manually:</p>
314
+ <code className="bg-muted text-foreground block rounded px-2 py-1.5 font-mono text-xs select-all">
315
+ {info.updateCommand}
316
+ </code>
317
+ </div>
318
+ )}
319
+ </div>
320
+ )}
321
+
322
+ {/* Changelog */}
323
+ {info?.updateAvailable && changelogGroups.length > 0 && (
324
+ <SettingsCard
325
+ title="Changelog"
326
+ description={
327
+ info.source === 'npm'
328
+ ? 'Version history from npm. Connect the update server for full release notes.'
329
+ : "What's new since your current version."
330
+ }
331
+ >
332
+ <div className="max-h-80 space-y-4 overflow-y-auto pr-1">
333
+ {changelogGroups.map((group) => (
334
+ <div
335
+ key={group.version}
336
+ className="border-border border-b pb-3 last:border-0 last:pb-0"
337
+ >
338
+ <div className="mb-1.5 flex items-baseline gap-3">
339
+ <span className="text-foreground font-mono text-sm font-medium">
340
+ {group.version}
341
+ </span>
342
+ {group.date && (
343
+ <span className="text-muted-foreground text-xs">{group.date}</span>
344
+ )}
345
+ </div>
346
+ <ul className="space-y-1.5">
347
+ {group.items.map((item, i) => (
348
+ <li key={i} className="flex items-start gap-2 text-sm">
349
+ <ChangeTypeBadge type={item.type} />
350
+ <span className="text-muted-foreground min-w-0">{item.summary}</span>
351
+ </li>
352
+ ))}
353
+ </ul>
354
+ </div>
355
+ ))}
356
+ </div>
357
+ </SettingsCard>
358
+ )}
359
+
360
+ {/* Plugin compatibility */}
361
+ {info?.updateAvailable && info.pluginCompatibility && info.pluginCompatibility.length > 0 && (
362
+ <SettingsCard
363
+ title="Plugin compatibility"
364
+ description="Latest published versions of the official plugins for this release."
365
+ >
366
+ <ul className="grid gap-2 sm:grid-cols-2">
367
+ {info.pluginCompatibility.map((plugin) => (
368
+ <li
369
+ key={plugin.name}
370
+ className="border-border flex items-center justify-between gap-3 rounded-md border px-3 py-2 text-sm"
371
+ >
372
+ <span className="text-foreground inline-flex min-w-0 items-center gap-2">
373
+ <Puzzle size={14} className="text-muted-foreground shrink-0" aria-hidden="true" />
374
+ <span className="truncate font-mono text-xs">
375
+ {plugin.name.replace('@actuate-media/', '')}
376
+ </span>
377
+ </span>
378
+ <span className="text-muted-foreground inline-flex shrink-0 items-center gap-1.5">
379
+ {plugin.minVersion && (
380
+ <span className="font-mono text-xs">{plugin.minVersion}</span>
381
+ )}
382
+ <CheckCircle2 size={14} className="text-success" aria-hidden="true" />
383
+ </span>
384
+ </li>
385
+ ))}
386
+ </ul>
387
+ </SettingsCard>
388
+ )}
389
+
390
+ {/* PR created */}
391
+ {prResult && (
392
+ <div className="border-success/30 bg-success/10 rounded-lg border p-4">
393
+ <div className="flex items-start gap-3">
394
+ <GitPullRequest size={20} className="text-success mt-0.5 shrink-0" aria-hidden="true" />
395
+ <div>
396
+ <h3 className="text-foreground font-medium">Pull Request Created</h3>
397
+ <p className="text-muted-foreground mt-1 text-sm">
398
+ PR #{prResult.prNumber} has been created on your repository. Review and merge it to
399
+ apply the update, then run{' '}
400
+ <code className="bg-muted rounded px-1 font-mono text-xs">
401
+ npx prisma migrate deploy
402
+ </code>
403
+ .
404
+ </p>
405
+ <a
406
+ href={prResult.prUrl}
407
+ target="_blank"
408
+ rel="noopener noreferrer"
409
+ className="text-brand mt-2 inline-flex items-center gap-1.5 text-sm font-medium hover:underline"
410
+ >
411
+ <ExternalLink size={16} aria-hidden="true" />
412
+ View Pull Request
413
+ </a>
414
+ </div>
415
+ </div>
416
+ </div>
417
+ )}
418
+
419
+ {/* GitHub integration — admins only */}
420
+ {canManage && (
421
+ <SettingsCard
422
+ title="GitHub Integration"
423
+ description="Connect your repository to enable one-click update PRs. Credentials are encrypted at rest (AES-256-GCM)."
424
+ >
425
+ <div className="space-y-4">
426
+ <SettingsControlRow
427
+ label="Repository"
428
+ htmlFor="updates-gh-repo"
429
+ align="start"
430
+ description={
431
+ <>
432
+ e.g.{' '}
433
+ <code className="bg-muted rounded px-1 font-mono">
434
+ actuate-media/my-client-site
435
+ </code>
436
+ </>
437
+ }
438
+ control={
439
+ <div className="w-72 max-w-full">
440
+ <input
441
+ id="updates-gh-repo"
442
+ type="text"
443
+ value={form.githubRepo}
444
+ onChange={(e) => setField('githubRepo', e.target.value)}
445
+ placeholder="owner/repo"
446
+ aria-invalid={Boolean(repoError)}
447
+ aria-describedby={repoError ? 'updates-gh-repo-error' : undefined}
448
+ className={ghInputClass}
449
+ />
450
+ {repoError && (
451
+ <p id="updates-gh-repo-error" className="text-destructive mt-1 text-xs">
452
+ {repoError}
453
+ </p>
454
+ )}
455
+ </div>
456
+ }
457
+ />
458
+
459
+ <SettingsControlRow
460
+ label={
461
+ <span className="inline-flex items-center gap-2">
462
+ GitHub token
463
+ {info?.hasGithubToken && !form.githubToken && (
464
+ <span className="text-success text-xs font-normal">Saved</span>
465
+ )}
466
+ </span>
467
+ }
468
+ htmlFor="updates-gh-token"
469
+ align="start"
470
+ description={
471
+ <>
472
+ Needs <code className="bg-muted rounded px-1 font-mono">repo</code> scope. Create
473
+ one at{' '}
474
+ <a
475
+ href="https://github.com/settings/tokens"
476
+ target="_blank"
477
+ rel="noopener noreferrer"
478
+ className="text-brand hover:underline"
479
+ >
480
+ github.com/settings/tokens
481
+ </a>
482
+ .
483
+ </>
484
+ }
485
+ control={
486
+ <div className="relative w-72 max-w-full">
487
+ <input
488
+ id="updates-gh-token"
489
+ type={showToken ? 'text' : 'password'}
490
+ value={form.githubToken}
491
+ onChange={(e) => setField('githubToken', e.target.value)}
492
+ placeholder={
493
+ info?.hasGithubToken ? '••••••••••••••••' : 'ghp_… or github_pat_…'
494
+ }
495
+ className={`${ghInputClass} pr-10`}
496
+ />
497
+ <button
498
+ type="button"
499
+ onClick={() => setShowToken((s) => !s)}
500
+ aria-label={showToken ? 'Hide token' : 'Show token'}
501
+ className="text-muted-foreground hover:text-foreground absolute top-1/2 right-2 -translate-y-1/2 p-1"
502
+ >
503
+ {showToken ? (
504
+ <EyeOff size={16} aria-hidden="true" />
505
+ ) : (
506
+ <Eye size={16} aria-hidden="true" />
507
+ )}
508
+ </button>
509
+ </div>
510
+ }
511
+ />
512
+ </div>
513
+
514
+ <SettingsSaveBar
515
+ dirty={dirty}
516
+ canEdit={canManage}
517
+ hasErrors={hasErrors}
518
+ saveState={saveState}
519
+ onSave={save}
520
+ onReset={reset}
521
+ />
522
+ </SettingsCard>
523
+ )}
524
+
525
+ {/* Help */}
526
+ <SettingsCard title="How Updates Work">
527
+ <ol className="text-muted-foreground space-y-1.5 text-sm">
528
+ {[
529
+ 'Click “Check for Updates” to see if a new version is available.',
530
+ 'Add your GitHub token and repository above (admins only). They’re encrypted at rest — never stored in plaintext.',
531
+ 'Click “Create Update PR” to open a pull request that bumps your CMS packages automatically.',
532
+ 'Review and merge the PR, then deploy. Database migrations run automatically via prisma migrate deploy.',
533
+ ].map((step, i) => (
534
+ <li key={i} className="flex items-start gap-2">
535
+ <span className="bg-brand/10 text-brand mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[10px] font-medium">
536
+ {i + 1}
537
+ </span>
538
+ <span>{step}</span>
539
+ </li>
540
+ ))}
541
+ </ol>
542
+ </SettingsCard>
543
+ </>
544
+ )
545
+ }
@@ -76,6 +76,57 @@ export function SettingsSubsection({
76
76
  )
77
77
  }
78
78
 
79
+ /**
80
+ * A single setting row: an optional leading visual plus a label and
81
+ * description on the left, and a control aligned to the right edge. This is the
82
+ * uniform rhythm for compact controls (upload buttons, segmented toggles)
83
+ * inside a {@link SettingsCard} — mirror it everywhere so rows line up instead
84
+ * of stacking controls beneath their copy.
85
+ */
86
+ export function SettingsControlRow({
87
+ label,
88
+ description,
89
+ control,
90
+ leading,
91
+ htmlFor,
92
+ align = 'center',
93
+ className,
94
+ }: {
95
+ label: ReactNode
96
+ description?: ReactNode
97
+ /** Right-aligned control(s) for this row. */
98
+ control: ReactNode
99
+ /** Optional visual (e.g. a logo thumbnail) shown before the label. */
100
+ leading?: ReactNode
101
+ /** When set, the label renders as a `<label>` bound to this control id. */
102
+ htmlFor?: string
103
+ /** Vertical alignment of the two columns. Defaults to centered. */
104
+ align?: 'center' | 'start'
105
+ className?: string
106
+ }) {
107
+ const labelClass = 'text-foreground block text-base font-medium'
108
+ return (
109
+ <div
110
+ className={`flex justify-between gap-4 ${align === 'center' ? 'items-center' : 'items-start'} ${className ?? ''}`}
111
+ >
112
+ <div className="flex min-w-0 items-center gap-4">
113
+ {leading}
114
+ <div className="min-w-0">
115
+ {htmlFor ? (
116
+ <label htmlFor={htmlFor} className={labelClass}>
117
+ {label}
118
+ </label>
119
+ ) : (
120
+ <span className={labelClass}>{label}</span>
121
+ )}
122
+ {description && <p className="text-muted-foreground mt-0.5 text-sm">{description}</p>}
123
+ </div>
124
+ </div>
125
+ <div className="flex shrink-0 items-center gap-2">{control}</div>
126
+ </div>
127
+ )
128
+ }
129
+
79
130
  const SOURCE_LABEL: Record<SettingsSourceMetadata['source'], string> = {
80
131
  config: 'Config-managed',
81
132
  environment: 'Environment-managed',