@actuate-media/cms-admin 0.35.0 → 0.36.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.
- package/dist/__tests__/views/ai-settings.render.test.js +22 -0
- package/dist/__tests__/views/ai-settings.render.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/views/ApiKeys.d.ts.map +1 -1
- package/dist/views/ApiKeys.js +14 -13
- package/dist/views/ApiKeys.js.map +1 -1
- package/dist/views/Profile.d.ts.map +1 -1
- package/dist/views/Profile.js +1 -1
- package/dist/views/Profile.js.map +1 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +25 -20
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/Users.js +1 -1
- package/dist/views/Users.js.map +1 -1
- package/dist/views/settings/AISettingsTab.d.ts.map +1 -1
- package/dist/views/settings/AISettingsTab.js +42 -43
- package/dist/views/settings/AISettingsTab.js.map +1 -1
- package/dist/views/settings/AiFeaturesCard.d.ts.map +1 -1
- package/dist/views/settings/AiFeaturesCard.js +2 -3
- package/dist/views/settings/AiFeaturesCard.js.map +1 -1
- package/dist/views/settings/BrandVoiceCard.d.ts +12 -0
- package/dist/views/settings/BrandVoiceCard.d.ts.map +1 -0
- package/dist/views/settings/BrandVoiceCard.js +25 -0
- package/dist/views/settings/BrandVoiceCard.js.map +1 -0
- package/dist/views/settings/GeneralSettingsTab.js +1 -1
- package/dist/views/settings/LanguageRegionCard.d.ts.map +1 -1
- package/dist/views/settings/LanguageRegionCard.js +2 -1
- package/dist/views/settings/LanguageRegionCard.js.map +1 -1
- package/dist/views/settings/MonthlyUsageCard.d.ts.map +1 -1
- package/dist/views/settings/MonthlyUsageCard.js +3 -3
- package/dist/views/settings/MonthlyUsageCard.js.map +1 -1
- package/dist/views/settings/ProviderConnectionCard.js +1 -1
- package/dist/views/settings/ProviderConnectionCard.js.map +1 -1
- package/dist/views/settings/SecurityStatusCard.d.ts.map +1 -1
- package/dist/views/settings/SecurityStatusCard.js +2 -3
- package/dist/views/settings/SecurityStatusCard.js.map +1 -1
- package/dist/views/settings/SecurityTab.js +1 -1
- package/dist/views/settings/SeoRobotsDefaultsCard.d.ts.map +1 -1
- package/dist/views/settings/SeoRobotsDefaultsCard.js +3 -4
- package/dist/views/settings/SeoRobotsDefaultsCard.js.map +1 -1
- package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
- package/dist/views/settings/SeoSettingsTab.js +5 -8
- package/dist/views/settings/SeoSettingsTab.js.map +1 -1
- package/dist/views/settings/SiteIndexStatusCard.d.ts.map +1 -1
- package/dist/views/settings/SiteIndexStatusCard.js +3 -2
- package/dist/views/settings/SiteIndexStatusCard.js.map +1 -1
- package/dist/views/settings/SiteInformationCard.d.ts.map +1 -1
- package/dist/views/settings/SiteInformationCard.js +4 -4
- package/dist/views/settings/SiteInformationCard.js.map +1 -1
- package/dist/views/settings/components.d.ts +31 -1
- package/dist/views/settings/components.d.ts.map +1 -1
- package/dist/views/settings/components.js +22 -0
- package/dist/views/settings/components.js.map +1 -1
- package/dist/views/settings/securityPrimitives.d.ts +11 -3
- package/dist/views/settings/securityPrimitives.d.ts.map +1 -1
- package/dist/views/settings/securityPrimitives.js +10 -3
- package/dist/views/settings/securityPrimitives.js.map +1 -1
- package/dist/views/settings/useAiSettings.d.ts +12 -0
- package/dist/views/settings/useAiSettings.d.ts.map +1 -1
- package/dist/views/settings/useAiSettings.js +51 -0
- package/dist/views/settings/useAiSettings.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/views/ai-settings.render.test.tsx +36 -0
- package/src/views/ApiKeys.tsx +190 -154
- package/src/views/Profile.tsx +4 -2
- package/src/views/Settings.tsx +154 -143
- package/src/views/Users.tsx +1 -1
- package/src/views/settings/AISettingsTab.tsx +66 -76
- package/src/views/settings/AiFeaturesCard.tsx +6 -12
- package/src/views/settings/BrandVoiceCard.tsx +139 -0
- package/src/views/settings/GeneralSettingsTab.tsx +1 -1
- package/src/views/settings/LanguageRegionCard.tsx +5 -7
- package/src/views/settings/MonthlyUsageCard.tsx +6 -10
- package/src/views/settings/ProviderConnectionCard.tsx +1 -1
- package/src/views/settings/SecurityStatusCard.tsx +8 -11
- package/src/views/settings/SecurityTab.tsx +1 -1
- package/src/views/settings/SeoRobotsDefaultsCard.tsx +7 -14
- package/src/views/settings/SeoSettingsTab.tsx +17 -26
- package/src/views/settings/SiteIndexStatusCard.tsx +10 -22
- package/src/views/settings/SiteInformationCard.tsx +3 -9
- package/src/views/settings/components.tsx +72 -1
- package/src/views/settings/securityPrimitives.tsx +14 -9
- package/src/views/settings/useAiSettings.ts +76 -0
|
@@ -17,6 +17,7 @@ import { MediaPickerModal } from '../../components/MediaPickerModal.js'
|
|
|
17
17
|
import { SEOConfigPanel } from '../../components/SEOConfigPanel.js'
|
|
18
18
|
import {
|
|
19
19
|
ConfirmDangerousSettingDialog,
|
|
20
|
+
SettingsCard,
|
|
20
21
|
SettingsSaveBar,
|
|
21
22
|
SettingsSourceBadge,
|
|
22
23
|
SettingsToggleRow,
|
|
@@ -82,7 +83,7 @@ export function SeoSettingsTab({ onNavigate, role }: SeoSettingsTabProps) {
|
|
|
82
83
|
<div className="space-y-4" aria-busy="true" aria-live="polite">
|
|
83
84
|
<span className="sr-only">Loading SEO settings…</span>
|
|
84
85
|
{[0, 1, 2, 3].map((i) => (
|
|
85
|
-
<div key={i} className="border-border bg-card rounded-lg border p-
|
|
86
|
+
<div key={i} className="border-border bg-card rounded-lg border p-6">
|
|
86
87
|
<div className="bg-muted mb-4 h-4 w-40 animate-pulse rounded" />
|
|
87
88
|
<div className="space-y-3">
|
|
88
89
|
<div className="bg-muted h-9 w-full animate-pulse rounded" />
|
|
@@ -208,7 +209,6 @@ function SeoWorkspaceCallout({ onNavigate }: { onNavigate?: (path: string) => vo
|
|
|
208
209
|
// ---------------------------------------------------------------------------
|
|
209
210
|
|
|
210
211
|
function MetaDefaultsCard({ seo, canEdit }: { seo: UseSeoSettings; canEdit: boolean }) {
|
|
211
|
-
const headingId = useId()
|
|
212
212
|
const templateId = useId()
|
|
213
213
|
const descId = useId()
|
|
214
214
|
const [pickerOpen, setPickerOpen] = useState(false)
|
|
@@ -229,11 +229,10 @@ function MetaDefaultsCard({ seo, canEdit }: { seo: UseSeoSettings; canEdit: bool
|
|
|
229
229
|
const imageEditable = sources.defaultOgImage?.editable !== false && canEdit
|
|
230
230
|
|
|
231
231
|
return (
|
|
232
|
-
<
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
232
|
+
<SettingsCard
|
|
233
|
+
title="Meta Defaults"
|
|
234
|
+
description="Fallback page title, description, and social image applied when a page defines none of its own."
|
|
235
|
+
>
|
|
237
236
|
<div className="space-y-4">
|
|
238
237
|
{/* Title template */}
|
|
239
238
|
<div>
|
|
@@ -362,7 +361,7 @@ function MetaDefaultsCard({ seo, canEdit }: { seo: UseSeoSettings; canEdit: bool
|
|
|
362
361
|
onSelect={(url) => setField('defaultOgImage', url)}
|
|
363
362
|
onSelectItem={(item) => setField('defaultSocialImageAssetId', item.id)}
|
|
364
363
|
/>
|
|
365
|
-
</
|
|
364
|
+
</SettingsCard>
|
|
366
365
|
)
|
|
367
366
|
}
|
|
368
367
|
|
|
@@ -458,20 +457,14 @@ function VerificationField({
|
|
|
458
457
|
}
|
|
459
458
|
|
|
460
459
|
function SearchEngineVerificationCard({ seo, canEdit }: { seo: UseSeoSettings; canEdit: boolean }) {
|
|
461
|
-
const headingId = useId()
|
|
462
460
|
const { form, setField } = seo
|
|
463
461
|
const onChange = (key: keyof SeoSettingsForm, value: string) => setField(key, value as never)
|
|
464
462
|
|
|
465
463
|
return (
|
|
466
|
-
<
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
<p className="text-muted-foreground mb-4 text-sm">
|
|
471
|
-
Paste the verification token from each provider. We render only safe meta tags into your
|
|
472
|
-
site's head — never arbitrary HTML.
|
|
473
|
-
</p>
|
|
474
|
-
|
|
464
|
+
<SettingsCard
|
|
465
|
+
title="Search Engine Verification"
|
|
466
|
+
description="Paste the verification token from each provider. We render only safe meta tags into your site's head — never arbitrary HTML."
|
|
467
|
+
>
|
|
475
468
|
<div className="space-y-4">
|
|
476
469
|
<VerificationField
|
|
477
470
|
label="Google Search Console"
|
|
@@ -523,7 +516,7 @@ function SearchEngineVerificationCard({ seo, canEdit }: { seo: UseSeoSettings; c
|
|
|
523
516
|
/>
|
|
524
517
|
</div>
|
|
525
518
|
</details>
|
|
526
|
-
</
|
|
519
|
+
</SettingsCard>
|
|
527
520
|
)
|
|
528
521
|
}
|
|
529
522
|
|
|
@@ -540,7 +533,6 @@ function SitemapDefaultsCard({
|
|
|
540
533
|
canEdit: boolean
|
|
541
534
|
onNavigate?: (path: string) => void
|
|
542
535
|
}) {
|
|
543
|
-
const headingId = useId()
|
|
544
536
|
const [pendingDisable, setPendingDisable] = useState(false)
|
|
545
537
|
const { form, setField, sitemap, siteUrl, isProduction, regenerateSitemap, regenerating } = seo
|
|
546
538
|
|
|
@@ -558,11 +550,10 @@ function SitemapDefaultsCard({
|
|
|
558
550
|
: 'Never'
|
|
559
551
|
|
|
560
552
|
return (
|
|
561
|
-
<
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
553
|
+
<SettingsCard
|
|
554
|
+
title="Sitemap"
|
|
555
|
+
description="Control XML sitemap generation and whether search engines are pinged on publish."
|
|
556
|
+
>
|
|
566
557
|
{!siteUrl && (
|
|
567
558
|
<div className="border-warning/30 bg-warning/10 text-warning mb-4 flex items-start gap-2 rounded-md border p-3 text-sm">
|
|
568
559
|
<AlertTriangle size={16} className="mt-0.5 shrink-0" aria-hidden="true" />
|
|
@@ -644,7 +635,7 @@ function SitemapDefaultsCard({
|
|
|
644
635
|
}}
|
|
645
636
|
onCancel={() => setPendingDisable(false)}
|
|
646
637
|
/>
|
|
647
|
-
</
|
|
638
|
+
</SettingsCard>
|
|
648
639
|
)
|
|
649
640
|
}
|
|
650
641
|
|
|
@@ -5,11 +5,11 @@ import {
|
|
|
5
5
|
XCircle,
|
|
6
6
|
AlertTriangle,
|
|
7
7
|
HelpCircle,
|
|
8
|
-
ShieldCheck,
|
|
9
8
|
ExternalLink,
|
|
10
9
|
Search,
|
|
11
10
|
} from 'lucide-react'
|
|
12
11
|
import type { ReactNode } from 'react'
|
|
12
|
+
import { SettingsCard } from './components.js'
|
|
13
13
|
import type { SiteIndexStatus } from './useGeneralSettings.js'
|
|
14
14
|
|
|
15
15
|
interface SiteIndexStatusCardProps {
|
|
@@ -61,23 +61,11 @@ export function SiteIndexStatusCard({
|
|
|
61
61
|
onRunAudit,
|
|
62
62
|
}: SiteIndexStatusCardProps) {
|
|
63
63
|
return (
|
|
64
|
-
<
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
<div className="flex items-start gap-2">
|
|
70
|
-
<ShieldCheck size={18} className="text-muted-foreground mt-0.5" aria-hidden="true" />
|
|
71
|
-
<div>
|
|
72
|
-
<h3 id="site-index-status-heading" className="text-foreground text-base font-medium">
|
|
73
|
-
Site Index Status
|
|
74
|
-
</h3>
|
|
75
|
-
<p className="text-muted-foreground text-sm">
|
|
76
|
-
Current effective crawl and indexing state
|
|
77
|
-
</p>
|
|
78
|
-
</div>
|
|
79
|
-
</div>
|
|
80
|
-
{status && (
|
|
64
|
+
<SettingsCard
|
|
65
|
+
title="Site Index Status"
|
|
66
|
+
description="Current effective crawl and indexing state."
|
|
67
|
+
actions={
|
|
68
|
+
status ? (
|
|
81
69
|
<span
|
|
82
70
|
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium ${SEVERITY_STYLES[status.severity].pill}`}
|
|
83
71
|
>
|
|
@@ -85,9 +73,9 @@ export function SiteIndexStatusCard({
|
|
|
85
73
|
<span className="sr-only">Status: </span>
|
|
86
74
|
{status.label}
|
|
87
75
|
</span>
|
|
88
|
-
)
|
|
89
|
-
|
|
90
|
-
|
|
76
|
+
) : undefined
|
|
77
|
+
}
|
|
78
|
+
>
|
|
91
79
|
{loading && !status ? (
|
|
92
80
|
<div className="space-y-2" aria-hidden="true">
|
|
93
81
|
<div className="bg-muted h-4 w-2/3 animate-pulse rounded" />
|
|
@@ -128,7 +116,7 @@ export function SiteIndexStatusCard({
|
|
|
128
116
|
)}
|
|
129
117
|
</>
|
|
130
118
|
)}
|
|
131
|
-
</
|
|
119
|
+
</SettingsCard>
|
|
132
120
|
)
|
|
133
121
|
}
|
|
134
122
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useId } from 'react'
|
|
4
4
|
import { CheckCircle2 } from 'lucide-react'
|
|
5
|
-
import { SettingsSourceBadge } from './components.js'
|
|
5
|
+
import { SettingsCard, SettingsSourceBadge } from './components.js'
|
|
6
6
|
import type {
|
|
7
7
|
GeneralSettingsForm,
|
|
8
8
|
SettingsSourceMetadata,
|
|
@@ -39,13 +39,7 @@ export function SiteInformationCard({
|
|
|
39
39
|
const urlValid = !urlIssue && form.siteUrl.trim().length > 0
|
|
40
40
|
|
|
41
41
|
return (
|
|
42
|
-
<
|
|
43
|
-
aria-labelledby={`${titleId}-heading`}
|
|
44
|
-
className="border-border bg-card rounded-lg border p-4"
|
|
45
|
-
>
|
|
46
|
-
<h3 id={`${titleId}-heading`} className="text-foreground mb-4 text-base font-medium">
|
|
47
|
-
Site Information
|
|
48
|
-
</h3>
|
|
42
|
+
<SettingsCard title="Site Information" description="Your site's name, tagline, and public URL.">
|
|
49
43
|
<div className="space-y-4">
|
|
50
44
|
<div>
|
|
51
45
|
<label htmlFor={titleId} className={LABEL_CLASS}>
|
|
@@ -117,6 +111,6 @@ export function SiteInformationCard({
|
|
|
117
111
|
)}
|
|
118
112
|
</div>
|
|
119
113
|
</div>
|
|
120
|
-
</
|
|
114
|
+
</SettingsCard>
|
|
121
115
|
)
|
|
122
116
|
}
|
|
@@ -2,9 +2,80 @@
|
|
|
2
2
|
|
|
3
3
|
import * as Dialog from '@radix-ui/react-dialog'
|
|
4
4
|
import { AlertTriangle, Check, Loader2, Lock } from 'lucide-react'
|
|
5
|
-
import type
|
|
5
|
+
import { useId, type ReactNode } from 'react'
|
|
6
6
|
import type { SettingsSourceMetadata, ValidationIssue } from './useGeneralSettings.js'
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* The canonical Settings section card: a bordered `bg-card` surface with a
|
|
10
|
+
* header (title + optional description) and an optional right-aligned actions
|
|
11
|
+
* slot. This is the single source of truth for the Settings design language —
|
|
12
|
+
* every tab's sections render through it so spacing, padding, typography, and
|
|
13
|
+
* the title/description rhythm stay identical across General, Appearance,
|
|
14
|
+
* Security, SEO, AI, and beyond. Match the Appearance tab exactly: `p-6`,
|
|
15
|
+
* `text-lg` medium title, `text-sm` muted description.
|
|
16
|
+
*/
|
|
17
|
+
export function SettingsCard({
|
|
18
|
+
title,
|
|
19
|
+
description,
|
|
20
|
+
actions,
|
|
21
|
+
children,
|
|
22
|
+
className,
|
|
23
|
+
headingLevel: Heading = 'h3',
|
|
24
|
+
}: {
|
|
25
|
+
title: ReactNode
|
|
26
|
+
description?: ReactNode
|
|
27
|
+
/** Right-aligned controls in the header (e.g. a Refresh or Add button). */
|
|
28
|
+
actions?: ReactNode
|
|
29
|
+
children: ReactNode
|
|
30
|
+
className?: string
|
|
31
|
+
/** Override the heading element for correct document outline nesting. */
|
|
32
|
+
headingLevel?: 'h2' | 'h3'
|
|
33
|
+
}) {
|
|
34
|
+
const headingId = useId()
|
|
35
|
+
return (
|
|
36
|
+
<section
|
|
37
|
+
aria-labelledby={headingId}
|
|
38
|
+
className={`border-border bg-card rounded-lg border p-6 ${className ?? ''}`}
|
|
39
|
+
>
|
|
40
|
+
<header className="mb-4 flex items-start justify-between gap-3">
|
|
41
|
+
<div className="min-w-0">
|
|
42
|
+
<Heading id={headingId} className="text-foreground text-lg font-medium">
|
|
43
|
+
{title}
|
|
44
|
+
</Heading>
|
|
45
|
+
{description && <p className="text-muted-foreground mt-0.5 text-sm">{description}</p>}
|
|
46
|
+
</div>
|
|
47
|
+
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
|
|
48
|
+
</header>
|
|
49
|
+
{children}
|
|
50
|
+
</section>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A sub-section inside a {@link SettingsCard}: a top divider plus a
|
|
56
|
+
* `text-base` medium label and optional description. Use for grouped controls
|
|
57
|
+
* within a single card (e.g. "Admin branding" under "Branding").
|
|
58
|
+
*/
|
|
59
|
+
export function SettingsSubsection({
|
|
60
|
+
title,
|
|
61
|
+
description,
|
|
62
|
+
children,
|
|
63
|
+
className,
|
|
64
|
+
}: {
|
|
65
|
+
title: ReactNode
|
|
66
|
+
description?: ReactNode
|
|
67
|
+
children?: ReactNode
|
|
68
|
+
className?: string
|
|
69
|
+
}) {
|
|
70
|
+
return (
|
|
71
|
+
<div className={`border-border border-t pt-4 ${className ?? ''}`}>
|
|
72
|
+
<div className="text-foreground text-base font-medium">{title}</div>
|
|
73
|
+
{description && <p className="text-muted-foreground mt-0.5 text-sm">{description}</p>}
|
|
74
|
+
{children}
|
|
75
|
+
</div>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
8
79
|
const SOURCE_LABEL: Record<SettingsSourceMetadata['source'], string> = {
|
|
9
80
|
config: 'Config-managed',
|
|
10
81
|
environment: 'Environment-managed',
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { AlertTriangle, Check, CircleHelp, X } from 'lucide-react'
|
|
4
4
|
import type { ReactNode } from 'react'
|
|
5
5
|
import type { CheckStatus } from '@actuate-media/cms-core/security-center'
|
|
6
|
+
import { SettingsCard } from './components.js'
|
|
6
7
|
|
|
7
8
|
/** Small status icon used in check/summary rows. Text label always accompanies it. */
|
|
8
9
|
export function CheckIcon({ status }: { status: CheckStatus }) {
|
|
@@ -51,25 +52,29 @@ export function StatusRow({
|
|
|
51
52
|
)
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* Section wrapper for the Security tab. Delegates to the shared
|
|
57
|
+
* {@link SettingsCard} so Security matches the canonical Settings design
|
|
58
|
+
* language (p-6, text-lg title, muted description). `headingId` is accepted
|
|
59
|
+
* for backward compatibility but ignored — {@link SettingsCard} generates and
|
|
60
|
+
* wires its own accessible heading id.
|
|
61
|
+
*/
|
|
55
62
|
export function SecurityCard({
|
|
56
|
-
headingId,
|
|
57
63
|
title,
|
|
58
64
|
description,
|
|
65
|
+
actions,
|
|
59
66
|
children,
|
|
60
67
|
}: {
|
|
61
|
-
|
|
68
|
+
/** @deprecated No longer used; SettingsCard manages the heading id. */
|
|
69
|
+
headingId?: string
|
|
62
70
|
title: string
|
|
63
71
|
description: string
|
|
72
|
+
actions?: ReactNode
|
|
64
73
|
children: ReactNode
|
|
65
74
|
}) {
|
|
66
75
|
return (
|
|
67
|
-
<
|
|
68
|
-
<h3 id={headingId} className="text-foreground mb-1 text-base font-medium">
|
|
69
|
-
{title}
|
|
70
|
-
</h3>
|
|
71
|
-
<p className="text-muted-foreground mb-4 text-sm">{description}</p>
|
|
76
|
+
<SettingsCard title={title} description={description} actions={actions}>
|
|
72
77
|
{children}
|
|
73
|
-
</
|
|
78
|
+
</SettingsCard>
|
|
74
79
|
)
|
|
75
80
|
}
|
|
@@ -93,12 +93,32 @@ export interface AiUsageView {
|
|
|
93
93
|
runCount: number
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/** Editable brand voice fields (the buffered Save-bar portion). */
|
|
97
|
+
export interface BrandVoiceForm {
|
|
98
|
+
enabled: boolean
|
|
99
|
+
name: string
|
|
100
|
+
tone: string
|
|
101
|
+
audience: string
|
|
102
|
+
guidelines: string
|
|
103
|
+
sampleText: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const EMPTY_BRAND_VOICE: BrandVoiceForm = {
|
|
107
|
+
enabled: false,
|
|
108
|
+
name: '',
|
|
109
|
+
tone: '',
|
|
110
|
+
audience: '',
|
|
111
|
+
guidelines: '',
|
|
112
|
+
sampleText: '',
|
|
113
|
+
}
|
|
114
|
+
|
|
96
115
|
/** The buffered (Save-bar) portion of AI settings. */
|
|
97
116
|
export interface AiSettingsForm {
|
|
98
117
|
defaultProviderId: string
|
|
99
118
|
defaultModelId: string
|
|
100
119
|
features: AiFeatureView[]
|
|
101
120
|
monthlyTokenLimit: string
|
|
121
|
+
brandVoice: BrandVoiceForm
|
|
102
122
|
}
|
|
103
123
|
|
|
104
124
|
interface AiSettingsResponse {
|
|
@@ -107,6 +127,14 @@ interface AiSettingsResponse {
|
|
|
107
127
|
defaultModelId?: string
|
|
108
128
|
features: AiFeatureView[]
|
|
109
129
|
budget: { monthlyTokenLimit?: number }
|
|
130
|
+
brandVoice?: {
|
|
131
|
+
enabled?: boolean
|
|
132
|
+
name?: string
|
|
133
|
+
tone?: string
|
|
134
|
+
audience?: string
|
|
135
|
+
guidelines?: string
|
|
136
|
+
sampleText?: string
|
|
137
|
+
}
|
|
110
138
|
}
|
|
111
139
|
providers: AiProviderView[]
|
|
112
140
|
defaultModel: AiModelView | null
|
|
@@ -127,6 +155,14 @@ function formFromResponse(res: AiSettingsResponse): AiSettingsForm {
|
|
|
127
155
|
typeof res.settings.budget.monthlyTokenLimit === 'number'
|
|
128
156
|
? String(res.settings.budget.monthlyTokenLimit)
|
|
129
157
|
: '',
|
|
158
|
+
brandVoice: {
|
|
159
|
+
enabled: res.settings.brandVoice?.enabled ?? false,
|
|
160
|
+
name: res.settings.brandVoice?.name ?? '',
|
|
161
|
+
tone: res.settings.brandVoice?.tone ?? '',
|
|
162
|
+
audience: res.settings.brandVoice?.audience ?? '',
|
|
163
|
+
guidelines: res.settings.brandVoice?.guidelines ?? '',
|
|
164
|
+
sampleText: res.settings.brandVoice?.sampleText ?? '',
|
|
165
|
+
},
|
|
130
166
|
}
|
|
131
167
|
}
|
|
132
168
|
|
|
@@ -135,6 +171,19 @@ const EMPTY_FORM: AiSettingsForm = {
|
|
|
135
171
|
defaultModelId: '',
|
|
136
172
|
features: [],
|
|
137
173
|
monthlyTokenLimit: '',
|
|
174
|
+
brandVoice: { ...EMPTY_BRAND_VOICE },
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** A brand voice with no content and disabled clears the stored profile (send null). */
|
|
178
|
+
function brandVoiceIsEmpty(bv: BrandVoiceForm): boolean {
|
|
179
|
+
return (
|
|
180
|
+
!bv.enabled &&
|
|
181
|
+
!bv.name.trim() &&
|
|
182
|
+
!bv.tone.trim() &&
|
|
183
|
+
!bv.audience.trim() &&
|
|
184
|
+
!bv.guidelines.trim() &&
|
|
185
|
+
!bv.sampleText.trim()
|
|
186
|
+
)
|
|
138
187
|
}
|
|
139
188
|
|
|
140
189
|
export interface ProviderInput {
|
|
@@ -162,6 +211,8 @@ export interface UseAiSettings {
|
|
|
162
211
|
toggleFeature: (key: string, enabled: boolean) => void
|
|
163
212
|
/** Set a per-feature model override. Empty string reverts to the default model. */
|
|
164
213
|
setFeatureModel: (key: string, modelId: string) => void
|
|
214
|
+
/** Patch one or more brand voice fields. */
|
|
215
|
+
setBrandVoice: (patch: Partial<BrandVoiceForm>) => void
|
|
165
216
|
dirty: boolean
|
|
166
217
|
budgetError: string | null
|
|
167
218
|
saveState: SaveState
|
|
@@ -273,6 +324,11 @@ export function useAiSettings(canEdit: boolean): UseAiSettings {
|
|
|
273
324
|
setSaveState('idle')
|
|
274
325
|
}, [])
|
|
275
326
|
|
|
327
|
+
const setBrandVoice = useCallback((patch: Partial<BrandVoiceForm>) => {
|
|
328
|
+
setForm((prev) => ({ ...prev, brandVoice: { ...prev.brandVoice, ...patch } }))
|
|
329
|
+
setSaveState('idle')
|
|
330
|
+
}, [])
|
|
331
|
+
|
|
276
332
|
const budgetError = useMemo(() => {
|
|
277
333
|
if (form.monthlyTokenLimit === '') return null
|
|
278
334
|
const n = Number(form.monthlyTokenLimit)
|
|
@@ -285,6 +341,15 @@ export function useAiSettings(canEdit: boolean): UseAiSettings {
|
|
|
285
341
|
if (form.defaultModelId !== baseline.defaultModelId) return true
|
|
286
342
|
if (form.monthlyTokenLimit !== baseline.monthlyTokenLimit) return true
|
|
287
343
|
if (form.features.length !== baseline.features.length) return true
|
|
344
|
+
const bvKeys: (keyof BrandVoiceForm)[] = [
|
|
345
|
+
'enabled',
|
|
346
|
+
'name',
|
|
347
|
+
'tone',
|
|
348
|
+
'audience',
|
|
349
|
+
'guidelines',
|
|
350
|
+
'sampleText',
|
|
351
|
+
]
|
|
352
|
+
if (bvKeys.some((k) => form.brandVoice[k] !== baseline.brandVoice[k])) return true
|
|
288
353
|
return form.features.some((f, i) => {
|
|
289
354
|
const base = baseline.features[i]
|
|
290
355
|
return f.enabled !== base?.enabled || (f.modelId ?? '') !== (base?.modelId ?? '')
|
|
@@ -310,6 +375,16 @@ export function useAiSettings(canEdit: boolean): UseAiSettings {
|
|
|
310
375
|
monthlyTokenLimit:
|
|
311
376
|
form.monthlyTokenLimit === '' ? null : Number(form.monthlyTokenLimit),
|
|
312
377
|
},
|
|
378
|
+
brandVoice: brandVoiceIsEmpty(form.brandVoice)
|
|
379
|
+
? null
|
|
380
|
+
: {
|
|
381
|
+
enabled: form.brandVoice.enabled,
|
|
382
|
+
name: form.brandVoice.name,
|
|
383
|
+
tone: form.brandVoice.tone,
|
|
384
|
+
audience: form.brandVoice.audience,
|
|
385
|
+
guidelines: form.brandVoice.guidelines,
|
|
386
|
+
sampleText: form.brandVoice.sampleText,
|
|
387
|
+
},
|
|
313
388
|
}),
|
|
314
389
|
})
|
|
315
390
|
if (res.error) throw new Error(res.error)
|
|
@@ -459,6 +534,7 @@ export function useAiSettings(canEdit: boolean): UseAiSettings {
|
|
|
459
534
|
setMonthlyTokenLimit,
|
|
460
535
|
toggleFeature,
|
|
461
536
|
setFeatureModel,
|
|
537
|
+
setBrandVoice,
|
|
462
538
|
dirty,
|
|
463
539
|
budgetError,
|
|
464
540
|
saveState,
|