@actuate-media/cms-admin 0.56.2 → 0.57.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.
- package/CHANGELOG.md +9 -0
- package/dist/__tests__/views/ai-settings.render.test.js +34 -0
- package/dist/__tests__/views/ai-settings.render.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/AICoauthorBubbleMenu.d.ts.map +1 -1
- package/dist/components/AICoauthorBubbleMenu.js +5 -1
- package/dist/components/AICoauthorBubbleMenu.js.map +1 -1
- package/dist/components/BrandVoiceAlignmentBadge.d.ts +6 -0
- package/dist/components/BrandVoiceAlignmentBadge.d.ts.map +1 -0
- package/dist/components/BrandVoiceAlignmentBadge.js +11 -0
- package/dist/components/BrandVoiceAlignmentBadge.js.map +1 -0
- package/dist/components/CoauthorResultPopover.d.ts +5 -1
- package/dist/components/CoauthorResultPopover.d.ts.map +1 -1
- package/dist/components/CoauthorResultPopover.js +35 -6
- package/dist/components/CoauthorResultPopover.js.map +1 -1
- package/dist/components/CommandPalette.d.ts.map +1 -1
- package/dist/components/CommandPalette.js +5 -1
- package/dist/components/CommandPalette.js.map +1 -1
- package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -1
- package/dist/components/seo/SeoEditorDrawer.js +43 -2
- package/dist/components/seo/SeoEditorDrawer.js.map +1 -1
- package/dist/components/ui/Badge.d.ts +1 -1
- package/dist/lib/brand-voice-client.d.ts +21 -0
- package/dist/lib/brand-voice-client.d.ts.map +1 -0
- package/dist/lib/brand-voice-client.js +27 -0
- package/dist/lib/brand-voice-client.js.map +1 -0
- package/dist/lib/coauthor-client.d.ts +2 -0
- package/dist/lib/coauthor-client.d.ts.map +1 -1
- package/dist/lib/coauthor-client.js +1 -0
- package/dist/lib/coauthor-client.js.map +1 -1
- package/dist/lib/seo-service.d.ts +12 -0
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +0 -10
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/views/Settings.js +1 -1
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/settings/AISettingsTab.d.ts +3 -1
- package/dist/views/settings/AISettingsTab.d.ts.map +1 -1
- package/dist/views/settings/AISettingsTab.js +2 -2
- package/dist/views/settings/AISettingsTab.js.map +1 -1
- package/dist/views/settings/BrandVoiceCard.d.ts +39 -2
- package/dist/views/settings/BrandVoiceCard.d.ts.map +1 -1
- package/dist/views/settings/BrandVoiceCard.js +215 -3
- package/dist/views/settings/BrandVoiceCard.js.map +1 -1
- package/dist/views/settings/brand-voice/BrandVoiceAlignmentPanel.d.ts +13 -0
- package/dist/views/settings/brand-voice/BrandVoiceAlignmentPanel.d.ts.map +1 -0
- package/dist/views/settings/brand-voice/BrandVoiceAlignmentPanel.js +74 -0
- package/dist/views/settings/brand-voice/BrandVoiceAlignmentPanel.js.map +1 -0
- package/dist/views/settings/brand-voice/BrandVoiceRulesSections.d.ts +7 -0
- package/dist/views/settings/brand-voice/BrandVoiceRulesSections.d.ts.map +1 -0
- package/dist/views/settings/brand-voice/BrandVoiceRulesSections.js +54 -0
- package/dist/views/settings/brand-voice/BrandVoiceRulesSections.js.map +1 -0
- package/dist/views/settings/useAiSettings.d.ts +32 -0
- package/dist/views/settings/useAiSettings.d.ts.map +1 -1
- package/dist/views/settings/useAiSettings.js +85 -3
- package/dist/views/settings/useAiSettings.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/views/ai-settings.render.test.tsx +50 -0
- package/src/components/AICoauthorBubbleMenu.tsx +12 -0
- package/src/components/BrandVoiceAlignmentBadge.tsx +39 -0
- package/src/components/CoauthorResultPopover.tsx +62 -4
- package/src/components/CommandPalette.tsx +12 -0
- package/src/components/seo/SeoEditorDrawer.tsx +65 -1
- package/src/lib/brand-voice-client.ts +38 -0
- package/src/lib/coauthor-client.ts +4 -0
- package/src/lib/seo-service.ts +2 -0
- package/src/views/Settings.tsx +1 -1
- package/src/views/settings/AISettingsTab.tsx +13 -2
- package/src/views/settings/BrandVoiceCard.tsx +592 -76
- package/src/views/settings/brand-voice/BrandVoiceAlignmentPanel.tsx +205 -0
- package/src/views/settings/brand-voice/BrandVoiceRulesSections.tsx +321 -0
- package/src/views/settings/useAiSettings.ts +134 -3
|
@@ -1,18 +1,93 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
applyStructuredProfileToDocument,
|
|
5
|
+
BRAND_VOICE_EXPORT_FILENAME,
|
|
6
|
+
ensureBrandVoiceDocument,
|
|
7
|
+
parseBrandVoiceDocument,
|
|
8
|
+
parseStructuredProfile,
|
|
9
|
+
type BrandAlignmentScoreResult,
|
|
10
|
+
type BrandVoiceStructuredProfile,
|
|
11
|
+
} from '@actuate-media/cms-core/brand-voice'
|
|
12
|
+
import { Download, FileUp, Link2, Loader2, Sparkles } from 'lucide-react'
|
|
13
|
+
import { useCallback, useId, useMemo, useRef, useState } from 'react'
|
|
14
|
+
import { toast } from 'sonner'
|
|
15
|
+
import { BrandVoiceAlignmentPanel } from './brand-voice/BrandVoiceAlignmentPanel.js'
|
|
16
|
+
import { BrandVoiceRulesSections } from './brand-voice/BrandVoiceRulesSections.js'
|
|
4
17
|
import { SettingsCard, SettingsToggleRow } from './components.js'
|
|
5
|
-
import type { BrandVoiceForm } from './useAiSettings.js'
|
|
18
|
+
import type { BrandVoiceEditMode, BrandVoiceForm } from './useAiSettings.js'
|
|
6
19
|
|
|
7
20
|
const INPUT_CLASS =
|
|
8
21
|
'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'
|
|
9
22
|
const TEXTAREA_CLASS = `${INPUT_CLASS} min-h-[88px] resize-y`
|
|
23
|
+
const DOCUMENT_TEXTAREA_CLASS = `${INPUT_CLASS} min-h-[280px] resize-y font-mono text-sm`
|
|
10
24
|
const LABEL_CLASS = 'mb-1 block text-sm font-medium text-foreground'
|
|
11
25
|
|
|
12
26
|
const TONE_MAX = 400
|
|
13
27
|
const GUIDELINES_MAX = 2000
|
|
14
28
|
const SAMPLE_MAX = 2000
|
|
15
29
|
|
|
30
|
+
export interface BrandVoiceCollectionOption {
|
|
31
|
+
slug: string
|
|
32
|
+
label: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface BrandVoiceAnalyzeFn {
|
|
36
|
+
(input: {
|
|
37
|
+
collections: string[]
|
|
38
|
+
urls?: string[]
|
|
39
|
+
uploads?: Array<{ name: string; text: string }>
|
|
40
|
+
}): Promise<{
|
|
41
|
+
ok: boolean
|
|
42
|
+
draft?: Partial<BrandVoiceForm>
|
|
43
|
+
sampleCount?: number
|
|
44
|
+
error?: string
|
|
45
|
+
}>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type BrandVoiceScoreInput = {
|
|
49
|
+
content: string
|
|
50
|
+
channel?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface BrandVoiceScoreFn {
|
|
54
|
+
(input: BrandVoiceScoreInput): Promise<{
|
|
55
|
+
ok: boolean
|
|
56
|
+
result?: BrandAlignmentScoreResult
|
|
57
|
+
error?: string
|
|
58
|
+
}>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function contentCollections(config: unknown): BrandVoiceCollectionOption[] {
|
|
62
|
+
const cfg = config as { collections?: Record<string, unknown> | unknown[] } | undefined
|
|
63
|
+
if (!cfg?.collections) return []
|
|
64
|
+
const raw = cfg.collections
|
|
65
|
+
const list: Array<{
|
|
66
|
+
slug?: string
|
|
67
|
+
type?: string
|
|
68
|
+
labels?: { plural?: string }
|
|
69
|
+
admin?: { hidden?: boolean }
|
|
70
|
+
}> = Array.isArray(raw)
|
|
71
|
+
? (raw as typeof list)
|
|
72
|
+
: Object.values(raw as Record<string, (typeof list)[number]>)
|
|
73
|
+
return list
|
|
74
|
+
.filter((c) => (c.type === 'page' || c.type === 'post') && !c.admin?.hidden && c.slug)
|
|
75
|
+
.map((c) => ({
|
|
76
|
+
slug: c.slug!,
|
|
77
|
+
label: c.labels?.plural ?? c.slug!.charAt(0).toUpperCase() + c.slug!.slice(1),
|
|
78
|
+
}))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function downloadTextFile(filename: string, contents: string) {
|
|
82
|
+
const blob = new Blob([contents], { type: 'text/markdown;charset=utf-8' })
|
|
83
|
+
const url = URL.createObjectURL(blob)
|
|
84
|
+
const anchor = document.createElement('a')
|
|
85
|
+
anchor.href = url
|
|
86
|
+
anchor.download = filename
|
|
87
|
+
anchor.click()
|
|
88
|
+
URL.revokeObjectURL(url)
|
|
89
|
+
}
|
|
90
|
+
|
|
16
91
|
/**
|
|
17
92
|
* Brand voice — a reusable description of how generated copy should sound. When
|
|
18
93
|
* enabled, it is injected into the system prompt of content-generation features
|
|
@@ -22,18 +97,226 @@ export function BrandVoiceCard({
|
|
|
22
97
|
value,
|
|
23
98
|
canEdit,
|
|
24
99
|
onChange,
|
|
100
|
+
config,
|
|
101
|
+
onAnalyze,
|
|
102
|
+
onScore,
|
|
103
|
+
editMode,
|
|
104
|
+
onEditModeChange,
|
|
25
105
|
}: {
|
|
26
106
|
value: BrandVoiceForm
|
|
27
107
|
canEdit: boolean
|
|
28
108
|
onChange: (patch: Partial<BrandVoiceForm>) => void
|
|
109
|
+
config?: unknown
|
|
110
|
+
onAnalyze?: BrandVoiceAnalyzeFn
|
|
111
|
+
onScore?: BrandVoiceScoreFn
|
|
112
|
+
editMode: BrandVoiceEditMode
|
|
113
|
+
onEditModeChange: (mode: BrandVoiceEditMode) => void
|
|
29
114
|
}) {
|
|
30
115
|
const nameId = useId()
|
|
31
116
|
const toneId = useId()
|
|
32
117
|
const audienceId = useId()
|
|
33
118
|
const guidelinesId = useId()
|
|
34
119
|
const sampleId = useId()
|
|
120
|
+
const documentId = useId()
|
|
121
|
+
const generatePanelId = useId()
|
|
122
|
+
const importInputId = useId()
|
|
123
|
+
const urlInputId = useId()
|
|
124
|
+
const uploadInputId = useId()
|
|
125
|
+
const importRef = useRef<HTMLInputElement>(null)
|
|
126
|
+
const uploadRef = useRef<HTMLInputElement>(null)
|
|
35
127
|
const disabled = !canEdit
|
|
36
128
|
|
|
129
|
+
const collections = useMemo(() => contentCollections(config), [config])
|
|
130
|
+
const [selectedCollections, setSelectedCollections] = useState<string[]>(() =>
|
|
131
|
+
collections.map((c) => c.slug),
|
|
132
|
+
)
|
|
133
|
+
const [analyzing, setAnalyzing] = useState(false)
|
|
134
|
+
const [lastSampleCount, setLastSampleCount] = useState<number | null>(null)
|
|
135
|
+
const [urlInput, setUrlInput] = useState('')
|
|
136
|
+
const [uploads, setUploads] = useState<Array<{ name: string; text: string }>>([])
|
|
137
|
+
const [importError, setImportError] = useState<string | null>(null)
|
|
138
|
+
const showAdvanced = editMode === 'document'
|
|
139
|
+
|
|
140
|
+
const handleStructuredChange = useCallback(
|
|
141
|
+
(structured: BrandVoiceStructuredProfile) => {
|
|
142
|
+
const document = applyStructuredProfileToDocument(
|
|
143
|
+
value.document.trim() || ensureBrandVoiceDocument(value),
|
|
144
|
+
structured,
|
|
145
|
+
)
|
|
146
|
+
const sampleText =
|
|
147
|
+
structured.workedExamples.find((example) => example.onBrand.trim())?.onBrand ??
|
|
148
|
+
value.sampleText
|
|
149
|
+
onChange({ structured, document, sampleText })
|
|
150
|
+
},
|
|
151
|
+
[onChange, value],
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
const toggleCollection = useCallback((slug: string, checked: boolean) => {
|
|
155
|
+
setSelectedCollections((prev) => {
|
|
156
|
+
if (checked) return prev.includes(slug) ? prev : [...prev, slug]
|
|
157
|
+
return prev.filter((s) => s !== slug)
|
|
158
|
+
})
|
|
159
|
+
}, [])
|
|
160
|
+
|
|
161
|
+
const parsedUrls = useMemo(
|
|
162
|
+
() =>
|
|
163
|
+
urlInput
|
|
164
|
+
.split('\n')
|
|
165
|
+
.map((line) => line.trim())
|
|
166
|
+
.filter(Boolean)
|
|
167
|
+
.slice(0, 5),
|
|
168
|
+
[urlInput],
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
const canAnalyze =
|
|
172
|
+
!disabled &&
|
|
173
|
+
!analyzing &&
|
|
174
|
+
(selectedCollections.length > 0 || parsedUrls.length > 0 || uploads.length > 0)
|
|
175
|
+
|
|
176
|
+
const handleAnalyze = useCallback(async () => {
|
|
177
|
+
if (!onAnalyze || !canAnalyze) return
|
|
178
|
+
setAnalyzing(true)
|
|
179
|
+
setLastSampleCount(null)
|
|
180
|
+
try {
|
|
181
|
+
const res = await onAnalyze({
|
|
182
|
+
collections: selectedCollections,
|
|
183
|
+
urls: parsedUrls.length ? parsedUrls : undefined,
|
|
184
|
+
uploads: uploads.length ? uploads : undefined,
|
|
185
|
+
})
|
|
186
|
+
if (!res.ok || !res.draft) {
|
|
187
|
+
toast.error(res.error ?? 'Brand voice analysis failed.')
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
onChange({
|
|
191
|
+
...res.draft,
|
|
192
|
+
enabled: value.enabled || true,
|
|
193
|
+
version: res.draft.version ?? 1,
|
|
194
|
+
status: res.draft.status ?? 'draft',
|
|
195
|
+
generatedFrom: res.draft.generatedFrom ?? [],
|
|
196
|
+
document: res.draft.document ?? '',
|
|
197
|
+
structured: res.draft.document?.trim()
|
|
198
|
+
? parseStructuredProfile(res.draft.document)
|
|
199
|
+
: value.structured,
|
|
200
|
+
})
|
|
201
|
+
onEditModeChange('form')
|
|
202
|
+
setLastSampleCount(res.sampleCount ?? null)
|
|
203
|
+
toast.success(
|
|
204
|
+
res.sampleCount
|
|
205
|
+
? `Draft profile generated from ${res.sampleCount} content sample${res.sampleCount === 1 ? '' : 's'}. Review and save when ready.`
|
|
206
|
+
: 'Draft profile generated. Review and save when ready.',
|
|
207
|
+
)
|
|
208
|
+
} finally {
|
|
209
|
+
setAnalyzing(false)
|
|
210
|
+
}
|
|
211
|
+
}, [
|
|
212
|
+
canAnalyze,
|
|
213
|
+
onAnalyze,
|
|
214
|
+
onChange,
|
|
215
|
+
onEditModeChange,
|
|
216
|
+
parsedUrls,
|
|
217
|
+
selectedCollections,
|
|
218
|
+
uploads,
|
|
219
|
+
value.enabled,
|
|
220
|
+
])
|
|
221
|
+
|
|
222
|
+
const handleExport = useCallback(() => {
|
|
223
|
+
const markdown = ensureBrandVoiceDocument(value)
|
|
224
|
+
downloadTextFile(BRAND_VOICE_EXPORT_FILENAME, markdown)
|
|
225
|
+
toast.success('Brand voice profile exported.')
|
|
226
|
+
}, [value])
|
|
227
|
+
|
|
228
|
+
const handleImportFile = useCallback(
|
|
229
|
+
async (file: File | undefined) => {
|
|
230
|
+
if (!file) return
|
|
231
|
+
setImportError(null)
|
|
232
|
+
try {
|
|
233
|
+
const text = await file.text()
|
|
234
|
+
const parsed = parseBrandVoiceDocument(text)
|
|
235
|
+
if (!parsed.ok) {
|
|
236
|
+
setImportError(parsed.error)
|
|
237
|
+
toast.error(parsed.error)
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
onChange({
|
|
241
|
+
name: parsed.value.fields.name,
|
|
242
|
+
tone: parsed.value.fields.tone,
|
|
243
|
+
audience: parsed.value.fields.audience,
|
|
244
|
+
guidelines: parsed.value.fields.guidelines,
|
|
245
|
+
sampleText: parsed.value.fields.sampleText,
|
|
246
|
+
document: parsed.value.document,
|
|
247
|
+
version: parsed.value.meta.version,
|
|
248
|
+
status: parsed.value.meta.status,
|
|
249
|
+
generatedFrom: parsed.value.meta.generatedFrom,
|
|
250
|
+
structured: parseStructuredProfile(parsed.value.document),
|
|
251
|
+
enabled: value.enabled || true,
|
|
252
|
+
})
|
|
253
|
+
onEditModeChange('document')
|
|
254
|
+
toast.success('Profile imported. Review and save when ready.')
|
|
255
|
+
} catch {
|
|
256
|
+
const message = 'Could not read the selected file.'
|
|
257
|
+
setImportError(message)
|
|
258
|
+
toast.error(message)
|
|
259
|
+
} finally {
|
|
260
|
+
if (importRef.current) importRef.current.value = ''
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
[onChange, onEditModeChange, value.enabled],
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
const openAdvanced = useCallback(() => {
|
|
267
|
+
onEditModeChange('document')
|
|
268
|
+
if (!value.document.trim()) {
|
|
269
|
+
onChange({ document: ensureBrandVoiceDocument(value) })
|
|
270
|
+
}
|
|
271
|
+
}, [onChange, onEditModeChange, value])
|
|
272
|
+
|
|
273
|
+
const applyDocumentToFields = useCallback(() => {
|
|
274
|
+
const parsed = parseBrandVoiceDocument(value.document)
|
|
275
|
+
if (!parsed.ok) {
|
|
276
|
+
setImportError(parsed.error)
|
|
277
|
+
toast.error(parsed.error)
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
setImportError(null)
|
|
281
|
+
onChange({
|
|
282
|
+
name: parsed.value.fields.name,
|
|
283
|
+
tone: parsed.value.fields.tone,
|
|
284
|
+
audience: parsed.value.fields.audience,
|
|
285
|
+
guidelines: parsed.value.fields.guidelines,
|
|
286
|
+
sampleText: parsed.value.fields.sampleText,
|
|
287
|
+
version: parsed.value.meta.version,
|
|
288
|
+
status: parsed.value.meta.status,
|
|
289
|
+
generatedFrom: parsed.value.meta.generatedFrom,
|
|
290
|
+
structured: parseStructuredProfile(value.document),
|
|
291
|
+
})
|
|
292
|
+
onEditModeChange('form')
|
|
293
|
+
toast.success('Quick-edit fields updated from the profile document.')
|
|
294
|
+
}, [onChange, onEditModeChange, value.document])
|
|
295
|
+
|
|
296
|
+
const handleUploadFiles = useCallback(async (files: FileList | null) => {
|
|
297
|
+
if (!files?.length) return
|
|
298
|
+
const next: Array<{ name: string; text: string }> = []
|
|
299
|
+
for (const file of Array.from(files).slice(0, 5)) {
|
|
300
|
+
if (
|
|
301
|
+
!file.type.startsWith('text/') &&
|
|
302
|
+
!file.name.endsWith('.md') &&
|
|
303
|
+
!file.name.endsWith('.txt')
|
|
304
|
+
) {
|
|
305
|
+
toast.error(`Skipped ${file.name}: only text or markdown files are supported.`)
|
|
306
|
+
continue
|
|
307
|
+
}
|
|
308
|
+
const text = await file.text()
|
|
309
|
+
if (!text.trim()) continue
|
|
310
|
+
next.push({ name: file.name, text })
|
|
311
|
+
}
|
|
312
|
+
if (next.length === 0) {
|
|
313
|
+
toast.error('No readable text found in the selected files.')
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
setUploads((prev) => [...prev, ...next].slice(0, 5))
|
|
317
|
+
toast.success(`Added ${next.length} upload${next.length === 1 ? '' : 's'}.`)
|
|
318
|
+
}, [])
|
|
319
|
+
|
|
37
320
|
return (
|
|
38
321
|
<SettingsCard
|
|
39
322
|
title="Brand voice"
|
|
@@ -48,91 +331,324 @@ export function BrandVoiceCard({
|
|
|
48
331
|
onChange={(v) => onChange({ enabled: v })}
|
|
49
332
|
/>
|
|
50
333
|
|
|
51
|
-
<div className="
|
|
52
|
-
<
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
334
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
335
|
+
<button
|
|
336
|
+
type="button"
|
|
337
|
+
onClick={handleExport}
|
|
338
|
+
disabled={disabled}
|
|
339
|
+
className="border-border text-foreground hover:bg-accent inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
|
340
|
+
>
|
|
341
|
+
<Download size={14} aria-hidden="true" />
|
|
342
|
+
Export .md
|
|
343
|
+
</button>
|
|
344
|
+
<button
|
|
345
|
+
type="button"
|
|
346
|
+
onClick={() => importRef.current?.click()}
|
|
347
|
+
disabled={disabled}
|
|
348
|
+
className="border-border text-foreground hover:bg-accent inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
|
349
|
+
>
|
|
350
|
+
<FileUp size={14} aria-hidden="true" />
|
|
351
|
+
Import .md
|
|
352
|
+
</button>
|
|
353
|
+
<input
|
|
354
|
+
ref={importRef}
|
|
355
|
+
id={importInputId}
|
|
356
|
+
type="file"
|
|
357
|
+
accept=".md,text/markdown,text/plain"
|
|
358
|
+
className="sr-only"
|
|
359
|
+
disabled={disabled}
|
|
360
|
+
onChange={(e) => void handleImportFile(e.target.files?.[0])}
|
|
361
|
+
/>
|
|
362
|
+
{!showAdvanced ? (
|
|
363
|
+
<button
|
|
364
|
+
type="button"
|
|
365
|
+
onClick={openAdvanced}
|
|
60
366
|
disabled={disabled}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
Tone
|
|
70
|
-
</label>
|
|
71
|
-
<input
|
|
72
|
-
id={toneId}
|
|
73
|
-
type="text"
|
|
74
|
-
value={value.tone}
|
|
367
|
+
className="text-primary hover:text-primary/80 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
|
368
|
+
>
|
|
369
|
+
Advanced profile document
|
|
370
|
+
</button>
|
|
371
|
+
) : (
|
|
372
|
+
<button
|
|
373
|
+
type="button"
|
|
374
|
+
onClick={() => onEditModeChange('form')}
|
|
75
375
|
disabled={disabled}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
376
|
+
className="text-primary hover:text-primary/80 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
|
377
|
+
>
|
|
378
|
+
Back to quick edit
|
|
379
|
+
</button>
|
|
380
|
+
)}
|
|
381
|
+
</div>
|
|
382
|
+
|
|
383
|
+
{importError && (
|
|
384
|
+
<div
|
|
385
|
+
role="alert"
|
|
386
|
+
className="border-destructive/30 bg-destructive/10 text-destructive rounded-md border p-3 text-sm"
|
|
387
|
+
>
|
|
388
|
+
{importError}
|
|
81
389
|
</div>
|
|
390
|
+
)}
|
|
82
391
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
392
|
+
{onAnalyze && (
|
|
393
|
+
<div
|
|
394
|
+
id={generatePanelId}
|
|
395
|
+
className="border-border space-y-3 rounded-md border border-dashed p-4"
|
|
396
|
+
>
|
|
397
|
+
<div>
|
|
398
|
+
<p className="text-foreground text-base font-medium">Generate from content</p>
|
|
399
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
400
|
+
Scan published pages and posts, public URLs, or uploaded text files to draft a
|
|
401
|
+
profile you can edit before saving.
|
|
402
|
+
</p>
|
|
403
|
+
</div>
|
|
404
|
+
|
|
405
|
+
{collections.length > 0 && (
|
|
406
|
+
<fieldset className="space-y-2" disabled={disabled || analyzing}>
|
|
407
|
+
<legend className="text-foreground mb-1 text-sm font-medium">
|
|
408
|
+
CMS collections
|
|
409
|
+
</legend>
|
|
410
|
+
{collections.map((col) => {
|
|
411
|
+
const inputId = `${generatePanelId}-${col.slug}`
|
|
412
|
+
const checked = selectedCollections.includes(col.slug)
|
|
413
|
+
return (
|
|
414
|
+
<label
|
|
415
|
+
key={col.slug}
|
|
416
|
+
htmlFor={inputId}
|
|
417
|
+
className="text-foreground flex cursor-pointer items-center gap-2 text-sm"
|
|
418
|
+
>
|
|
419
|
+
<input
|
|
420
|
+
id={inputId}
|
|
421
|
+
type="checkbox"
|
|
422
|
+
checked={checked}
|
|
423
|
+
disabled={disabled || analyzing}
|
|
424
|
+
onChange={(e) => toggleCollection(col.slug, e.target.checked)}
|
|
425
|
+
className="border-border text-primary focus-visible:ring-ring size-4 rounded border focus-visible:ring-2 focus-visible:outline-none"
|
|
426
|
+
/>
|
|
427
|
+
{col.label}
|
|
428
|
+
</label>
|
|
429
|
+
)
|
|
430
|
+
})}
|
|
431
|
+
</fieldset>
|
|
432
|
+
)}
|
|
433
|
+
|
|
434
|
+
<div>
|
|
435
|
+
<label htmlFor={urlInputId} className={LABEL_CLASS}>
|
|
436
|
+
Public URLs (optional)
|
|
437
|
+
</label>
|
|
438
|
+
<textarea
|
|
439
|
+
id={urlInputId}
|
|
440
|
+
value={urlInput}
|
|
441
|
+
disabled={disabled || analyzing}
|
|
442
|
+
placeholder={'https://example.com/about\nOne URL per line, up to 5'}
|
|
443
|
+
onChange={(e) => setUrlInput(e.target.value)}
|
|
444
|
+
className={`${TEXTAREA_CLASS} min-h-[72px] font-mono text-sm`}
|
|
445
|
+
/>
|
|
446
|
+
</div>
|
|
447
|
+
|
|
448
|
+
<div>
|
|
449
|
+
<label htmlFor={uploadInputId} className={LABEL_CLASS}>
|
|
450
|
+
Upload text files (optional)
|
|
451
|
+
</label>
|
|
452
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
453
|
+
<button
|
|
454
|
+
type="button"
|
|
455
|
+
disabled={disabled || analyzing || uploads.length >= 5}
|
|
456
|
+
onClick={() => uploadRef.current?.click()}
|
|
457
|
+
className="border-border text-foreground hover:bg-accent inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
|
458
|
+
>
|
|
459
|
+
<FileUp size={16} aria-hidden="true" />
|
|
460
|
+
Choose files
|
|
461
|
+
</button>
|
|
462
|
+
{uploads.length > 0 && (
|
|
463
|
+
<ul className="text-muted-foreground text-sm">
|
|
464
|
+
{uploads.map((file) => (
|
|
465
|
+
<li key={file.name} className="flex items-center gap-2">
|
|
466
|
+
<Link2 size={14} aria-hidden="true" />
|
|
467
|
+
{file.name}
|
|
468
|
+
<button
|
|
469
|
+
type="button"
|
|
470
|
+
className="text-primary text-xs"
|
|
471
|
+
onClick={() =>
|
|
472
|
+
setUploads((prev) => prev.filter((u) => u.name !== file.name))
|
|
473
|
+
}
|
|
474
|
+
>
|
|
475
|
+
Remove
|
|
476
|
+
</button>
|
|
477
|
+
</li>
|
|
478
|
+
))}
|
|
479
|
+
</ul>
|
|
480
|
+
)}
|
|
481
|
+
</div>
|
|
482
|
+
<input
|
|
483
|
+
ref={uploadRef}
|
|
484
|
+
id={uploadInputId}
|
|
485
|
+
type="file"
|
|
486
|
+
accept=".txt,.md,text/plain,text/markdown"
|
|
487
|
+
multiple
|
|
488
|
+
className="sr-only"
|
|
489
|
+
onChange={(e) => {
|
|
490
|
+
void handleUploadFiles(e.target.files)
|
|
491
|
+
e.target.value = ''
|
|
492
|
+
}}
|
|
493
|
+
/>
|
|
494
|
+
</div>
|
|
495
|
+
|
|
496
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
497
|
+
<button
|
|
498
|
+
type="button"
|
|
499
|
+
onClick={handleAnalyze}
|
|
500
|
+
disabled={!canAnalyze}
|
|
501
|
+
className="bg-primary text-primary-foreground inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
|
502
|
+
>
|
|
503
|
+
{analyzing ? (
|
|
504
|
+
<Loader2 size={16} className="animate-spin" aria-hidden="true" />
|
|
505
|
+
) : (
|
|
506
|
+
<Sparkles size={16} aria-hidden="true" />
|
|
507
|
+
)}
|
|
508
|
+
{analyzing ? 'Analyzing…' : 'Analyze & draft profile'}
|
|
509
|
+
</button>
|
|
510
|
+
{lastSampleCount != null && (
|
|
511
|
+
<p className="text-muted-foreground text-sm" aria-live="polite">
|
|
512
|
+
Last run used {lastSampleCount} sample{lastSampleCount === 1 ? '' : 's'}.
|
|
513
|
+
</p>
|
|
514
|
+
)}
|
|
515
|
+
</div>
|
|
96
516
|
</div>
|
|
517
|
+
)}
|
|
97
518
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
519
|
+
{showAdvanced ? (
|
|
520
|
+
<div className="border-border space-y-3 border-t pt-4">
|
|
521
|
+
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
522
|
+
<div>
|
|
523
|
+
<p className="text-foreground text-base font-medium">Profile document</p>
|
|
524
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
525
|
+
Portable markdown with YAML rules and human guidance. Saving uses this document as
|
|
526
|
+
the source of truth.
|
|
527
|
+
</p>
|
|
528
|
+
</div>
|
|
529
|
+
<p className="text-muted-foreground text-sm">
|
|
530
|
+
v{value.version} · {value.status}
|
|
531
|
+
</p>
|
|
532
|
+
</div>
|
|
533
|
+
<div>
|
|
534
|
+
<label htmlFor={documentId} className={LABEL_CLASS}>
|
|
535
|
+
brand-voice-profile.md
|
|
536
|
+
</label>
|
|
537
|
+
<textarea
|
|
538
|
+
id={documentId}
|
|
539
|
+
value={value.document}
|
|
540
|
+
disabled={disabled}
|
|
541
|
+
onChange={(e) => onChange({ document: e.target.value })}
|
|
542
|
+
className={DOCUMENT_TEXTAREA_CLASS}
|
|
543
|
+
spellCheck={false}
|
|
544
|
+
/>
|
|
545
|
+
</div>
|
|
546
|
+
<div className="flex flex-wrap gap-3">
|
|
547
|
+
<button
|
|
548
|
+
type="button"
|
|
549
|
+
onClick={applyDocumentToFields}
|
|
550
|
+
disabled={disabled || !value.document.trim()}
|
|
551
|
+
className="border-border text-foreground hover:bg-accent rounded-md border px-3 py-2 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
|
552
|
+
>
|
|
553
|
+
Apply to quick-edit fields
|
|
554
|
+
</button>
|
|
555
|
+
</div>
|
|
116
556
|
</div>
|
|
557
|
+
) : (
|
|
558
|
+
<div className="border-border space-y-4 border-t pt-4">
|
|
559
|
+
<div>
|
|
560
|
+
<label htmlFor={nameId} className={LABEL_CLASS}>
|
|
561
|
+
Name
|
|
562
|
+
</label>
|
|
563
|
+
<input
|
|
564
|
+
id={nameId}
|
|
565
|
+
type="text"
|
|
566
|
+
value={value.name}
|
|
567
|
+
disabled={disabled}
|
|
568
|
+
placeholder="e.g. Acme Marketing"
|
|
569
|
+
onChange={(e) => onChange({ name: e.target.value })}
|
|
570
|
+
className={INPUT_CLASS}
|
|
571
|
+
/>
|
|
572
|
+
</div>
|
|
573
|
+
|
|
574
|
+
<div>
|
|
575
|
+
<label htmlFor={toneId} className={LABEL_CLASS}>
|
|
576
|
+
Tone
|
|
577
|
+
</label>
|
|
578
|
+
<input
|
|
579
|
+
id={toneId}
|
|
580
|
+
type="text"
|
|
581
|
+
value={value.tone}
|
|
582
|
+
disabled={disabled}
|
|
583
|
+
maxLength={TONE_MAX}
|
|
584
|
+
placeholder="e.g. confident, warm, plain-spoken"
|
|
585
|
+
onChange={(e) => onChange({ tone: e.target.value })}
|
|
586
|
+
className={INPUT_CLASS}
|
|
587
|
+
/>
|
|
588
|
+
</div>
|
|
117
589
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
590
|
+
<div>
|
|
591
|
+
<label htmlFor={audienceId} className={LABEL_CLASS}>
|
|
592
|
+
Audience <span className="text-muted-foreground font-normal">(optional)</span>
|
|
593
|
+
</label>
|
|
594
|
+
<input
|
|
595
|
+
id={audienceId}
|
|
596
|
+
type="text"
|
|
597
|
+
value={value.audience}
|
|
598
|
+
disabled={disabled}
|
|
599
|
+
placeholder="e.g. busy IT directors at mid-market SaaS companies"
|
|
600
|
+
onChange={(e) => onChange({ audience: e.target.value })}
|
|
601
|
+
className={INPUT_CLASS}
|
|
602
|
+
/>
|
|
603
|
+
</div>
|
|
604
|
+
|
|
605
|
+
<div>
|
|
606
|
+
<label htmlFor={guidelinesId} className={LABEL_CLASS}>
|
|
607
|
+
Guidelines <span className="text-muted-foreground font-normal">(optional)</span>
|
|
608
|
+
</label>
|
|
609
|
+
<textarea
|
|
610
|
+
id={guidelinesId}
|
|
611
|
+
value={value.guidelines}
|
|
612
|
+
disabled={disabled}
|
|
613
|
+
maxLength={GUIDELINES_MAX}
|
|
614
|
+
placeholder={
|
|
615
|
+
'Do: lead with the benefit, use active voice.\nDon\u2019t: use jargon, exclamation points, or hype.'
|
|
616
|
+
}
|
|
617
|
+
onChange={(e) => onChange({ guidelines: e.target.value })}
|
|
618
|
+
className={TEXTAREA_CLASS}
|
|
619
|
+
/>
|
|
620
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
621
|
+
{value.guidelines.length}/{GUIDELINES_MAX}
|
|
622
|
+
</p>
|
|
623
|
+
</div>
|
|
624
|
+
|
|
625
|
+
<div>
|
|
626
|
+
<label htmlFor={sampleId} className={LABEL_CLASS}>
|
|
627
|
+
Sample copy <span className="text-muted-foreground font-normal">(optional)</span>
|
|
628
|
+
</label>
|
|
629
|
+
<textarea
|
|
630
|
+
id={sampleId}
|
|
631
|
+
value={value.sampleText}
|
|
632
|
+
disabled={disabled}
|
|
633
|
+
maxLength={SAMPLE_MAX}
|
|
634
|
+
placeholder="Paste a paragraph of on-brand copy the AI can pattern-match against."
|
|
635
|
+
onChange={(e) => onChange({ sampleText: e.target.value })}
|
|
636
|
+
className={TEXTAREA_CLASS}
|
|
637
|
+
/>
|
|
638
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
639
|
+
{value.sampleText.length}/{SAMPLE_MAX}
|
|
640
|
+
</p>
|
|
641
|
+
</div>
|
|
642
|
+
|
|
643
|
+
<BrandVoiceRulesSections
|
|
644
|
+
structured={value.structured}
|
|
125
645
|
disabled={disabled}
|
|
126
|
-
|
|
127
|
-
placeholder="Paste a paragraph of on-brand copy the AI can pattern-match against."
|
|
128
|
-
onChange={(e) => onChange({ sampleText: e.target.value })}
|
|
129
|
-
className={TEXTAREA_CLASS}
|
|
646
|
+
onChange={handleStructuredChange}
|
|
130
647
|
/>
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
</p>
|
|
648
|
+
|
|
649
|
+
{onScore && <BrandVoiceAlignmentPanel disabled={disabled} onScore={onScore} />}
|
|
134
650
|
</div>
|
|
135
|
-
|
|
651
|
+
)}
|
|
136
652
|
</div>
|
|
137
653
|
</SettingsCard>
|
|
138
654
|
)
|