@actuate-media/cms-admin 0.57.2 → 0.58.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.
@@ -0,0 +1,589 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { Check, ChevronDown, Loader2, Sparkles, Wand2 } from 'lucide-react'
5
+ import { toast } from 'sonner'
6
+ import {
7
+ applySeoIssueFix,
8
+ fetchSeoIssueFixSuggestion,
9
+ fetchSeoContentBrief,
10
+ fetchSeoContentResearch,
11
+ generateSeoContentBrief,
12
+ ignoreSeoIssue,
13
+ isInlineSeoFixIssue,
14
+ researchAndBriefSeoIssue,
15
+ supportsSeoContentBrief,
16
+ supportsSeoContentResearch,
17
+ type SeoContentBrief,
18
+ type SeoContentResearch,
19
+ type SeoFixFieldChange,
20
+ type SeoIssue,
21
+ type SeoIssueFixSuggestion,
22
+ } from '../../lib/seo-service.js'
23
+ import { SeoErrorState, btnPrimary, btnSecondary } from './primitives.js'
24
+
25
+ function formatValue(value: string | boolean | null): string {
26
+ if (value === null || value === '') return '—'
27
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No'
28
+ if (value.length > 280) return `${value.slice(0, 277)}…`
29
+ return value
30
+ }
31
+
32
+ function ChangePreview({ changes }: { changes: SeoFixFieldChange[] }) {
33
+ if (changes.length === 0) return null
34
+ return (
35
+ <div className="border-border overflow-hidden rounded-md border">
36
+ <table className="w-full text-sm" aria-label="Proposed SEO changes">
37
+ <thead>
38
+ <tr className="border-border bg-muted/40 text-muted-foreground border-b text-left">
39
+ <th scope="col" className="py-2 pr-3 pl-3 font-medium">
40
+ Field
41
+ </th>
42
+ <th scope="col" className="py-2 pr-3 font-medium">
43
+ Current
44
+ </th>
45
+ <th scope="col" className="py-2 pr-3 font-medium">
46
+ Proposed
47
+ </th>
48
+ </tr>
49
+ </thead>
50
+ <tbody className="divide-border divide-y">
51
+ {changes.map((c) => (
52
+ <tr key={c.field}>
53
+ <td className="text-foreground py-2 pr-3 pl-3 font-medium">{c.label}</td>
54
+ <td className="text-muted-foreground max-w-48 py-2 pr-3 wrap-break-word">
55
+ {formatValue(c.before)}
56
+ </td>
57
+ <td className="text-foreground max-w-48 py-2 pr-3 wrap-break-word">
58
+ {formatValue(c.after)}
59
+ </td>
60
+ </tr>
61
+ ))}
62
+ </tbody>
63
+ </table>
64
+ </div>
65
+ )
66
+ }
67
+
68
+ function ContentResearchCard({ research }: { research: SeoContentResearch }) {
69
+ return (
70
+ <div className="border-border bg-card space-y-3 rounded-md border p-4">
71
+ <div className="flex items-center gap-2">
72
+ <span className="bg-accent text-foreground inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium">
73
+ <Sparkles className="h-3 w-3" aria-hidden />
74
+ Topic research
75
+ </span>
76
+ </div>
77
+ <p className="text-foreground text-sm">{research.summary}</p>
78
+ <ul className="text-foreground space-y-1.5 text-sm">
79
+ {research.focusKeyword ? (
80
+ <li>
81
+ <span className="text-muted-foreground">Focus keyword: </span>
82
+ {research.focusKeyword}
83
+ </li>
84
+ ) : null}
85
+ {research.suggestedWordCount ? (
86
+ <li>
87
+ <span className="text-muted-foreground">Suggested length: </span>~
88
+ {research.suggestedWordCount} words
89
+ </li>
90
+ ) : null}
91
+ </ul>
92
+ {research.outline.length > 0 ? (
93
+ <div>
94
+ <p className="text-muted-foreground mb-1.5 text-xs font-medium tracking-wide uppercase">
95
+ Suggested outline
96
+ </p>
97
+ <ul className="text-foreground list-disc space-y-1 pl-5 text-sm">
98
+ {research.outline.map((item) => (
99
+ <li key={item}>{item}</li>
100
+ ))}
101
+ </ul>
102
+ </div>
103
+ ) : null}
104
+ {research.faqCandidates.length > 0 ? (
105
+ <div>
106
+ <p className="text-muted-foreground mb-1.5 text-xs font-medium tracking-wide uppercase">
107
+ FAQ candidates
108
+ </p>
109
+ <ul className="text-foreground list-disc space-y-1 pl-5 text-sm">
110
+ {research.faqCandidates.map((q) => (
111
+ <li key={q}>{q}</li>
112
+ ))}
113
+ </ul>
114
+ </div>
115
+ ) : null}
116
+ </div>
117
+ )
118
+ }
119
+
120
+ function ContentBriefCard({ brief }: { brief: SeoContentBrief }) {
121
+ return (
122
+ <div className="border-border bg-card space-y-3 rounded-md border p-4">
123
+ <div className="flex items-center gap-2">
124
+ <span className="bg-primary/10 text-primary inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium">
125
+ <Sparkles className="h-3 w-3" aria-hidden />
126
+ AI content brief
127
+ </span>
128
+ </div>
129
+ <p className="text-foreground text-sm">{brief.summary}</p>
130
+ <ul className="text-foreground space-y-1.5 text-sm">
131
+ {brief.suggestedH1 ? (
132
+ <li>
133
+ <span className="text-muted-foreground">Suggested H1: </span>
134
+ {brief.suggestedH1}
135
+ </li>
136
+ ) : null}
137
+ {brief.targetWordCount ? (
138
+ <li>
139
+ <span className="text-muted-foreground">Target length: </span>~{brief.targetWordCount}{' '}
140
+ words
141
+ </li>
142
+ ) : null}
143
+ {brief.keyTakeawayDraft ? (
144
+ <li>
145
+ <span className="text-muted-foreground">GEO takeaway: </span>
146
+ {brief.keyTakeawayDraft}
147
+ </li>
148
+ ) : null}
149
+ {brief.geoNote ? (
150
+ <li>
151
+ <span className="text-muted-foreground">GEO note: </span>
152
+ {brief.geoNote}
153
+ </li>
154
+ ) : null}
155
+ </ul>
156
+ {brief.topics.length > 0 ? (
157
+ <div>
158
+ <p className="text-muted-foreground mb-1.5 text-xs font-medium tracking-wide uppercase">
159
+ Topics to cover
160
+ </p>
161
+ <ul className="text-foreground list-disc space-y-1 pl-5 text-sm">
162
+ {brief.topics.map((topic) => (
163
+ <li key={topic}>{topic}</li>
164
+ ))}
165
+ </ul>
166
+ </div>
167
+ ) : null}
168
+ {brief.sections.map((section) => (
169
+ <div key={section.heading}>
170
+ <p className="text-foreground text-sm font-medium">{section.heading}</p>
171
+ {section.bullets.length > 0 ? (
172
+ <ul className="text-muted-foreground mt-1 list-disc space-y-1 pl-5 text-sm">
173
+ {section.bullets.map((bullet) => (
174
+ <li key={bullet}>{bullet}</li>
175
+ ))}
176
+ </ul>
177
+ ) : null}
178
+ </div>
179
+ ))}
180
+ </div>
181
+ )
182
+ }
183
+
184
+ function manualPath(issue: SeoIssue): string | null {
185
+ const ent = issue.entityType
186
+ const id = issue.entityId
187
+ switch (issue.fixActionType) {
188
+ case 'edit-metadata':
189
+ case 'edit-canonical':
190
+ case 'edit-og':
191
+ case 'edit-og-image':
192
+ case 'edit-schema':
193
+ if (ent && id) {
194
+ return `/seo?tab=content&entityType=${ent}&entityId=${encodeURIComponent(id)}`
195
+ }
196
+ return null
197
+ case 'edit-content':
198
+ if (ent === 'page' && id) return `/pages/${id}`
199
+ if (ent && id) {
200
+ return `/seo?tab=content&entityType=${ent}&entityId=${encodeURIComponent(id)}`
201
+ }
202
+ return null
203
+ case 'fix-broken-link':
204
+ case 'fix-link':
205
+ case 'create-redirect':
206
+ return '/seo?tab=redirects'
207
+ case 'optimize-media':
208
+ return '/media'
209
+ case 'improve-speed':
210
+ return '/seo?tab=technical'
211
+ default:
212
+ if (ent === 'page' && id) return `/pages/${id}`
213
+ return null
214
+ }
215
+ }
216
+
217
+ export function SeoIssueFixPanel({
218
+ issue,
219
+ expanded,
220
+ onChanged,
221
+ onNavigate,
222
+ onOpenDetails,
223
+ }: {
224
+ issue: SeoIssue
225
+ expanded: boolean
226
+ onChanged?: () => void
227
+ onNavigate?: (path: string) => void
228
+ onOpenDetails?: () => void
229
+ }) {
230
+ const inlineFix = isInlineSeoFixIssue(issue.type)
231
+ const contentBriefEligible = supportsSeoContentBrief(issue.type)
232
+ const researchEligible = supportsSeoContentResearch(issue.type)
233
+
234
+ const [suggestion, setSuggestion] = useState<SeoIssueFixSuggestion | null>(null)
235
+ const [contentBrief, setContentBrief] = useState<SeoContentBrief | null>(null)
236
+ const [contentResearch, setContentResearch] = useState<SeoContentResearch | null>(null)
237
+ const [competitorUrls, setCompetitorUrls] = useState(['', '', ''])
238
+ const [briefStale, setBriefStale] = useState(false)
239
+ const [researchStale, setResearchStale] = useState(false)
240
+ const [loading, setLoading] = useState(false)
241
+ const [error, setError] = useState<string | null>(null)
242
+ const [busy, setBusy] = useState(false)
243
+ const [generatingBrief, setGeneratingBrief] = useState(false)
244
+ const [researching, setResearching] = useState(false)
245
+
246
+ useEffect(() => {
247
+ if (!expanded) {
248
+ setSuggestion(null)
249
+ setContentBrief(null)
250
+ setContentResearch(null)
251
+ setCompetitorUrls(['', '', ''])
252
+ setBriefStale(false)
253
+ setResearchStale(false)
254
+ setError(null)
255
+ return
256
+ }
257
+
258
+ let alive = true
259
+ setLoading(true)
260
+ setError(null)
261
+
262
+ async function load() {
263
+ if (inlineFix) {
264
+ const res = await fetchSeoIssueFixSuggestion(issue.id)
265
+ if (!alive) return
266
+ if (res.error) setError(res.error)
267
+ else setSuggestion(res.suggestion ?? null)
268
+ return
269
+ }
270
+
271
+ if (contentBriefEligible) {
272
+ const briefRes = await fetchSeoContentBrief(issue.id)
273
+ if (!alive) return
274
+ if (briefRes.error) setError(briefRes.error)
275
+ else {
276
+ setContentBrief(briefRes.brief ?? null)
277
+ setBriefStale(briefRes.stale ?? false)
278
+ }
279
+
280
+ if (researchEligible) {
281
+ const researchRes = await fetchSeoContentResearch(issue.id)
282
+ if (!alive) return
283
+ if (researchRes.error && !briefRes.error) setError(researchRes.error)
284
+ else if (!researchRes.error) {
285
+ setContentResearch(researchRes.research ?? null)
286
+ setResearchStale(researchRes.stale ?? false)
287
+ }
288
+ }
289
+ }
290
+ }
291
+
292
+ load()
293
+ .catch((e) => alive && setError(e instanceof Error ? e.message : 'Failed to load'))
294
+ .finally(() => alive && setLoading(false))
295
+
296
+ return () => {
297
+ alive = false
298
+ }
299
+ }, [expanded, issue.id, inlineFix, contentBriefEligible, researchEligible])
300
+
301
+ function parsedCompetitorUrls(): string[] {
302
+ return competitorUrls
303
+ .map((u) => u.trim())
304
+ .filter(Boolean)
305
+ .slice(0, 3)
306
+ }
307
+
308
+ async function handleApprove() {
309
+ if (!suggestion?.autoFixable) return
310
+ setBusy(true)
311
+ const res = await applySeoIssueFix(issue.id, suggestion.fingerprint)
312
+ setBusy(false)
313
+ if (res.error) {
314
+ toast.error(res.error)
315
+ return
316
+ }
317
+ toast.success('Fix applied and issue resolved.')
318
+ onChanged?.()
319
+ }
320
+
321
+ async function handleResearchAndBrief() {
322
+ setResearching(true)
323
+ const res = await researchAndBriefSeoIssue(issue.id, parsedCompetitorUrls())
324
+ setResearching(false)
325
+ if (res.error) {
326
+ toast.error(res.error)
327
+ return
328
+ }
329
+ if (res.research) {
330
+ setContentResearch(res.research)
331
+ setResearchStale(false)
332
+ }
333
+ if (res.brief) {
334
+ setContentBrief(res.brief)
335
+ setBriefStale(false)
336
+ }
337
+ toast.success('Research and content brief generated — review before editing the page.')
338
+ }
339
+ async function handleGenerateBrief() {
340
+ setGeneratingBrief(true)
341
+ const res = await generateSeoContentBrief(issue.id)
342
+ setGeneratingBrief(false)
343
+ if (res.error) {
344
+ toast.error(res.error)
345
+ return
346
+ }
347
+ if (res.brief) {
348
+ setContentBrief(res.brief)
349
+ setBriefStale(false)
350
+ toast.success('Content brief generated — review before editing the page.')
351
+ }
352
+ }
353
+
354
+ async function handleIgnore() {
355
+ setBusy(true)
356
+ const res = await ignoreSeoIssue(issue.id)
357
+ setBusy(false)
358
+ if (res.error) return toast.error(res.error)
359
+ toast.success('Issue ignored.')
360
+ onChanged?.()
361
+ }
362
+
363
+ const manualRoute = manualPath(issue)
364
+
365
+ if (!expanded) return null
366
+
367
+ return (
368
+ <tr>
369
+ <td colSpan={5} className="bg-muted/30 px-4 py-4">
370
+ <div className="space-y-4">
371
+ {loading ? (
372
+ <div
373
+ className="text-muted-foreground flex items-center gap-2 text-sm"
374
+ role="status"
375
+ aria-live="polite"
376
+ >
377
+ <Loader2 className="h-4 w-4 motion-safe:animate-spin" aria-hidden />
378
+ {inlineFix ? 'Loading suggested fix…' : 'Loading issue details…'}
379
+ </div>
380
+ ) : error ? (
381
+ <SeoErrorState message={error} />
382
+ ) : (
383
+ <>
384
+ {!inlineFix && (issue.description || issue.recommendation) ? (
385
+ <div className="space-y-2">
386
+ {issue.description ? (
387
+ <p className="text-foreground text-sm">{issue.description}</p>
388
+ ) : null}
389
+ {issue.recommendation ? (
390
+ <p className="text-muted-foreground text-sm">
391
+ <span className="text-foreground font-medium">Recommended fix: </span>
392
+ {issue.recommendation}
393
+ </p>
394
+ ) : null}
395
+ </div>
396
+ ) : null}
397
+
398
+ {inlineFix && suggestion ? (
399
+ <>
400
+ <div className="flex flex-wrap items-center gap-2">
401
+ {suggestion.source === 'ai' && !suggestion.aiUnavailable ? (
402
+ <span className="bg-primary/10 text-primary inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium">
403
+ <Sparkles className="h-3 w-3" aria-hidden />
404
+ AI suggestion
405
+ </span>
406
+ ) : suggestion.source === 'deterministic' ? (
407
+ <span className="bg-muted text-muted-foreground rounded-full px-2.5 py-0.5 text-xs font-medium">
408
+ Rule-based fix
409
+ </span>
410
+ ) : null}
411
+ </div>
412
+ <div className="flex items-start gap-2">
413
+ <Sparkles className="text-primary mt-0.5 h-4 w-4 shrink-0" aria-hidden />
414
+ <p className="text-foreground text-sm">{suggestion.justification}</p>
415
+ </div>
416
+ <ChangePreview changes={suggestion.changes} />
417
+ </>
418
+ ) : null}
419
+
420
+ {contentBriefEligible ? (
421
+ <>
422
+ {researchEligible ? (
423
+ <div className="space-y-2">
424
+ <p className="text-muted-foreground text-sm">
425
+ Optional competitor URLs (SSRF-safe fetch, max 3) to enrich topic research
426
+ for thin content.
427
+ </p>
428
+ {competitorUrls.map((value, index) => (
429
+ <label key={index} className="block">
430
+ <span className="text-muted-foreground sr-only">
431
+ Competitor URL {index + 1}
432
+ </span>
433
+ <input
434
+ type="url"
435
+ value={value}
436
+ onChange={(e) => {
437
+ const next = [...competitorUrls]
438
+ next[index] = e.target.value
439
+ setCompetitorUrls(next)
440
+ }}
441
+ placeholder={`Competitor URL ${index + 1} (optional)`}
442
+ className="border-border bg-input-background text-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none"
443
+ />
444
+ </label>
445
+ ))}
446
+ </div>
447
+ ) : null}
448
+ {researchStale && !contentResearch ? (
449
+ <p className="text-muted-foreground text-sm" role="status">
450
+ Previous topic research is outdated because the page changed.
451
+ </p>
452
+ ) : null}
453
+ {contentResearch ? <ContentResearchCard research={contentResearch} /> : null}
454
+ {briefStale && !contentBrief ? (
455
+ <p className="text-muted-foreground text-sm" role="status">
456
+ A previous content brief is outdated because the page changed. Generate a new
457
+ one.
458
+ </p>
459
+ ) : null}
460
+ {contentBrief ? <ContentBriefCard brief={contentBrief} /> : null}
461
+ {!contentBrief ? (
462
+ <p className="text-muted-foreground text-sm">
463
+ Generate an AI content brief with suggested headings, topics, and GEO guidance
464
+ for your dev team.
465
+ </p>
466
+ ) : null}
467
+ </>
468
+ ) : null}
469
+
470
+ <div className="flex flex-wrap items-center gap-2">
471
+ {inlineFix && suggestion?.autoFixable ? (
472
+ <button
473
+ type="button"
474
+ className={btnPrimary}
475
+ onClick={handleApprove}
476
+ disabled={busy}
477
+ >
478
+ <Check className="h-4 w-4" aria-hidden />
479
+ {busy ? 'Applying…' : 'Approve fix'}
480
+ </button>
481
+ ) : inlineFix && suggestion?.aiUnavailable ? (
482
+ <p className="text-muted-foreground text-sm">
483
+ Configure an AI provider in Settings → AI to enable generated fixes for this
484
+ issue.
485
+ </p>
486
+ ) : null}
487
+
488
+ {contentBriefEligible ? (
489
+ <>
490
+ {researchEligible ? (
491
+ <button
492
+ type="button"
493
+ className={!contentBrief && !contentResearch ? btnPrimary : btnSecondary}
494
+ onClick={handleResearchAndBrief}
495
+ disabled={busy || researching || generatingBrief}
496
+ >
497
+ <Sparkles
498
+ className={`h-4 w-4 ${researching ? 'motion-safe:animate-pulse' : ''}`}
499
+ aria-hidden
500
+ />
501
+ {researching ? 'Researching…' : 'Research & brief'}
502
+ </button>
503
+ ) : null}
504
+ <button
505
+ type="button"
506
+ className={contentBrief ? btnSecondary : btnPrimary}
507
+ onClick={handleGenerateBrief}
508
+ disabled={busy || generatingBrief || researching}
509
+ >
510
+ <Wand2
511
+ className={`h-4 w-4 ${generatingBrief ? 'motion-safe:animate-pulse' : ''}`}
512
+ aria-hidden
513
+ />
514
+ {generatingBrief
515
+ ? 'Generating brief…'
516
+ : contentBrief
517
+ ? 'Regenerate brief'
518
+ : 'Generate content brief'}
519
+ </button>
520
+ </>
521
+ ) : null}
522
+
523
+ {!inlineFix && !contentBriefEligible ? (
524
+ <p className="text-muted-foreground text-sm">
525
+ This issue requires manual follow-up in the admin or codebase.
526
+ </p>
527
+ ) : null}
528
+
529
+ {manualRoute && onNavigate ? (
530
+ <button
531
+ type="button"
532
+ className={btnSecondary}
533
+ onClick={() => onNavigate(manualRoute)}
534
+ disabled={busy}
535
+ >
536
+ Edit manually
537
+ </button>
538
+ ) : null}
539
+ <button
540
+ type="button"
541
+ className={btnSecondary}
542
+ onClick={handleIgnore}
543
+ disabled={busy}
544
+ >
545
+ Ignore
546
+ </button>
547
+ {onOpenDetails ? (
548
+ <button
549
+ type="button"
550
+ className="text-primary hover:bg-accent focus-visible:ring-ring rounded-md px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none"
551
+ onClick={onOpenDetails}
552
+ disabled={busy}
553
+ >
554
+ More details
555
+ </button>
556
+ ) : null}
557
+ </div>
558
+ </>
559
+ )}
560
+ </div>
561
+ </td>
562
+ </tr>
563
+ )
564
+ }
565
+
566
+ export function SeoIssueFixToggle({
567
+ expanded,
568
+ fixable,
569
+ onToggle,
570
+ }: {
571
+ expanded: boolean
572
+ fixable: boolean
573
+ onToggle: () => void
574
+ }) {
575
+ return (
576
+ <button
577
+ type="button"
578
+ onClick={onToggle}
579
+ aria-expanded={expanded}
580
+ className="text-primary hover:bg-accent focus-visible:ring-ring inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm focus-visible:ring-2 focus-visible:outline-none"
581
+ >
582
+ {fixable ? 'Fix' : 'View'}
583
+ <ChevronDown
584
+ className={`h-3.5 w-3.5 motion-safe:transition-transform ${expanded ? 'rotate-180' : ''}`}
585
+ aria-hidden
586
+ />
587
+ </button>
588
+ )
589
+ }