@actuate-media/cms-admin 0.57.3 → 0.58.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/CHANGELOG.md +12 -0
- package/dist/AdminRoot.d.ts +2 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +4 -0
- package/dist/AdminRoot.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/ErrorBoundary.d.ts.map +1 -1
- package/dist/components/ErrorBoundary.js +2 -0
- package/dist/components/ErrorBoundary.js.map +1 -1
- package/dist/components/seo/SeoIssueFixPanel.d.ts +14 -0
- package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -0
- package/dist/components/seo/SeoIssueFixPanel.js +208 -0
- package/dist/components/seo/SeoIssueFixPanel.js.map +1 -0
- package/dist/lib/sentry.d.ts +7 -0
- package/dist/lib/sentry.d.ts.map +1 -0
- package/dist/lib/sentry.js +29 -0
- package/dist/lib/sentry.js.map +1 -0
- package/dist/lib/seo-service.d.ts +131 -0
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +127 -0
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/views/seo/AuditTab.d.ts.map +1 -1
- package/dist/views/seo/AuditTab.js +87 -12
- package/dist/views/seo/AuditTab.js.map +1 -1
- package/package.json +3 -2
- package/src/AdminRoot.tsx +7 -0
- package/src/components/ErrorBoundary.tsx +2 -0
- package/src/components/seo/SeoIssueFixPanel.tsx +589 -0
- package/src/lib/sentry.ts +32 -0
- package/src/lib/seo-service.ts +263 -0
- package/src/views/seo/AuditTab.tsx +193 -70
package/src/lib/seo-service.ts
CHANGED
|
@@ -356,6 +356,269 @@ export async function reopenSeoIssue(issueId: string): Promise<{ error?: string
|
|
|
356
356
|
return res.error ? { error: res.error } : {}
|
|
357
357
|
}
|
|
358
358
|
|
|
359
|
+
// ─── Inline audit fixes (Phase 1 — deterministic, approval-gated) ───
|
|
360
|
+
|
|
361
|
+
export interface SeoFixFieldChange {
|
|
362
|
+
field: string
|
|
363
|
+
label: string
|
|
364
|
+
before: string | boolean | null
|
|
365
|
+
after: string | boolean | null
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export interface SeoIssueFixSuggestion {
|
|
369
|
+
autoFixable: boolean
|
|
370
|
+
source?: 'deterministic' | 'ai'
|
|
371
|
+
aiUnavailable?: boolean
|
|
372
|
+
fingerprint: string
|
|
373
|
+
patch: Record<string, unknown>
|
|
374
|
+
changes: SeoFixFieldChange[]
|
|
375
|
+
justification: string
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export async function fetchSeoIssueFixSuggestion(issueId: string): Promise<{
|
|
379
|
+
suggestion?: SeoIssueFixSuggestion
|
|
380
|
+
error?: string
|
|
381
|
+
}> {
|
|
382
|
+
const res = await cmsApi<{ suggestion: SeoIssueFixSuggestion }>(
|
|
383
|
+
`/seo/issues/${encodeURIComponent(issueId)}/suggest-fix`,
|
|
384
|
+
{ method: 'POST' },
|
|
385
|
+
)
|
|
386
|
+
if (res.error) return { error: res.error }
|
|
387
|
+
return { suggestion: res.data?.suggestion }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export async function applySeoIssueFix(
|
|
391
|
+
issueId: string,
|
|
392
|
+
fingerprint: string,
|
|
393
|
+
): Promise<{ issue?: SeoIssue; error?: string }> {
|
|
394
|
+
const res = await cmsApi<{ issue: SeoIssue }>(
|
|
395
|
+
`/seo/issues/${encodeURIComponent(issueId)}/apply-fix`,
|
|
396
|
+
{
|
|
397
|
+
method: 'POST',
|
|
398
|
+
body: JSON.stringify({ fingerprint }),
|
|
399
|
+
},
|
|
400
|
+
)
|
|
401
|
+
if (res.error) return { error: res.error }
|
|
402
|
+
return { issue: res.data?.issue }
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export interface SeoFixBriefExport {
|
|
406
|
+
markdown: string
|
|
407
|
+
filename: string
|
|
408
|
+
issueCount: number
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export interface SeoFixBriefExportQuery {
|
|
412
|
+
severity?: SeoSeverity | 'all'
|
|
413
|
+
status?: SeoIssueStatus | 'all'
|
|
414
|
+
auditRunId?: string
|
|
415
|
+
/** Default true — only issues needing dev/content handoff. */
|
|
416
|
+
manualOnly?: boolean
|
|
417
|
+
/** Default true — embed cached AI content briefs when available. */
|
|
418
|
+
includeContentBriefs?: boolean
|
|
419
|
+
/** Default true — embed cached topic research when available. */
|
|
420
|
+
includeContentResearch?: boolean
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export async function exportSeoFixBrief(
|
|
424
|
+
query: SeoFixBriefExportQuery = {},
|
|
425
|
+
): Promise<{ data?: SeoFixBriefExport; error?: string }> {
|
|
426
|
+
const params = new URLSearchParams()
|
|
427
|
+
if (query.severity && query.severity !== 'all') params.set('severity', query.severity)
|
|
428
|
+
if (query.status && query.status !== 'all') params.set('status', query.status)
|
|
429
|
+
if (query.auditRunId) params.set('auditRunId', query.auditRunId)
|
|
430
|
+
if (query.manualOnly === false) params.set('manualOnly', 'false')
|
|
431
|
+
if (query.includeContentBriefs === false) params.set('includeContentBriefs', 'false')
|
|
432
|
+
if (query.includeContentResearch === false) params.set('includeContentResearch', 'false')
|
|
433
|
+
const qs = params.toString()
|
|
434
|
+
const res = await cmsApi<SeoFixBriefExport>(`/seo/issues/export-brief${qs ? `?${qs}` : ''}`)
|
|
435
|
+
if (res.error) return { error: res.error }
|
|
436
|
+
return { data: res.data }
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Issue types with inline Approve fix in the Audit tab (keep in sync with cms-core). */
|
|
440
|
+
export const SEO_INLINE_FIX_ISSUE_TYPES = new Set([
|
|
441
|
+
'meta-title-length',
|
|
442
|
+
'meta-description-length',
|
|
443
|
+
'accidental-noindex',
|
|
444
|
+
'missing-meta-title',
|
|
445
|
+
'missing-meta-description',
|
|
446
|
+
'missing-canonical',
|
|
447
|
+
'duplicate-meta-title',
|
|
448
|
+
'duplicate-meta-description',
|
|
449
|
+
'missing-structured-data',
|
|
450
|
+
])
|
|
451
|
+
|
|
452
|
+
export function isInlineSeoFixIssue(type: string): boolean {
|
|
453
|
+
return SEO_INLINE_FIX_ISSUE_TYPES.has(type)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export const SEO_CONTENT_BRIEF_ISSUE_TYPES = [
|
|
457
|
+
'thin-content',
|
|
458
|
+
'missing-h1',
|
|
459
|
+
'multiple-h1',
|
|
460
|
+
'missing-alt-text',
|
|
461
|
+
] as const
|
|
462
|
+
|
|
463
|
+
export function supportsSeoContentBrief(type: string): boolean {
|
|
464
|
+
return (SEO_CONTENT_BRIEF_ISSUE_TYPES as readonly string[]).includes(type)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export interface SeoContentBriefSection {
|
|
468
|
+
heading: string
|
|
469
|
+
bullets: string[]
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export interface SeoContentBrief {
|
|
473
|
+
issueType: string
|
|
474
|
+
entityId: string
|
|
475
|
+
fingerprint: string
|
|
476
|
+
generatedAt: string
|
|
477
|
+
summary: string
|
|
478
|
+
suggestedH1: string | null
|
|
479
|
+
targetWordCount: number | null
|
|
480
|
+
topics: string[]
|
|
481
|
+
sections: SeoContentBriefSection[]
|
|
482
|
+
keyTakeawayDraft: string | null
|
|
483
|
+
geoNote: string | null
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export async function fetchSeoContentBrief(issueId: string): Promise<{
|
|
487
|
+
supported?: boolean
|
|
488
|
+
brief?: SeoContentBrief | null
|
|
489
|
+
stale?: boolean
|
|
490
|
+
error?: string
|
|
491
|
+
}> {
|
|
492
|
+
const res = await cmsApi<{ supported: boolean; brief: SeoContentBrief | null; stale: boolean }>(
|
|
493
|
+
`/seo/issues/${encodeURIComponent(issueId)}/content-brief`,
|
|
494
|
+
)
|
|
495
|
+
if (res.error) return { error: res.error }
|
|
496
|
+
return {
|
|
497
|
+
supported: res.data?.supported,
|
|
498
|
+
brief: res.data?.brief,
|
|
499
|
+
stale: res.data?.stale,
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export async function generateSeoContentBrief(
|
|
504
|
+
issueId: string,
|
|
505
|
+
): Promise<{ brief?: SeoContentBrief; error?: string }> {
|
|
506
|
+
const res = await cmsApi<{ brief: SeoContentBrief }>(
|
|
507
|
+
`/seo/issues/${encodeURIComponent(issueId)}/content-brief`,
|
|
508
|
+
{ method: 'POST' },
|
|
509
|
+
)
|
|
510
|
+
if (res.error) return { error: res.error }
|
|
511
|
+
return { brief: res.data?.brief }
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export const SEO_RESEARCH_ISSUE_TYPES = ['thin-content'] as const
|
|
515
|
+
|
|
516
|
+
export function supportsSeoContentResearch(type: string): boolean {
|
|
517
|
+
return (SEO_RESEARCH_ISSUE_TYPES as readonly string[]).includes(type)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export interface SeoContentResearchCompetitor {
|
|
521
|
+
url: string
|
|
522
|
+
title: string
|
|
523
|
+
note: string
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
export interface SeoContentResearch {
|
|
527
|
+
issueType: string
|
|
528
|
+
entityId: string
|
|
529
|
+
fingerprint: string
|
|
530
|
+
generatedAt: string
|
|
531
|
+
focusKeyword: string | null
|
|
532
|
+
suggestedWordCount: number | null
|
|
533
|
+
outline: string[]
|
|
534
|
+
faqCandidates: string[]
|
|
535
|
+
entitiesToMention: string[]
|
|
536
|
+
competitorInsights: SeoContentResearchCompetitor[]
|
|
537
|
+
summary: string
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
export async function fetchSeoContentResearch(issueId: string): Promise<{
|
|
541
|
+
supported?: boolean
|
|
542
|
+
research?: SeoContentResearch | null
|
|
543
|
+
stale?: boolean
|
|
544
|
+
error?: string
|
|
545
|
+
}> {
|
|
546
|
+
const res = await cmsApi<{
|
|
547
|
+
supported: boolean
|
|
548
|
+
research: SeoContentResearch | null
|
|
549
|
+
stale: boolean
|
|
550
|
+
}>(`/seo/issues/${encodeURIComponent(issueId)}/content-research`)
|
|
551
|
+
if (res.error) return { error: res.error }
|
|
552
|
+
return {
|
|
553
|
+
supported: res.data?.supported,
|
|
554
|
+
research: res.data?.research,
|
|
555
|
+
stale: res.data?.stale,
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
export async function generateSeoContentResearch(
|
|
560
|
+
issueId: string,
|
|
561
|
+
competitorUrls?: string[],
|
|
562
|
+
): Promise<{ research?: SeoContentResearch; error?: string }> {
|
|
563
|
+
const res = await cmsApi<{ research: SeoContentResearch }>(
|
|
564
|
+
`/seo/issues/${encodeURIComponent(issueId)}/content-research`,
|
|
565
|
+
{
|
|
566
|
+
method: 'POST',
|
|
567
|
+
body: JSON.stringify({ competitorUrls: competitorUrls?.filter(Boolean) }),
|
|
568
|
+
},
|
|
569
|
+
)
|
|
570
|
+
if (res.error) return { error: res.error }
|
|
571
|
+
return { research: res.data?.research }
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
export async function researchAndBriefSeoIssue(
|
|
575
|
+
issueId: string,
|
|
576
|
+
competitorUrls?: string[],
|
|
577
|
+
): Promise<{ research?: SeoContentResearch | null; brief?: SeoContentBrief; error?: string }> {
|
|
578
|
+
const res = await cmsApi<{ research: SeoContentResearch | null; brief: SeoContentBrief }>(
|
|
579
|
+
`/seo/issues/${encodeURIComponent(issueId)}/research-and-brief`,
|
|
580
|
+
{
|
|
581
|
+
method: 'POST',
|
|
582
|
+
body: JSON.stringify({ competitorUrls: competitorUrls?.filter(Boolean) }),
|
|
583
|
+
},
|
|
584
|
+
)
|
|
585
|
+
if (res.error) return { error: res.error }
|
|
586
|
+
return { research: res.data?.research, brief: res.data?.brief }
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export interface BatchContentBriefResult {
|
|
590
|
+
generated: number
|
|
591
|
+
skipped: number
|
|
592
|
+
failed: number
|
|
593
|
+
results: Array<{ issueId: string; status: 'generated' | 'skipped' | 'failed'; error?: string }>
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
export interface BatchContentBriefQuery {
|
|
597
|
+
severity?: SeoSeverity | 'all'
|
|
598
|
+
status?: SeoIssueStatus | 'all'
|
|
599
|
+
issueIds?: string[]
|
|
600
|
+
skipExisting?: boolean
|
|
601
|
+
includeResearch?: boolean
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
export async function batchGenerateSeoContentBriefs(
|
|
605
|
+
query: BatchContentBriefQuery = {},
|
|
606
|
+
): Promise<{ data?: BatchContentBriefResult; error?: string }> {
|
|
607
|
+
const body: Record<string, unknown> = {}
|
|
608
|
+
if (query.severity && query.severity !== 'all') body.severity = query.severity
|
|
609
|
+
if (query.status && query.status !== 'all') body.status = query.status
|
|
610
|
+
if (query.issueIds?.length) body.issueIds = query.issueIds
|
|
611
|
+
if (query.skipExisting === false) body.skipExisting = false
|
|
612
|
+
if (query.includeResearch) body.includeResearch = true
|
|
613
|
+
|
|
614
|
+
const res = await cmsApi<BatchContentBriefResult>('/seo/issues/batch-content-briefs', {
|
|
615
|
+
method: 'POST',
|
|
616
|
+
body: JSON.stringify(body),
|
|
617
|
+
})
|
|
618
|
+
if (res.error) return { error: res.error }
|
|
619
|
+
return { data: res.data }
|
|
620
|
+
}
|
|
621
|
+
|
|
359
622
|
// ─── Technical (sitemap / robots / crawl) ────────────────────────────
|
|
360
623
|
|
|
361
624
|
export interface SitemapStatus {
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useMemo, useState } from 'react'
|
|
4
|
-
import { ClipboardCheck, Clock, CheckCircle2 } from 'lucide-react'
|
|
3
|
+
import { Fragment, useMemo, useState } from 'react'
|
|
4
|
+
import { ClipboardCheck, Clock, CheckCircle2, FileDown, Sparkles } from 'lucide-react'
|
|
5
|
+
import { toast } from 'sonner'
|
|
5
6
|
import {
|
|
6
7
|
fetchSeoIssues,
|
|
7
8
|
fetchSeoAuditRuns,
|
|
9
|
+
batchGenerateSeoContentBriefs,
|
|
10
|
+
exportSeoFixBrief,
|
|
11
|
+
isInlineSeoFixIssue,
|
|
12
|
+
supportsSeoContentBrief,
|
|
8
13
|
type SeoIssue,
|
|
9
14
|
type SeoIssueQuery,
|
|
10
15
|
type SeoSeverity,
|
|
@@ -17,8 +22,10 @@ import {
|
|
|
17
22
|
SeoErrorState,
|
|
18
23
|
severityMeta,
|
|
19
24
|
SeoStatusBadge,
|
|
25
|
+
btnSecondary,
|
|
20
26
|
} from '../../components/seo/primitives.js'
|
|
21
27
|
import { SeoIssueFixDrawer } from '../../components/seo/SeoIssueFixDrawer.js'
|
|
28
|
+
import { SeoIssueFixPanel, SeoIssueFixToggle } from '../../components/seo/SeoIssueFixPanel.js'
|
|
22
29
|
import { useSeoResource } from './useSeoResource.js'
|
|
23
30
|
|
|
24
31
|
const SEVERITY_FILTERS: { id: SeoSeverity | 'all'; label: string }[] = [
|
|
@@ -95,7 +102,10 @@ export function AuditTab({
|
|
|
95
102
|
}) {
|
|
96
103
|
const [severity, setSeverity] = useState<SeoSeverity | 'all'>('all')
|
|
97
104
|
const [status, setStatus] = useState<SeoIssueStatus | 'all'>('open')
|
|
98
|
-
const [
|
|
105
|
+
const [expandedIssueId, setExpandedIssueId] = useState<string | null>(initialIssueId ?? null)
|
|
106
|
+
const [detailIssueId, setDetailIssueId] = useState<string | null>(null)
|
|
107
|
+
const [exportingBrief, setExportingBrief] = useState(false)
|
|
108
|
+
const [batchGenerating, setBatchGenerating] = useState(false)
|
|
99
109
|
|
|
100
110
|
const query: SeoIssueQuery = useMemo(() => ({ severity, status }), [severity, status])
|
|
101
111
|
const { data, loading, error, refetch } = useSeoResource(
|
|
@@ -105,45 +115,142 @@ export function AuditTab({
|
|
|
105
115
|
|
|
106
116
|
const issues = data ?? []
|
|
107
117
|
|
|
118
|
+
const briefEligibleCount = useMemo(
|
|
119
|
+
() => issues.filter((i) => supportsSeoContentBrief(i.type)).length,
|
|
120
|
+
[issues],
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
function toggleExpanded(issueId: string) {
|
|
124
|
+
setExpandedIssueId((current) => (current === issueId ? null : issueId))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function handleChanged() {
|
|
128
|
+
setExpandedIssueId(null)
|
|
129
|
+
refetch()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function handleBatchGenerateBriefs() {
|
|
133
|
+
const issueIds = issues.filter((i) => supportsSeoContentBrief(i.type)).map((i) => i.id)
|
|
134
|
+
if (issueIds.length === 0) {
|
|
135
|
+
toast.message('No content-brief issues in the current list.')
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
setBatchGenerating(true)
|
|
139
|
+
try {
|
|
140
|
+
const res = await batchGenerateSeoContentBriefs({
|
|
141
|
+
severity,
|
|
142
|
+
status,
|
|
143
|
+
issueIds,
|
|
144
|
+
skipExisting: true,
|
|
145
|
+
})
|
|
146
|
+
if (res.error || !res.data) {
|
|
147
|
+
toast.error(res.error ?? 'Batch generation failed.')
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
const { generated, skipped, failed } = res.data
|
|
151
|
+
if (generated === 0 && failed === 0) {
|
|
152
|
+
toast.message(`All ${skipped} brief(s) already cached for the current filters.`)
|
|
153
|
+
} else {
|
|
154
|
+
toast.success(
|
|
155
|
+
`Generated ${generated} brief(s)${skipped ? `, skipped ${skipped} cached` : ''}${failed ? `, ${failed} failed` : ''}.`,
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
refetch()
|
|
159
|
+
} finally {
|
|
160
|
+
setBatchGenerating(false)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function handleExportBrief() {
|
|
165
|
+
setExportingBrief(true)
|
|
166
|
+
try {
|
|
167
|
+
const res = await exportSeoFixBrief({ severity, status, manualOnly: true })
|
|
168
|
+
if (res.error || !res.data?.markdown) {
|
|
169
|
+
toast.error(res.error ?? 'Export failed.')
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
const blob = new Blob([res.data.markdown], { type: 'text/markdown;charset=utf-8' })
|
|
173
|
+
const url = URL.createObjectURL(blob)
|
|
174
|
+
const link = document.createElement('a')
|
|
175
|
+
link.href = url
|
|
176
|
+
link.download = res.data.filename || 'seo-fix-brief.md'
|
|
177
|
+
document.body.appendChild(link)
|
|
178
|
+
link.click()
|
|
179
|
+
document.body.removeChild(link)
|
|
180
|
+
setTimeout(() => URL.revokeObjectURL(url), 0)
|
|
181
|
+
if (res.data.issueCount === 0) {
|
|
182
|
+
toast.message('Brief downloaded — no manual fixes matched the current filters.')
|
|
183
|
+
} else {
|
|
184
|
+
toast.success(`Exported ${res.data.issueCount} manual fix(es) for your dev team.`)
|
|
185
|
+
}
|
|
186
|
+
} finally {
|
|
187
|
+
setExportingBrief(false)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
108
191
|
return (
|
|
109
192
|
<div className="space-y-4">
|
|
110
193
|
<AuditSummaryCards refetchKey={refetchKey} />
|
|
111
194
|
|
|
112
195
|
<SectionCard>
|
|
113
|
-
<div className="mb-4 flex flex-col gap-3
|
|
114
|
-
<div className="flex flex-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
196
|
+
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
|
197
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
|
198
|
+
<div className="flex flex-wrap gap-1" role="group" aria-label="Filter by severity">
|
|
199
|
+
{SEVERITY_FILTERS.map((f) => (
|
|
200
|
+
<button
|
|
201
|
+
key={f.id}
|
|
202
|
+
type="button"
|
|
203
|
+
aria-pressed={severity === f.id}
|
|
204
|
+
onClick={() => setSeverity(f.id)}
|
|
205
|
+
className={`focus-visible:ring-ring rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none ${
|
|
206
|
+
severity === f.id
|
|
207
|
+
? 'bg-primary text-primary-foreground'
|
|
208
|
+
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
|
209
|
+
}`}
|
|
210
|
+
>
|
|
211
|
+
{f.label}
|
|
212
|
+
</button>
|
|
213
|
+
))}
|
|
214
|
+
</div>
|
|
215
|
+
<div className="flex flex-wrap gap-1" role="group" aria-label="Filter by status">
|
|
216
|
+
{STATUS_FILTERS.map((f) => (
|
|
217
|
+
<button
|
|
218
|
+
key={f.id}
|
|
219
|
+
type="button"
|
|
220
|
+
aria-pressed={status === f.id}
|
|
221
|
+
onClick={() => setStatus(f.id)}
|
|
222
|
+
className={`focus-visible:ring-ring rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none ${
|
|
223
|
+
status === f.id
|
|
224
|
+
? 'bg-accent text-foreground'
|
|
225
|
+
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
|
226
|
+
}`}
|
|
227
|
+
>
|
|
228
|
+
{f.label}
|
|
229
|
+
</button>
|
|
230
|
+
))}
|
|
231
|
+
</div>
|
|
130
232
|
</div>
|
|
131
|
-
<div className="flex flex-wrap gap-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
233
|
+
<div className="flex flex-wrap gap-2">
|
|
234
|
+
<button
|
|
235
|
+
type="button"
|
|
236
|
+
className={btnSecondary}
|
|
237
|
+
onClick={handleBatchGenerateBriefs}
|
|
238
|
+
disabled={batchGenerating || loading || briefEligibleCount === 0}
|
|
239
|
+
aria-busy={batchGenerating}
|
|
240
|
+
>
|
|
241
|
+
<Sparkles className="h-4 w-4" aria-hidden />
|
|
242
|
+
{batchGenerating ? 'Generating…' : 'Generate briefs'}
|
|
243
|
+
</button>
|
|
244
|
+
<button
|
|
245
|
+
type="button"
|
|
246
|
+
className={btnSecondary}
|
|
247
|
+
onClick={handleExportBrief}
|
|
248
|
+
disabled={exportingBrief || loading}
|
|
249
|
+
aria-busy={exportingBrief}
|
|
250
|
+
>
|
|
251
|
+
<FileDown className="h-4 w-4" aria-hidden />
|
|
252
|
+
{exportingBrief ? 'Exporting…' : 'Export fix brief'}
|
|
253
|
+
</button>
|
|
147
254
|
</div>
|
|
148
255
|
</div>
|
|
149
256
|
|
|
@@ -178,37 +285,53 @@ export function AuditTab({
|
|
|
178
285
|
<tbody className="divide-border divide-y">
|
|
179
286
|
{issues.map((issue: SeoIssue) => {
|
|
180
287
|
const meta = severityMeta(issue.severity)
|
|
288
|
+
const expanded = expandedIssueId === issue.id
|
|
181
289
|
return (
|
|
182
|
-
<
|
|
183
|
-
<
|
|
184
|
-
<
|
|
185
|
-
<span className={`
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
<
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
<
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
<
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
>
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
290
|
+
<Fragment key={issue.id}>
|
|
291
|
+
<tr className={`hover:bg-accent/40 ${expanded ? 'bg-accent/20' : ''}`}>
|
|
292
|
+
<td className="py-2.5 pr-4">
|
|
293
|
+
<span className={`inline-flex items-center gap-1.5 text-sm ${meta.text}`}>
|
|
294
|
+
<span className={`h-1.5 w-1.5 rounded-full ${meta.dot}`} aria-hidden />
|
|
295
|
+
{meta.label}
|
|
296
|
+
</span>
|
|
297
|
+
</td>
|
|
298
|
+
<td className="py-2.5 pr-4">
|
|
299
|
+
<span className="text-foreground block font-medium">{issue.title}</span>
|
|
300
|
+
<span className="text-muted-foreground block capitalize">
|
|
301
|
+
{issue.category}
|
|
302
|
+
</span>
|
|
303
|
+
</td>
|
|
304
|
+
<td className="text-muted-foreground py-2.5 pr-4">
|
|
305
|
+
<span className="block truncate">
|
|
306
|
+
{issue.entityTitle || issue.url || '—'}
|
|
307
|
+
</span>
|
|
308
|
+
</td>
|
|
309
|
+
<td className="py-2.5 pr-4">
|
|
310
|
+
<SeoStatusBadge
|
|
311
|
+
label={issue.status}
|
|
312
|
+
tone={issueStatusTone(issue.status)}
|
|
313
|
+
/>
|
|
314
|
+
</td>
|
|
315
|
+
<td className="py-2.5 text-right">
|
|
316
|
+
<SeoIssueFixToggle
|
|
317
|
+
expanded={expanded}
|
|
318
|
+
fixable={
|
|
319
|
+
issue.fixable ||
|
|
320
|
+
isInlineSeoFixIssue(issue.type) ||
|
|
321
|
+
supportsSeoContentBrief(issue.type)
|
|
322
|
+
}
|
|
323
|
+
onToggle={() => toggleExpanded(issue.id)}
|
|
324
|
+
/>
|
|
325
|
+
</td>
|
|
326
|
+
</tr>
|
|
327
|
+
<SeoIssueFixPanel
|
|
328
|
+
issue={issue}
|
|
329
|
+
expanded={expanded}
|
|
330
|
+
onChanged={handleChanged}
|
|
331
|
+
onNavigate={onNavigate}
|
|
332
|
+
onOpenDetails={() => setDetailIssueId(issue.id)}
|
|
333
|
+
/>
|
|
334
|
+
</Fragment>
|
|
212
335
|
)
|
|
213
336
|
})}
|
|
214
337
|
</tbody>
|
|
@@ -218,10 +341,10 @@ export function AuditTab({
|
|
|
218
341
|
</SectionCard>
|
|
219
342
|
|
|
220
343
|
<SeoIssueFixDrawer
|
|
221
|
-
open={
|
|
222
|
-
onOpenChange={(o) => !o &&
|
|
223
|
-
issueId={
|
|
224
|
-
onChanged={
|
|
344
|
+
open={detailIssueId !== null}
|
|
345
|
+
onOpenChange={(o) => !o && setDetailIssueId(null)}
|
|
346
|
+
issueId={detailIssueId}
|
|
347
|
+
onChanged={handleChanged}
|
|
225
348
|
onNavigate={onNavigate}
|
|
226
349
|
/>
|
|
227
350
|
</div>
|