@actuate-media/cms-admin 0.25.0 → 0.27.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 (101) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +3 -3
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/__tests__/views/appearance-settings.render.test.d.ts +2 -0
  5. package/dist/__tests__/views/appearance-settings.render.test.d.ts.map +1 -0
  6. package/dist/__tests__/views/appearance-settings.render.test.js +95 -0
  7. package/dist/__tests__/views/appearance-settings.render.test.js.map +1 -0
  8. package/dist/__tests__/views/general-settings.render.test.d.ts +2 -0
  9. package/dist/__tests__/views/general-settings.render.test.d.ts.map +1 -0
  10. package/dist/__tests__/views/general-settings.render.test.js +105 -0
  11. package/dist/__tests__/views/general-settings.render.test.js.map +1 -0
  12. package/dist/actuate-admin.css +1 -1
  13. package/dist/components/ThemeProvider.d.ts +47 -4
  14. package/dist/components/ThemeProvider.d.ts.map +1 -1
  15. package/dist/components/ThemeProvider.js +173 -27
  16. package/dist/components/ThemeProvider.js.map +1 -1
  17. package/dist/components/ui/Badge.d.ts +1 -1
  18. package/dist/components/ui/Button.d.ts +1 -1
  19. package/dist/components/ui/Card.d.ts +2 -2
  20. package/dist/components/ui/Input.d.ts +1 -1
  21. package/dist/components/ui/Select.d.ts +1 -1
  22. package/dist/layout/Sidebar.d.ts.map +1 -1
  23. package/dist/layout/Sidebar.js +47 -8
  24. package/dist/layout/Sidebar.js.map +1 -1
  25. package/dist/views/Settings.d.ts +3 -1
  26. package/dist/views/Settings.d.ts.map +1 -1
  27. package/dist/views/Settings.js +10 -33
  28. package/dist/views/Settings.js.map +1 -1
  29. package/dist/views/settings/AdminThemeCard.d.ts +6 -0
  30. package/dist/views/settings/AdminThemeCard.d.ts.map +1 -0
  31. package/dist/views/settings/AdminThemeCard.js +31 -0
  32. package/dist/views/settings/AdminThemeCard.js.map +1 -0
  33. package/dist/views/settings/AppearanceTab.d.ts +7 -0
  34. package/dist/views/settings/AppearanceTab.d.ts.map +1 -0
  35. package/dist/views/settings/AppearanceTab.js +24 -0
  36. package/dist/views/settings/AppearanceTab.js.map +1 -0
  37. package/dist/views/settings/BrandPreviewCard.d.ts +5 -0
  38. package/dist/views/settings/BrandPreviewCard.d.ts.map +1 -0
  39. package/dist/views/settings/BrandPreviewCard.js +18 -0
  40. package/dist/views/settings/BrandPreviewCard.js.map +1 -0
  41. package/dist/views/settings/BrandingCard.d.ts +6 -0
  42. package/dist/views/settings/BrandingCard.d.ts.map +1 -0
  43. package/dist/views/settings/BrandingCard.js +38 -0
  44. package/dist/views/settings/BrandingCard.js.map +1 -0
  45. package/dist/views/settings/GeneralSettingsTab.d.ts +7 -0
  46. package/dist/views/settings/GeneralSettingsTab.d.ts.map +1 -0
  47. package/dist/views/settings/GeneralSettingsTab.js +19 -0
  48. package/dist/views/settings/GeneralSettingsTab.js.map +1 -0
  49. package/dist/views/settings/LanguageRegionCard.d.ts +10 -0
  50. package/dist/views/settings/LanguageRegionCard.d.ts.map +1 -0
  51. package/dist/views/settings/LanguageRegionCard.js +48 -0
  52. package/dist/views/settings/LanguageRegionCard.js.map +1 -0
  53. package/dist/views/settings/SeoRobotsDefaultsCard.d.ts +12 -0
  54. package/dist/views/settings/SeoRobotsDefaultsCard.d.ts.map +1 -0
  55. package/dist/views/settings/SeoRobotsDefaultsCard.js +45 -0
  56. package/dist/views/settings/SeoRobotsDefaultsCard.js.map +1 -0
  57. package/dist/views/settings/SiteIndexStatusCard.d.ts +12 -0
  58. package/dist/views/settings/SiteIndexStatusCard.d.ts.map +1 -0
  59. package/dist/views/settings/SiteIndexStatusCard.js +51 -0
  60. package/dist/views/settings/SiteIndexStatusCard.js.map +1 -0
  61. package/dist/views/settings/SiteInformationCard.d.ts +11 -0
  62. package/dist/views/settings/SiteInformationCard.d.ts.map +1 -0
  63. package/dist/views/settings/SiteInformationCard.js +21 -0
  64. package/dist/views/settings/SiteInformationCard.js.map +1 -0
  65. package/dist/views/settings/TypographyMotionCard.d.ts +6 -0
  66. package/dist/views/settings/TypographyMotionCard.d.ts.map +1 -0
  67. package/dist/views/settings/TypographyMotionCard.js +8 -0
  68. package/dist/views/settings/TypographyMotionCard.js.map +1 -0
  69. package/dist/views/settings/components.d.ts +38 -0
  70. package/dist/views/settings/components.d.ts.map +1 -0
  71. package/dist/views/settings/components.js +43 -0
  72. package/dist/views/settings/components.js.map +1 -0
  73. package/dist/views/settings/useAppearanceSettings.d.ts +69 -0
  74. package/dist/views/settings/useAppearanceSettings.d.ts.map +1 -0
  75. package/dist/views/settings/useAppearanceSettings.js +344 -0
  76. package/dist/views/settings/useAppearanceSettings.js.map +1 -0
  77. package/dist/views/settings/useGeneralSettings.d.ts +82 -0
  78. package/dist/views/settings/useGeneralSettings.d.ts.map +1 -0
  79. package/dist/views/settings/useGeneralSettings.js +286 -0
  80. package/dist/views/settings/useGeneralSettings.js.map +1 -0
  81. package/package.json +3 -3
  82. package/src/AdminRoot.tsx +5 -3
  83. package/src/__tests__/views/appearance-settings.render.test.tsx +132 -0
  84. package/src/__tests__/views/general-settings.render.test.tsx +135 -0
  85. package/src/components/ThemeProvider.tsx +249 -36
  86. package/src/layout/Sidebar.tsx +81 -13
  87. package/src/styles/theme.css +32 -2
  88. package/src/views/Settings.tsx +19 -155
  89. package/src/views/settings/AdminThemeCard.tsx +145 -0
  90. package/src/views/settings/AppearanceTab.tsx +81 -0
  91. package/src/views/settings/BrandPreviewCard.tsx +125 -0
  92. package/src/views/settings/BrandingCard.tsx +332 -0
  93. package/src/views/settings/GeneralSettingsTab.tsx +126 -0
  94. package/src/views/settings/LanguageRegionCard.tsx +119 -0
  95. package/src/views/settings/SeoRobotsDefaultsCard.tsx +149 -0
  96. package/src/views/settings/SiteIndexStatusCard.tsx +184 -0
  97. package/src/views/settings/SiteInformationCard.tsx +122 -0
  98. package/src/views/settings/TypographyMotionCard.tsx +65 -0
  99. package/src/views/settings/components.tsx +198 -0
  100. package/src/views/settings/useAppearanceSettings.ts +513 -0
  101. package/src/views/settings/useGeneralSettings.ts +379 -0
@@ -0,0 +1,332 @@
1
+ 'use client'
2
+
3
+ import {
4
+ AlertTriangle,
5
+ ChevronDown,
6
+ ChevronRight,
7
+ ImageIcon,
8
+ Loader2,
9
+ Trash2,
10
+ Upload,
11
+ } from 'lucide-react'
12
+ import { useRef } from 'react'
13
+
14
+ import type { BrandAssetField, UseAppearanceSettings } from './useAppearanceSettings.js'
15
+
16
+ const LOGO_ACCEPT = 'image/svg+xml,image/png'
17
+
18
+ function previewSurface(dark: boolean): string {
19
+ return dark ? 'bg-foreground border-border' : 'bg-muted border-border'
20
+ }
21
+
22
+ function LogoField({
23
+ label,
24
+ helper,
25
+ field,
26
+ accept,
27
+ square,
28
+ darkSurface,
29
+ brand,
30
+ assets,
31
+ uploadingField,
32
+ canEdit,
33
+ warning,
34
+ onUpload,
35
+ onRemove,
36
+ }: {
37
+ label: string
38
+ helper: string
39
+ field: BrandAssetField
40
+ accept: string
41
+ square?: boolean
42
+ darkSurface?: boolean
43
+ brand: UseAppearanceSettings['brand']
44
+ assets: UseAppearanceSettings['assets']
45
+ uploadingField: BrandAssetField | null
46
+ canEdit: boolean
47
+ warning?: string
48
+ onUpload: (field: BrandAssetField, file: File) => void
49
+ onRemove: (field: BrandAssetField) => void
50
+ }) {
51
+ const inputRef = useRef<HTMLInputElement>(null)
52
+ const assetId = brand[field]
53
+ const asset = assetId ? assets[assetId] : undefined
54
+ const uploading = uploadingField === field
55
+ const inputId = `brand-${field}`
56
+
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}
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
+ <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>
82
+
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
+ />
97
+ <button
98
+ 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"
102
+ >
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()}`}
109
+ </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>
133
+ </div>
134
+ )
135
+ }
136
+
137
+ export function BrandingCard({ s, canEdit }: { s: UseAppearanceSettings; canEdit: boolean }) {
138
+ const warningFor = (field: BrandAssetField): string | undefined =>
139
+ s.issues.find((i) => i.field === field && i.severity === 'warning')?.message
140
+
141
+ const custom = s.brand.mode === 'customAdminBrand'
142
+
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>
151
+
152
+ <div className="space-y-5">
153
+ <LogoField
154
+ label="Primary Logo"
155
+ helper="SVG or PNG, up to 1MB. Best for the public header and full admin sidebar."
156
+ field="publicPrimaryLogoAssetId"
157
+ accept={LOGO_ACCEPT}
158
+ brand={s.brand}
159
+ assets={s.assets}
160
+ uploadingField={s.uploadingField}
161
+ canEdit={canEdit}
162
+ warning={warningFor('publicPrimaryLogoAssetId')}
163
+ onUpload={s.uploadAsset}
164
+ onRemove={s.removeAsset}
165
+ />
166
+
167
+ <LogoField
168
+ label="Favicon / App Icon Source"
169
+ helper="32×32 PNG or SVG. A larger square image is fine — it scales down cleanly."
170
+ field="faviconSourceAssetId"
171
+ accept={LOGO_ACCEPT}
172
+ square
173
+ brand={s.brand}
174
+ assets={s.assets}
175
+ uploadingField={s.uploadingField}
176
+ canEdit={canEdit}
177
+ warning={warningFor('faviconSourceAssetId')}
178
+ onUpload={s.uploadAsset}
179
+ onRemove={s.removeAsset}
180
+ />
181
+
182
+ {/* Advanced logo variants disclosure (Logo Mark + Dark Mode Logo). */}
183
+ <div className="border-border border-t pt-4">
184
+ <button
185
+ type="button"
186
+ onClick={() => s.setShowAdvanced(!s.showAdvanced)}
187
+ aria-expanded={s.showAdvanced}
188
+ className="text-foreground hover:text-brand inline-flex items-center gap-1.5 text-sm font-medium transition-colors"
189
+ >
190
+ {s.showAdvanced ? (
191
+ <ChevronDown size={16} aria-hidden="true" />
192
+ ) : (
193
+ <ChevronRight size={16} aria-hidden="true" />
194
+ )}
195
+ Advanced logo variants
196
+ </button>
197
+
198
+ {s.showAdvanced && (
199
+ <div className="mt-4 space-y-5">
200
+ <LogoField
201
+ label="Logo Mark"
202
+ helper="Square icon or mark. Used for the collapsed sidebar, compact UI, and app icons."
203
+ field="publicLogoMarkAssetId"
204
+ accept={LOGO_ACCEPT}
205
+ square
206
+ brand={s.brand}
207
+ assets={s.assets}
208
+ uploadingField={s.uploadingField}
209
+ canEdit={canEdit}
210
+ warning={warningFor('publicLogoMarkAssetId')}
211
+ onUpload={s.uploadAsset}
212
+ onRemove={s.removeAsset}
213
+ />
214
+ <LogoField
215
+ label="Dark Mode Logo"
216
+ helper="Optional. Used on dark backgrounds or the dark admin theme."
217
+ field="publicDarkLogoAssetId"
218
+ accept={LOGO_ACCEPT}
219
+ darkSurface
220
+ brand={s.brand}
221
+ assets={s.assets}
222
+ uploadingField={s.uploadingField}
223
+ canEdit={canEdit}
224
+ warning={warningFor('publicDarkLogoAssetId')}
225
+ onUpload={s.uploadAsset}
226
+ onRemove={s.removeAsset}
227
+ />
228
+ </div>
229
+ )}
230
+ </div>
231
+
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>
271
+
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>
304
+
305
+ {/* Default alt text */}
306
+ <div className="border-border border-t pt-4">
307
+ <label htmlFor="brand-alt" className="text-foreground block text-base font-medium">
308
+ Logo alt text
309
+ </label>
310
+ <p className="text-muted-foreground mt-0.5 mb-2 text-sm">
311
+ Used by screen readers on the public site. Defaults to the Site Title when empty.
312
+ </p>
313
+ <input
314
+ id="brand-alt"
315
+ type="text"
316
+ value={s.brand.defaultLogoAltText ?? ''}
317
+ disabled={!canEdit}
318
+ onChange={(e) => s.setAltText(e.target.value)}
319
+ placeholder="Site Title"
320
+ className="border-border bg-input-background text-foreground focus-visible:ring-ring w-full max-w-md rounded-md border px-3 py-2 text-base focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
321
+ />
322
+ </div>
323
+ </div>
324
+
325
+ {s.saveError && (
326
+ <p className="text-destructive mt-4 text-sm" role="alert">
327
+ {s.saveError}
328
+ </p>
329
+ )}
330
+ </section>
331
+ )
332
+ }
@@ -0,0 +1,126 @@
1
+ 'use client'
2
+
3
+ import { AlertTriangle, Info } from 'lucide-react'
4
+ import { useGeneralSettings } from './useGeneralSettings.js'
5
+ import { SettingsSaveBar, SettingsValidationSummary } from './components.js'
6
+ import { SiteIndexStatusCard } from './SiteIndexStatusCard.js'
7
+ import { SiteInformationCard } from './SiteInformationCard.js'
8
+ import { LanguageRegionCard } from './LanguageRegionCard.js'
9
+ import { SeoRobotsDefaultsCard } from './SeoRobotsDefaultsCard.js'
10
+
11
+ export interface GeneralSettingsTabProps {
12
+ onNavigate?: (path: string) => void
13
+ /** Current user's role. Only ADMIN can persist changes. */
14
+ role?: string
15
+ }
16
+
17
+ export function GeneralSettingsTab({ onNavigate, role }: GeneralSettingsTabProps) {
18
+ const canEdit = role === 'ADMIN'
19
+ const settings = useGeneralSettings(canEdit)
20
+
21
+ const runAudit = () => onNavigate?.('/seo?tab=audit')
22
+
23
+ if (settings.loading) {
24
+ return (
25
+ <div className="space-y-4" aria-busy="true" aria-live="polite">
26
+ <span className="sr-only">Loading settings…</span>
27
+ {[0, 1, 2, 3].map((i) => (
28
+ <div key={i} className="border-border bg-card rounded-lg border p-4">
29
+ <div className="bg-muted mb-4 h-4 w-40 animate-pulse rounded" />
30
+ <div className="space-y-3">
31
+ <div className="bg-muted h-9 w-full animate-pulse rounded" />
32
+ <div className="bg-muted h-9 w-full animate-pulse rounded" />
33
+ </div>
34
+ </div>
35
+ ))}
36
+ </div>
37
+ )
38
+ }
39
+
40
+ return (
41
+ <div className="space-y-4">
42
+ {settings.loadError && (
43
+ <div
44
+ role="alert"
45
+ className="border-destructive/30 bg-destructive/10 text-destructive flex items-center gap-3 rounded-lg border p-3 text-sm"
46
+ >
47
+ <AlertTriangle size={16} className="shrink-0" aria-hidden="true" />
48
+ <span className="flex-1">{settings.loadError}</span>
49
+ <button
50
+ type="button"
51
+ onClick={settings.refetch}
52
+ className="border-destructive/40 rounded-md border px-3 py-1 transition-colors hover:opacity-80"
53
+ >
54
+ Retry
55
+ </button>
56
+ </div>
57
+ )}
58
+
59
+ {!canEdit && (
60
+ <div className="border-border bg-muted text-muted-foreground flex items-center gap-2 rounded-lg border p-3 text-sm">
61
+ <Info size={16} className="shrink-0" aria-hidden="true" />
62
+ You have read-only access to settings. Only administrators can make changes.
63
+ </div>
64
+ )}
65
+
66
+ <SettingsValidationSummary issues={settings.issues} />
67
+
68
+ <SiteIndexStatusCard
69
+ status={settings.indexStatus}
70
+ loading={settings.loading}
71
+ onNavigate={onNavigate}
72
+ onRunAudit={runAudit}
73
+ />
74
+
75
+ <SiteInformationCard
76
+ form={settings.form}
77
+ setField={settings.setField}
78
+ sources={settings.sources}
79
+ issues={settings.issues}
80
+ canEdit={canEdit}
81
+ />
82
+
83
+ <LanguageRegionCard
84
+ form={settings.form}
85
+ setField={settings.setField}
86
+ issues={settings.issues}
87
+ canEdit={canEdit}
88
+ />
89
+
90
+ <SeoRobotsDefaultsCard
91
+ form={settings.form}
92
+ setField={settings.setField}
93
+ isProduction={settings.isProduction}
94
+ indexStatus={settings.indexStatus}
95
+ canEdit={canEdit}
96
+ onNavigate={onNavigate}
97
+ />
98
+
99
+ {settings.saveState === 'error' && settings.saveError && (
100
+ <div
101
+ role="alert"
102
+ className="border-destructive/30 bg-destructive/10 text-destructive flex items-center gap-3 rounded-lg border p-3 text-sm"
103
+ >
104
+ <AlertTriangle size={16} className="shrink-0" aria-hidden="true" />
105
+ <span className="flex-1">{settings.saveError}</span>
106
+ <button
107
+ type="button"
108
+ onClick={settings.save}
109
+ className="border-destructive/40 rounded-md border px-3 py-1 transition-colors hover:opacity-80"
110
+ >
111
+ Retry
112
+ </button>
113
+ </div>
114
+ )}
115
+
116
+ <SettingsSaveBar
117
+ dirty={settings.dirty}
118
+ canEdit={canEdit}
119
+ hasErrors={settings.hasErrors}
120
+ saveState={settings.saveState}
121
+ onSave={settings.save}
122
+ onReset={settings.reset}
123
+ />
124
+ </div>
125
+ )
126
+ }
@@ -0,0 +1,119 @@
1
+ 'use client'
2
+
3
+ import { useId, useMemo } from 'react'
4
+ import type { GeneralSettingsForm, ValidationIssue } from './useGeneralSettings.js'
5
+
6
+ const INPUT_CLASS =
7
+ '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 disabled:cursor-not-allowed disabled:opacity-60'
8
+ const LABEL_CLASS = 'mb-1 block text-sm font-medium text-foreground'
9
+
10
+ const LANGUAGES: Array<{ value: string; label: string }> = [
11
+ { value: 'en-US', label: 'English (US)' },
12
+ { value: 'en-GB', label: 'English (UK)' },
13
+ { value: 'es-ES', label: 'Spanish (Spain)' },
14
+ { value: 'es-MX', label: 'Spanish (Mexico)' },
15
+ { value: 'fr-FR', label: 'French (France)' },
16
+ { value: 'de-DE', label: 'German (Germany)' },
17
+ { value: 'en', label: 'English' },
18
+ ]
19
+
20
+ const TIMEZONES = [
21
+ 'UTC',
22
+ 'America/New_York',
23
+ 'America/Chicago',
24
+ 'America/Denver',
25
+ 'America/Los_Angeles',
26
+ 'America/Anchorage',
27
+ 'Europe/London',
28
+ 'Europe/Paris',
29
+ 'Europe/Berlin',
30
+ 'Asia/Tokyo',
31
+ 'Asia/Singapore',
32
+ 'Australia/Sydney',
33
+ ]
34
+
35
+ interface LanguageRegionCardProps {
36
+ form: GeneralSettingsForm
37
+ setField: <K extends keyof GeneralSettingsForm>(key: K, value: GeneralSettingsForm[K]) => void
38
+ issues: ValidationIssue[]
39
+ canEdit: boolean
40
+ }
41
+
42
+ export function LanguageRegionCard({ form, setField, issues, canEdit }: LanguageRegionCardProps) {
43
+ const langId = useId()
44
+ const tzId = useId()
45
+ const langIssue = issues.find((i) => i.field === 'language')
46
+ const tzIssue = issues.find((i) => i.field === 'timezone')
47
+
48
+ // Surface the loaded value even when it isn't one of our presets, so a
49
+ // seeded/config value is never silently dropped from the select.
50
+ const languageOptions = useMemo(() => {
51
+ if (LANGUAGES.some((l) => l.value === form.language)) return LANGUAGES
52
+ return [{ value: form.language, label: form.language }, ...LANGUAGES]
53
+ }, [form.language])
54
+
55
+ const timezoneOptions = useMemo(() => {
56
+ if (TIMEZONES.includes(form.timezone)) return TIMEZONES
57
+ return [form.timezone, ...TIMEZONES]
58
+ }, [form.timezone])
59
+
60
+ return (
61
+ <section
62
+ aria-labelledby={`${langId}-heading`}
63
+ className="border-border bg-card rounded-lg border p-4"
64
+ >
65
+ <h3 id={`${langId}-heading`} className="text-foreground mb-4 text-base font-medium">
66
+ Language &amp; Region
67
+ </h3>
68
+ <div className="space-y-4">
69
+ <div>
70
+ <label htmlFor={langId} className={LABEL_CLASS}>
71
+ Language
72
+ </label>
73
+ <select
74
+ id={langId}
75
+ value={form.language}
76
+ disabled={!canEdit}
77
+ aria-invalid={Boolean(langIssue)}
78
+ onChange={(e) => setField('language', e.target.value)}
79
+ className={INPUT_CLASS}
80
+ >
81
+ {languageOptions.map((l) => (
82
+ <option key={l.value} value={l.value}>
83
+ {l.label}
84
+ </option>
85
+ ))}
86
+ </select>
87
+ {langIssue && <p className="text-destructive mt-1 text-sm">{langIssue.message}</p>}
88
+ </div>
89
+
90
+ <div>
91
+ <label htmlFor={tzId} className={LABEL_CLASS}>
92
+ Timezone
93
+ </label>
94
+ <select
95
+ id={tzId}
96
+ value={form.timezone}
97
+ disabled={!canEdit}
98
+ aria-invalid={Boolean(tzIssue)}
99
+ onChange={(e) => setField('timezone', e.target.value)}
100
+ className={INPUT_CLASS}
101
+ >
102
+ {timezoneOptions.map((tz) => (
103
+ <option key={tz} value={tz}>
104
+ {tz}
105
+ </option>
106
+ ))}
107
+ </select>
108
+ {tzIssue ? (
109
+ <p className="text-destructive mt-1 text-sm">{tzIssue.message}</p>
110
+ ) : (
111
+ <p className="text-muted-foreground mt-1 text-sm">
112
+ Applied to dashboard dates, audit logs, scheduled publishing, and form submissions.
113
+ </p>
114
+ )}
115
+ </div>
116
+ </div>
117
+ </section>
118
+ )
119
+ }