@prenta/admin 1.16.0 → 1.17.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 +18 -0
- package/dist/__tests__/components/faq-proposal-preview.render.test.d.ts +2 -0
- package/dist/__tests__/components/faq-proposal-preview.render.test.d.ts.map +1 -0
- package/dist/__tests__/components/faq-proposal-preview.render.test.js +97 -0
- package/dist/__tests__/components/faq-proposal-preview.render.test.js.map +1 -0
- package/dist/__tests__/components/seo-issue-fix-panel.render.test.js +37 -11
- package/dist/__tests__/components/seo-issue-fix-panel.render.test.js.map +1 -1
- package/dist/__tests__/lib/seo-service-errors.test.js +5 -6
- package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
- package/dist/__tests__/lib/seo-service-proposals.test.js +24 -0
- package/dist/__tests__/lib/seo-service-proposals.test.js.map +1 -1
- package/dist/__tests__/views/seo-proposals-tab.render.test.js +277 -0
- package/dist/__tests__/views/seo-proposals-tab.render.test.js.map +1 -1
- package/dist/components/seo/FaqProposalPreview.d.ts +39 -0
- package/dist/components/seo/FaqProposalPreview.d.ts.map +1 -0
- package/dist/components/seo/FaqProposalPreview.js +62 -0
- package/dist/components/seo/FaqProposalPreview.js.map +1 -0
- package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
- package/dist/components/seo/SeoIssueFixPanel.js +7 -2
- package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
- package/dist/lib/seo-service.d.ts +61 -7
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +9 -13
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/prenta-admin.css +1 -1
- package/dist/views/seo/ProposalsTab.d.ts.map +1 -1
- package/dist/views/seo/ProposalsTab.js +160 -7
- package/dist/views/seo/ProposalsTab.js.map +1 -1
- package/dist/views/settings/SeoAutopilotCard.d.ts.map +1 -1
- package/dist/views/settings/SeoAutopilotCard.js +6 -0
- package/dist/views/settings/SeoAutopilotCard.js.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/components/faq-proposal-preview.render.test.tsx +137 -0
- package/src/__tests__/components/seo-issue-fix-panel.render.test.tsx +42 -11
- package/src/__tests__/lib/seo-service-errors.test.ts +5 -9
- package/src/__tests__/lib/seo-service-proposals.test.ts +30 -0
- package/src/__tests__/views/seo-proposals-tab.render.test.tsx +358 -0
- package/src/components/seo/FaqProposalPreview.tsx +134 -0
- package/src/components/seo/SeoIssueFixPanel.tsx +7 -2
- package/src/lib/seo-service.ts +57 -15
- package/src/views/seo/ProposalsTab.tsx +279 -13
- package/src/views/settings/SeoAutopilotCard.tsx +6 -0
- package/dist/components/ContentOverviewChart.d.ts +0 -8
- package/dist/components/ContentOverviewChart.d.ts.map +0 -1
- package/dist/components/ContentOverviewChart.js +0 -20
- package/dist/components/ContentOverviewChart.js.map +0 -1
- package/dist/components/SEOPerformance.d.ts +0 -5
- package/dist/components/SEOPerformance.d.ts.map +0 -1
- package/dist/components/SEOPerformance.js +0 -29
- package/dist/components/SEOPerformance.js.map +0 -1
- package/dist/components/ui/Avatar.d.ts +0 -9
- package/dist/components/ui/Avatar.d.ts.map +0 -1
- package/dist/components/ui/Avatar.js +0 -21
- package/dist/components/ui/Avatar.js.map +0 -1
- package/dist/components/ui/CommandPalette.d.ts +0 -14
- package/dist/components/ui/CommandPalette.d.ts.map +0 -1
- package/dist/components/ui/CommandPalette.js +0 -58
- package/dist/components/ui/CommandPalette.js.map +0 -1
- package/dist/components/ui/DataTable.d.ts +0 -30
- package/dist/components/ui/DataTable.d.ts.map +0 -1
- package/dist/components/ui/DataTable.js +0 -44
- package/dist/components/ui/DataTable.js.map +0 -1
- package/dist/components/ui/Pagination.d.ts +0 -9
- package/dist/components/ui/Pagination.d.ts.map +0 -1
- package/dist/components/ui/Pagination.js +0 -26
- package/dist/components/ui/Pagination.js.map +0 -1
- package/dist/components/ui/Select.d.ts +0 -16
- package/dist/components/ui/Select.d.ts.map +0 -1
- package/dist/components/ui/Select.js +0 -24
- package/dist/components/ui/Select.js.map +0 -1
- package/dist/components/ui/Toast.d.ts +0 -18
- package/dist/components/ui/Toast.d.ts.map +0 -1
- package/dist/components/ui/Toast.js +0 -29
- package/dist/components/ui/Toast.js.map +0 -1
- package/dist/components/ui/index.d.ts +0 -46
- package/dist/components/ui/index.d.ts.map +0 -1
- package/dist/components/ui/index.js +0 -24
- package/dist/components/ui/index.js.map +0 -1
- package/dist/hooks/useContentLock.d.ts +0 -12
- package/dist/hooks/useContentLock.d.ts.map +0 -1
- package/dist/hooks/useContentLock.js +0 -38
- package/dist/hooks/useContentLock.js.map +0 -1
- package/dist/hooks/useDebounce.d.ts +0 -2
- package/dist/hooks/useDebounce.d.ts.map +0 -1
- package/dist/hooks/useDebounce.js +0 -11
- package/dist/hooks/useDebounce.js.map +0 -1
- package/src/components/ContentOverviewChart.tsx +0 -75
- package/src/components/SEOPerformance.tsx +0 -163
- package/src/components/ui/Avatar.tsx +0 -42
- package/src/components/ui/CommandPalette.tsx +0 -133
- package/src/components/ui/DataTable.tsx +0 -204
- package/src/components/ui/Pagination.tsx +0 -94
- package/src/components/ui/Select.tsx +0 -53
- package/src/components/ui/Toast.tsx +0 -72
- package/src/components/ui/index.ts +0 -61
- package/src/hooks/useContentLock.ts +0 -55
- package/src/hooks/useDebounce.ts +0 -14
package/src/lib/seo-service.ts
CHANGED
|
@@ -501,15 +501,30 @@ export async function fetchSeoIssueFixSuggestion(issueId: string): Promise<{
|
|
|
501
501
|
return { suggestion: res.data?.suggestion }
|
|
502
502
|
}
|
|
503
503
|
|
|
504
|
+
/**
|
|
505
|
+
* Per-approval refinements for an `add-section` (FAQ) fix. Both are ignored by
|
|
506
|
+
* the server for every other fix strategy.
|
|
507
|
+
*/
|
|
508
|
+
export interface SeoApplyFixOptions {
|
|
509
|
+
/** Indices into `section.content.items` to keep; the server requires at least two. */
|
|
510
|
+
includeItems?: number[]
|
|
511
|
+
/** Replacement section heading (server trims, then enforces `FAQ_HEADING_MAX`, mirrored from core). */
|
|
512
|
+
headingOverride?: string
|
|
513
|
+
}
|
|
514
|
+
|
|
504
515
|
export async function applySeoIssueFix(
|
|
505
516
|
issueId: string,
|
|
506
517
|
fingerprint: string,
|
|
518
|
+
opts?: SeoApplyFixOptions,
|
|
507
519
|
): Promise<{ issue?: SeoIssue; error?: string }> {
|
|
520
|
+
const body: Record<string, unknown> = { fingerprint }
|
|
521
|
+
if (opts?.includeItems) body.includeItems = opts.includeItems
|
|
522
|
+
if (opts?.headingOverride !== undefined) body.headingOverride = opts.headingOverride
|
|
508
523
|
const res = await cmsApi<{ issue: SeoIssue }>(
|
|
509
524
|
`/seo/issues/${encodeURIComponent(issueId)}/apply-fix`,
|
|
510
525
|
{
|
|
511
526
|
method: 'POST',
|
|
512
|
-
body: JSON.stringify(
|
|
527
|
+
body: JSON.stringify(body),
|
|
513
528
|
},
|
|
514
529
|
)
|
|
515
530
|
if (res.error) return { error: res.error }
|
|
@@ -561,6 +576,7 @@ export const SEO_INLINE_FIX_ISSUE_TYPES = new Set([
|
|
|
561
576
|
'duplicate-meta-title',
|
|
562
577
|
'duplicate-meta-description',
|
|
563
578
|
'missing-structured-data',
|
|
579
|
+
'faq-schema-missing',
|
|
564
580
|
'broken-internal-link',
|
|
565
581
|
'orphan-page',
|
|
566
582
|
])
|
|
@@ -843,6 +859,7 @@ export type SeoAutopilotIssueType =
|
|
|
843
859
|
| 'missing-canonical'
|
|
844
860
|
| 'accidental-noindex'
|
|
845
861
|
| 'missing-structured-data'
|
|
862
|
+
| 'faq-schema-missing'
|
|
846
863
|
| 'redirect'
|
|
847
864
|
|
|
848
865
|
export const SEO_AUTOPILOT_ISSUE_TYPES: readonly SeoAutopilotIssueType[] = [
|
|
@@ -855,6 +872,7 @@ export const SEO_AUTOPILOT_ISSUE_TYPES: readonly SeoAutopilotIssueType[] = [
|
|
|
855
872
|
'missing-canonical',
|
|
856
873
|
'accidental-noindex',
|
|
857
874
|
'missing-structured-data',
|
|
875
|
+
'faq-schema-missing',
|
|
858
876
|
'redirect',
|
|
859
877
|
]
|
|
860
878
|
|
|
@@ -1600,20 +1618,6 @@ export async function explainSeoIssue(issueId: string): Promise<SeoAiResult> {
|
|
|
1600
1618
|
return res.data ?? {}
|
|
1601
1619
|
}
|
|
1602
1620
|
|
|
1603
|
-
export async function generateStructuredData(context: {
|
|
1604
|
-
entityType: string
|
|
1605
|
-
entityId: string
|
|
1606
|
-
schemaType: string
|
|
1607
|
-
}): Promise<SeoAiResult> {
|
|
1608
|
-
const res = await cmsApi<SeoAiResult>('/ai/seo/generate-schema', {
|
|
1609
|
-
method: 'POST',
|
|
1610
|
-
body: JSON.stringify(context),
|
|
1611
|
-
})
|
|
1612
|
-
if (isAiUnavailable(res.status)) return { unavailable: true }
|
|
1613
|
-
if (res.error) return { error: res.error }
|
|
1614
|
-
return res.data ?? {}
|
|
1615
|
-
}
|
|
1616
|
-
|
|
1617
1621
|
export async function summarizeSeoAudit(): Promise<SeoAiResult> {
|
|
1618
1622
|
const res = await cmsApi<SeoAiResult>('/ai/seo/summarize', { method: 'POST' })
|
|
1619
1623
|
if (isAiUnavailable(res.status)) return { unavailable: true }
|
|
@@ -1747,6 +1751,28 @@ export async function fetchLinkHealthIssues(): Promise<{
|
|
|
1747
1751
|
}
|
|
1748
1752
|
|
|
1749
1753
|
// ─── AI proposals inbox ─────────────────────────────────────────────────
|
|
1754
|
+
// Mirrors `packages/cms-core/src/seo/proposals.ts` (`SeoProposal`) and
|
|
1755
|
+
// `seo/faq-signals.ts` (`FaqSignals`). The server is the contract.
|
|
1756
|
+
|
|
1757
|
+
/** Same union as core's `SeoProposalFixStrategy`; `null` for plain metadata writes. */
|
|
1758
|
+
export type SeoProposalFixStrategy = 'redirect' | 'fix-link' | 'insert-link' | 'add-section'
|
|
1759
|
+
|
|
1760
|
+
/** Why a page got a `missing-faq` proposal (28-day window). */
|
|
1761
|
+
export interface SeoFaqSignals {
|
|
1762
|
+
/**
|
|
1763
|
+
* Answer-engine activity. `engines` carries the referrer registry's display
|
|
1764
|
+
* names (`ChatGPT`, `Perplexity`, …), sorted; empty when only retrieval
|
|
1765
|
+
* crawls were seen.
|
|
1766
|
+
*/
|
|
1767
|
+
geo: { referralVisits: number; retrievalHits: number; engines: string[] } | null
|
|
1768
|
+
/** Question-shaped Search Console queries, highest impressions first. */
|
|
1769
|
+
queries: Array<{ query: string; impressions: number; clicks: number }>
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
export interface SeoFaqSectionDraft {
|
|
1773
|
+
sectionType: 'faq'
|
|
1774
|
+
content: { heading: string; items: Array<{ question: string; answer: string }> }
|
|
1775
|
+
}
|
|
1750
1776
|
|
|
1751
1777
|
export interface SeoProposal {
|
|
1752
1778
|
/** `issue:<issueId>` | `redirect:<suggestionId>` */
|
|
@@ -1770,6 +1796,22 @@ export interface SeoProposal {
|
|
|
1770
1796
|
severity: SeoSeverity | null
|
|
1771
1797
|
issueId: string | null
|
|
1772
1798
|
suggestionId: string | null
|
|
1799
|
+
/** Always `'redirect'` for redirect rows; `null` when the cached patch carries none. */
|
|
1800
|
+
fixStrategy: SeoProposalFixStrategy | null
|
|
1801
|
+
/**
|
|
1802
|
+
* `add-section` only: the section approval will insert. `null` when the
|
|
1803
|
+
* cached patch failed core's structural checks — the row must still render
|
|
1804
|
+
* (badge + signals) but cannot be approved from the inbox.
|
|
1805
|
+
*/
|
|
1806
|
+
section: SeoFaqSectionDraft | null
|
|
1807
|
+
/**
|
|
1808
|
+
* `add-section` only: verbatim page quotes parallel to `section.content.items`.
|
|
1809
|
+
* A malformed row arrives as `sourceQuote: ''` — render as "no quote", never
|
|
1810
|
+
* as an empty blockquote.
|
|
1811
|
+
*/
|
|
1812
|
+
grounding: Array<{ sourceQuote: string }> | null
|
|
1813
|
+
/** `missing-faq` only. */
|
|
1814
|
+
signals: SeoFaqSignals | null
|
|
1773
1815
|
}
|
|
1774
1816
|
|
|
1775
1817
|
export interface SeoProposalsPayload {
|
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
* in one review surface. Batch approve honours server governance
|
|
6
6
|
* (`allowBulkApply`); the UI only pre-disables the button using the flags the
|
|
7
7
|
* list payload exposes. Generation is human-triggered, capped per pass, and
|
|
8
|
-
* loops client-side while the server reports `remaining > 0`.
|
|
8
|
+
* loops client-side while the server reports `remaining > 0`. FAQ
|
|
9
|
+
* (`add-section`) rows expand into a per-question preview and approve one at
|
|
10
|
+
* a time through `apply-fix`; in a batch they apply the whole drafted section.
|
|
9
11
|
*/
|
|
10
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
12
|
+
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
11
13
|
import {
|
|
12
14
|
AlertTriangle,
|
|
13
15
|
ArrowRightLeft,
|
|
@@ -22,11 +24,13 @@ import {
|
|
|
22
24
|
} from 'lucide-react'
|
|
23
25
|
import { toast } from 'sonner'
|
|
24
26
|
import {
|
|
27
|
+
applySeoIssueFix,
|
|
25
28
|
bulkApplySeoProposals,
|
|
26
29
|
dismissRedirectSuggestion,
|
|
27
30
|
dismissSeoIssueFix,
|
|
28
31
|
fetchSeoProposals,
|
|
29
32
|
generateSeoProposals,
|
|
33
|
+
type SeoApplyFixOptions,
|
|
30
34
|
type SeoProposal,
|
|
31
35
|
type SeoProposalBulkApplyItem,
|
|
32
36
|
type SeoProposalsPayload,
|
|
@@ -35,6 +39,11 @@ import { hasPlanFeature, type PlanInfo } from '../../lib/plan.js'
|
|
|
35
39
|
import { brandAlignmentLabel, brandAlignmentTone } from '../../lib/brand-voice-client.js'
|
|
36
40
|
import { PlanUpgradeCallout } from '../../components/PlanUpgradeCallout.js'
|
|
37
41
|
import { ChangePreview } from '../../components/seo/ChangePreview.js'
|
|
42
|
+
import {
|
|
43
|
+
FaqProposalPreview,
|
|
44
|
+
faqDraftBlocker,
|
|
45
|
+
faqSignalLine,
|
|
46
|
+
} from '../../components/seo/FaqProposalPreview.js'
|
|
38
47
|
import {
|
|
39
48
|
SectionCard,
|
|
40
49
|
SeoEmptyState,
|
|
@@ -76,6 +85,77 @@ function toBulkItem(p: SeoProposal): SeoProposalBulkApplyItem {
|
|
|
76
85
|
: { kind: 'issue-fix', id: p.issueId ?? '', fingerprint: p.fingerprint ?? undefined }
|
|
77
86
|
}
|
|
78
87
|
|
|
88
|
+
/** An `add-section` (FAQ) proposal; `p.section` may still be `null` when the cache was unreadable. */
|
|
89
|
+
function isFaqProposal(p: SeoProposal): boolean {
|
|
90
|
+
return p.fixStrategy === 'add-section'
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Reviewer edits to a FAQ proposal before approval. `selected` is parallel to
|
|
95
|
+
* `section.content.items`; `heading` is the raw input value (trimmed on send).
|
|
96
|
+
*/
|
|
97
|
+
interface FaqDraft {
|
|
98
|
+
selected: boolean[]
|
|
99
|
+
heading: string
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Drafts are keyed by the drafted content, not just the issue: `fingerprint`
|
|
104
|
+
* tracks the page (`updatedAt`), so dismiss + regenerate on an unchanged page
|
|
105
|
+
* keeps the same id and fingerprint while the questions change. Folding the
|
|
106
|
+
* questions (and `generatedAt`) into the key makes a regenerated proposal
|
|
107
|
+
* start clean instead of inheriting stale ticks or a stale heading edit.
|
|
108
|
+
*/
|
|
109
|
+
function faqDraftKey(p: SeoProposal): string {
|
|
110
|
+
const questions = p.section?.content.items.map((i) => i.question).join('\u0001') ?? ''
|
|
111
|
+
return `${p.id}:${p.generatedAt ?? ''}:${questions}`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function initialFaqDraft(p: SeoProposal): FaqDraft {
|
|
115
|
+
const section = p.section
|
|
116
|
+
return {
|
|
117
|
+
selected: section ? section.content.items.map(() => true) : [],
|
|
118
|
+
heading: section?.content.heading ?? '',
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The row's stored draft when it still fits the section (one `selected` flag
|
|
124
|
+
* per item), otherwise the untouched default. Every reader *and* writer goes
|
|
125
|
+
* through this so a mismatched draft is replaced rather than mutated.
|
|
126
|
+
*/
|
|
127
|
+
function currentFaqDraft(drafts: Record<string, FaqDraft>, p: SeoProposal): FaqDraft {
|
|
128
|
+
const draft = drafts[faqDraftKey(p)]
|
|
129
|
+
if (draft && draft.selected.length === (p.section?.content.items.length ?? 0)) return draft
|
|
130
|
+
return initialFaqDraft(p)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Body of the single-row `apply-fix` call. `includeItems` only when the
|
|
135
|
+
* reviewer dropped a question; `headingOverride` only when the trimmed heading
|
|
136
|
+
* is non-empty and differs from the draft — so an untouched row sends exactly
|
|
137
|
+
* `{ fingerprint }`, the same request the Audit tab makes.
|
|
138
|
+
*/
|
|
139
|
+
function faqApplyOptions(p: SeoProposal, draft: FaqDraft): SeoApplyFixOptions {
|
|
140
|
+
const opts: SeoApplyFixOptions = {}
|
|
141
|
+
const includeItems = draft.selected.flatMap((on, i) => (on ? [i] : []))
|
|
142
|
+
if (includeItems.length < draft.selected.length) opts.includeItems = includeItems
|
|
143
|
+
const heading = draft.heading.trim()
|
|
144
|
+
if (heading !== '' && heading !== p.section?.content.heading) opts.headingOverride = heading
|
|
145
|
+
return opts
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Change-column text for a FAQ row: question count plus the strongest signal. */
|
|
149
|
+
function faqSummary(p: SeoProposal): string {
|
|
150
|
+
const signal = faqSignalLine(p.signals)
|
|
151
|
+
if (!p.section) return signal ?? p.justification
|
|
152
|
+
const count = plural(p.section.content.items.length, 'question')
|
|
153
|
+
return signal ? `${count} · ${signal}` : count
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const FAQ_UNREADABLE_HINT =
|
|
157
|
+
'The drafted section could not be read from the cache. Dismiss this proposal and generate again to get a fresh draft.'
|
|
158
|
+
|
|
79
159
|
function BrandScoreBadge({ score }: { score: number }) {
|
|
80
160
|
return (
|
|
81
161
|
<span
|
|
@@ -187,6 +267,8 @@ export function ProposalsTab({
|
|
|
187
267
|
const [confirmGenerate, setConfirmGenerate] = useState(false)
|
|
188
268
|
const [mutation, setMutation] = useState<Mutation | null>(null)
|
|
189
269
|
const [pendingItems, setPendingItems] = useState<SeoProposalBulkApplyItem[]>([])
|
|
270
|
+
/** Per-row FAQ review edits, created lazily on first interaction (see `faqDraftKey`). */
|
|
271
|
+
const [faqDrafts, setFaqDrafts] = useState<Record<string, FaqDraft>>({})
|
|
190
272
|
const [generating, setGenerating] = useState<{ done: number; total: number } | null>(null)
|
|
191
273
|
/** Bumped after every mutation so the autopilot ledger below refetches with the inbox. */
|
|
192
274
|
const [refreshKey, setRefreshKey] = useState(0)
|
|
@@ -305,6 +387,70 @@ export function ProposalsTab({
|
|
|
305
387
|
[afterMutation, beginMutation, endMutation],
|
|
306
388
|
)
|
|
307
389
|
|
|
390
|
+
/** The row's current draft, or the untouched default when the reviewer has not edited it. */
|
|
391
|
+
const faqDraftFor = useCallback(
|
|
392
|
+
(p: SeoProposal): FaqDraft => currentFaqDraft(faqDrafts, p),
|
|
393
|
+
[faqDrafts],
|
|
394
|
+
)
|
|
395
|
+
const toggleFaqItem = useCallback((p: SeoProposal, index: number) => {
|
|
396
|
+
setFaqDrafts((cur) => {
|
|
397
|
+
const base = currentFaqDraft(cur, p)
|
|
398
|
+
const selected = base.selected.map((on, i) => (i === index ? !on : on))
|
|
399
|
+
return { ...cur, [faqDraftKey(p)]: { ...base, selected } }
|
|
400
|
+
})
|
|
401
|
+
}, [])
|
|
402
|
+
const setFaqHeading = useCallback((p: SeoProposal, heading: string) => {
|
|
403
|
+
setFaqDrafts((cur) => ({ ...cur, [faqDraftKey(p)]: { ...currentFaqDraft(cur, p), heading } }))
|
|
404
|
+
}, [])
|
|
405
|
+
|
|
406
|
+
// Drop drafts whose proposal is gone or was regenerated, so the map cannot grow across refetches.
|
|
407
|
+
useEffect(() => {
|
|
408
|
+
const liveKeys = new Set(proposals.filter(isFaqProposal).map(faqDraftKey))
|
|
409
|
+
setFaqDrafts((cur) => {
|
|
410
|
+
const stale = Object.keys(cur).filter((key) => !liveKeys.has(key))
|
|
411
|
+
if (stale.length === 0) return cur
|
|
412
|
+
const next = { ...cur }
|
|
413
|
+
for (const key of stale) delete next[key]
|
|
414
|
+
return next
|
|
415
|
+
})
|
|
416
|
+
}, [proposals])
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Single-row approve for a FAQ proposal. Goes through `apply-fix` (not
|
|
420
|
+
* bulk-apply) so the reviewer's per-question selection and heading edit are
|
|
421
|
+
* honoured; the bulk path applies the whole drafted section unchanged.
|
|
422
|
+
*/
|
|
423
|
+
const handleApproveFaq = useCallback(
|
|
424
|
+
async (p: SeoProposal) => {
|
|
425
|
+
if (!p.issueId || !p.fingerprint || !p.section) return
|
|
426
|
+
const draft = faqDraftFor(p)
|
|
427
|
+
if (faqDraftBlocker(draft.selected) !== null) return
|
|
428
|
+
if (!beginMutation('apply')) return
|
|
429
|
+
try {
|
|
430
|
+
const res = await applySeoIssueFix(p.issueId, p.fingerprint, faqApplyOptions(p, draft))
|
|
431
|
+
if (!mountedRef.current) return
|
|
432
|
+
if (res.error) {
|
|
433
|
+
toast.error(res.error)
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
toast.success('FAQ section added.')
|
|
437
|
+
setFaqDrafts((cur) => {
|
|
438
|
+
const key = faqDraftKey(p)
|
|
439
|
+
if (!(key in cur)) return cur
|
|
440
|
+
const rest = { ...cur }
|
|
441
|
+
delete rest[key]
|
|
442
|
+
return rest
|
|
443
|
+
})
|
|
444
|
+
afterMutation()
|
|
445
|
+
} catch (err) {
|
|
446
|
+
if (mountedRef.current) toast.error(errorMessage(err))
|
|
447
|
+
} finally {
|
|
448
|
+
endMutation()
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
[afterMutation, beginMutation, endMutation, faqDraftFor],
|
|
452
|
+
)
|
|
453
|
+
|
|
308
454
|
const handleDismissSelected = useCallback(async () => {
|
|
309
455
|
const targets = selectedProposals
|
|
310
456
|
if (targets.length === 0) return
|
|
@@ -573,6 +719,41 @@ export function ProposalsTab({
|
|
|
573
719
|
<tbody className="divide-border divide-y">
|
|
574
720
|
{proposals.map((p) => {
|
|
575
721
|
const first = p.changes[0]
|
|
722
|
+
const canApply = inlineApplyEnabled || p.kind === 'redirect'
|
|
723
|
+
if (isFaqProposal(p)) {
|
|
724
|
+
const draft = faqDraftFor(p)
|
|
725
|
+
const blocker = p.section
|
|
726
|
+
? faqDraftBlocker(draft.selected)
|
|
727
|
+
: FAQ_UNREADABLE_HINT
|
|
728
|
+
return (
|
|
729
|
+
<ProposalRow
|
|
730
|
+
key={p.id}
|
|
731
|
+
proposal={p}
|
|
732
|
+
selected={selected.has(p.id)}
|
|
733
|
+
expanded={expanded.has(p.id)}
|
|
734
|
+
summary={faqSummary(p)}
|
|
735
|
+
onToggleSelect={() => toggleSelect(p.id)}
|
|
736
|
+
onToggleExpanded={() => toggleExpanded(p.id)}
|
|
737
|
+
onApprove={() => void handleApproveFaq(p)}
|
|
738
|
+
onDismiss={() => void handleDismissOne(p)}
|
|
739
|
+
busy={busy}
|
|
740
|
+
canApply={canApply}
|
|
741
|
+
approveBlocker={blocker}
|
|
742
|
+
details={
|
|
743
|
+
<FaqProposalDetails
|
|
744
|
+
proposal={p}
|
|
745
|
+
draft={draft}
|
|
746
|
+
blocker={blocker}
|
|
747
|
+
busy={busy}
|
|
748
|
+
canApply={canApply}
|
|
749
|
+
onToggle={(i) => toggleFaqItem(p, i)}
|
|
750
|
+
onHeadingChange={(h) => setFaqHeading(p, h)}
|
|
751
|
+
onApprove={() => void handleApproveFaq(p)}
|
|
752
|
+
/>
|
|
753
|
+
}
|
|
754
|
+
/>
|
|
755
|
+
)
|
|
756
|
+
}
|
|
576
757
|
return (
|
|
577
758
|
<ProposalRow
|
|
578
759
|
key={p.id}
|
|
@@ -589,7 +770,7 @@ export function ProposalsTab({
|
|
|
589
770
|
onApprove={() => void runApply([toBulkItem(p)])}
|
|
590
771
|
onDismiss={() => void handleDismissOne(p)}
|
|
591
772
|
busy={busy}
|
|
592
|
-
canApply={
|
|
773
|
+
canApply={canApply}
|
|
593
774
|
/>
|
|
594
775
|
)
|
|
595
776
|
})}
|
|
@@ -637,6 +818,8 @@ function ProposalRow({
|
|
|
637
818
|
onDismiss,
|
|
638
819
|
busy,
|
|
639
820
|
canApply,
|
|
821
|
+
approveBlocker = null,
|
|
822
|
+
details,
|
|
640
823
|
}: {
|
|
641
824
|
proposal: SeoProposal
|
|
642
825
|
selected: boolean
|
|
@@ -648,7 +831,16 @@ function ProposalRow({
|
|
|
648
831
|
onDismiss: () => void
|
|
649
832
|
busy: boolean
|
|
650
833
|
canApply: boolean
|
|
834
|
+
/**
|
|
835
|
+
* Why the row-level Approve is disabled (FAQ rows). Exposed as the button's
|
|
836
|
+
* tooltip and accessible description so the reason is available while the
|
|
837
|
+
* row is collapsed, not only in the expanded body.
|
|
838
|
+
*/
|
|
839
|
+
approveBlocker?: string | null
|
|
840
|
+
/** Replaces the default before/after table in the expanded row. */
|
|
841
|
+
details?: ReactNode
|
|
651
842
|
}) {
|
|
843
|
+
const approveHintId = `${useId()}-approve-hint`
|
|
652
844
|
return (
|
|
653
845
|
<>
|
|
654
846
|
<tr className={`${selected ? 'bg-accent/30' : ''} hover:bg-accent/20 transition-colors`}>
|
|
@@ -666,6 +858,7 @@ function ProposalRow({
|
|
|
666
858
|
<td className="px-3 py-3 align-top">
|
|
667
859
|
<div className="flex flex-wrap items-center gap-2">
|
|
668
860
|
<span className="text-foreground font-medium">{p.title}</span>
|
|
861
|
+
{isFaqProposal(p) ? <SeoStatusBadge label="FAQ section" tone="neutral" /> : null}
|
|
669
862
|
{p.severity ? (
|
|
670
863
|
<SeoStatusBadge
|
|
671
864
|
label={p.severity}
|
|
@@ -724,15 +917,24 @@ function ProposalRow({
|
|
|
724
917
|
<td className="px-3 py-3 align-top">
|
|
725
918
|
<div className="flex items-center justify-end gap-1">
|
|
726
919
|
{!p.stale && canApply ? (
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
920
|
+
<>
|
|
921
|
+
<button
|
|
922
|
+
type="button"
|
|
923
|
+
onClick={onApprove}
|
|
924
|
+
disabled={busy || approveBlocker !== null}
|
|
925
|
+
className="text-success hover:bg-accent focus-visible:ring-ring rounded-md p-1.5 focus-visible:ring-2 focus-visible:outline-none disabled:opacity-60"
|
|
926
|
+
aria-label={`Approve ${p.title}`}
|
|
927
|
+
title={approveBlocker ?? undefined}
|
|
928
|
+
aria-describedby={approveBlocker ? approveHintId : undefined}
|
|
929
|
+
>
|
|
930
|
+
<Check className="h-4 w-4" aria-hidden />
|
|
931
|
+
</button>
|
|
932
|
+
{approveBlocker ? (
|
|
933
|
+
<span id={approveHintId} className="sr-only">
|
|
934
|
+
{approveBlocker}
|
|
935
|
+
</span>
|
|
936
|
+
) : null}
|
|
937
|
+
</>
|
|
736
938
|
) : null}
|
|
737
939
|
<button
|
|
738
940
|
type="button"
|
|
@@ -751,7 +953,7 @@ function ProposalRow({
|
|
|
751
953
|
<td colSpan={6} className="bg-muted/30 px-4 py-4">
|
|
752
954
|
<div className="space-y-3">
|
|
753
955
|
<p className="text-foreground text-sm">{p.justification}</p>
|
|
754
|
-
<ChangePreview changes={p.changes} />
|
|
956
|
+
{details ?? <ChangePreview changes={p.changes} />}
|
|
755
957
|
</div>
|
|
756
958
|
</td>
|
|
757
959
|
</tr>
|
|
@@ -759,3 +961,67 @@ function ProposalRow({
|
|
|
759
961
|
</>
|
|
760
962
|
)
|
|
761
963
|
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Expanded body of a FAQ row: the per-question preview plus a reviewed
|
|
967
|
+
* Approve button. When core could not read the cached section the row shows
|
|
968
|
+
* the recovery hint instead of a preview and offers no approve at all.
|
|
969
|
+
*/
|
|
970
|
+
function FaqProposalDetails({
|
|
971
|
+
proposal: p,
|
|
972
|
+
draft,
|
|
973
|
+
blocker,
|
|
974
|
+
busy,
|
|
975
|
+
canApply,
|
|
976
|
+
onToggle,
|
|
977
|
+
onHeadingChange,
|
|
978
|
+
onApprove,
|
|
979
|
+
}: {
|
|
980
|
+
proposal: SeoProposal
|
|
981
|
+
draft: FaqDraft
|
|
982
|
+
blocker: string | null
|
|
983
|
+
busy: boolean
|
|
984
|
+
canApply: boolean
|
|
985
|
+
onToggle: (index: number) => void
|
|
986
|
+
onHeadingChange: (value: string) => void
|
|
987
|
+
onApprove: () => void
|
|
988
|
+
}) {
|
|
989
|
+
const section = p.section
|
|
990
|
+
if (!section) {
|
|
991
|
+
return (
|
|
992
|
+
<p className="text-muted-foreground flex items-start gap-2 text-sm">
|
|
993
|
+
<AlertTriangle className="text-warning mt-0.5 h-4 w-4 shrink-0" aria-hidden />
|
|
994
|
+
<span>{FAQ_UNREADABLE_HINT}</span>
|
|
995
|
+
</p>
|
|
996
|
+
)
|
|
997
|
+
}
|
|
998
|
+
const items = section.content.items.map((item, i) => ({
|
|
999
|
+
...item,
|
|
1000
|
+
sourceQuote: p.grounding?.[i]?.sourceQuote || undefined,
|
|
1001
|
+
}))
|
|
1002
|
+
return (
|
|
1003
|
+
<div className="space-y-4">
|
|
1004
|
+
<FaqProposalPreview
|
|
1005
|
+
heading={section.content.heading}
|
|
1006
|
+
items={items}
|
|
1007
|
+
signals={p.signals}
|
|
1008
|
+
selected={draft.selected}
|
|
1009
|
+
onToggle={onToggle}
|
|
1010
|
+
headingValue={draft.heading}
|
|
1011
|
+
onHeadingChange={onHeadingChange}
|
|
1012
|
+
disabled={busy}
|
|
1013
|
+
/>
|
|
1014
|
+
{!p.stale && canApply ? (
|
|
1015
|
+
<button
|
|
1016
|
+
type="button"
|
|
1017
|
+
className={btnPrimary}
|
|
1018
|
+
onClick={onApprove}
|
|
1019
|
+
disabled={busy || blocker !== null}
|
|
1020
|
+
>
|
|
1021
|
+
<Check className="h-4 w-4" aria-hidden />
|
|
1022
|
+
Approve
|
|
1023
|
+
</button>
|
|
1024
|
+
) : null}
|
|
1025
|
+
</div>
|
|
1026
|
+
)
|
|
1027
|
+
}
|
|
@@ -120,6 +120,12 @@ export const AUTOPILOT_TYPE_GROUPS: ReadonlyArray<{
|
|
|
120
120
|
help: 'Adds the collection’s default Schema.org type.',
|
|
121
121
|
producer: 'Rule-based',
|
|
122
122
|
},
|
|
123
|
+
{
|
|
124
|
+
id: 'faq-schema-missing',
|
|
125
|
+
label: 'FAQ schema for visible FAQ sections',
|
|
126
|
+
help: 'Adds a FAQPage node to a page’s stored structured data when a visible FAQ section has none — typically pages published before the section was added. Re-publishing the page clears it too.',
|
|
127
|
+
producer: 'Rule-based',
|
|
128
|
+
},
|
|
123
129
|
],
|
|
124
130
|
},
|
|
125
131
|
{
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
interface ContentOverviewChartProps {
|
|
2
|
-
published: number;
|
|
3
|
-
drafts: number;
|
|
4
|
-
scheduled: number;
|
|
5
|
-
}
|
|
6
|
-
export declare function ContentOverviewChart({ published, drafts, scheduled }: ContentOverviewChartProps): import("react/jsx-runtime").JSX.Element;
|
|
7
|
-
export {};
|
|
8
|
-
//# sourceMappingURL=ContentOverviewChart.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"ContentOverviewChart.d.ts","sourceRoot":"","sources":["../../src/components/ContentOverviewChart.tsx"],"names":[],"mappings":"AAIA,UAAU,yBAAyB;IACjC,SAAS,EAAE,MAAM,CAAA;IACjB,MAAM,EAAE,MAAM,CAAA;IACd,SAAS,EAAE,MAAM,CAAA;CAClB;AAID,wBAAgB,oBAAoB,CAAC,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,EAAE,yBAAyB,2CA8D/F"}
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts';
|
|
4
|
-
const COLORS = ['#22c55e', '#f59e0b', '#a855f7'];
|
|
5
|
-
export function ContentOverviewChart({ published, drafts, scheduled }) {
|
|
6
|
-
const total = published + drafts + scheduled;
|
|
7
|
-
const data = [
|
|
8
|
-
{ name: 'Published', value: published },
|
|
9
|
-
{ name: 'Drafts', value: drafts },
|
|
10
|
-
{ name: 'Scheduled', value: scheduled },
|
|
11
|
-
];
|
|
12
|
-
if (total === 0) {
|
|
13
|
-
return (_jsx("div", { className: "text-muted-foreground flex h-full items-center justify-center text-sm", children: "No content yet" }));
|
|
14
|
-
}
|
|
15
|
-
return (_jsxs("div", { className: "flex items-center gap-6", children: [_jsx("div", { className: "h-36 w-36 shrink-0", children: _jsx(ResponsiveContainer, { width: "100%", height: "100%", children: _jsx(PieChart, { children: _jsx(Pie, { data: data, cx: "50%", cy: "50%", innerRadius: 38, outerRadius: 65, dataKey: "value", strokeWidth: 2, stroke: "#fff", children: data.map((_, idx) => (_jsx(Cell, { fill: COLORS[idx] }, idx))) }) }) }) }), _jsx("div", { className: "space-y-3", children: data.map((entry, idx) => {
|
|
16
|
-
const pct = total > 0 ? Math.round((entry.value / total) * 100) : 0;
|
|
17
|
-
return (_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("span", { className: "h-2.5 w-2.5 shrink-0 rounded-full", style: { backgroundColor: COLORS[idx] } }), _jsxs("div", { className: "min-w-0", children: [_jsx("span", { className: "text-foreground text-sm", children: entry.name }), _jsxs("div", { className: "flex items-baseline gap-2", children: [_jsx("span", { className: "text-foreground text-lg font-medium", children: entry.value.toLocaleString() }), _jsxs("span", { className: "text-muted-foreground text-xs", children: [pct, "%"] })] })] })] }, entry.name));
|
|
18
|
-
}) })] }));
|
|
19
|
-
}
|
|
20
|
-
//# sourceMappingURL=ContentOverviewChart.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"ContentOverviewChart.js","sourceRoot":"","sources":["../../src/components/ContentOverviewChart.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAA;;AAEZ,OAAO,EAAE,QAAQ,EAAE,GAAG,EAAE,IAAI,EAAE,mBAAmB,EAAE,MAAM,UAAU,CAAA;AAQnE,MAAM,MAAM,GAAG,CAAC,SAAS,EAAE,SAAS,EAAE,SAAS,CAAC,CAAA;AAEhD,MAAM,UAAU,oBAAoB,CAAC,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAA6B;IAC9F,MAAM,KAAK,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAAA;IAC5C,MAAM,IAAI,GAAG;QACX,EAAE,IAAI,EAAE,WAAW,EAAE,KAAK,EAAE,SAAS,EAAE;QACvC,EAAE,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE;QACjC,EAAE,IAAI,EAAE,WAAW,EAAE,KAAK,EAAE,SAAS,EAAE;KACxC,CAAA;IAED,IAAI,KAAK,KAAK,CAAC,EAAE,CAAC;QAChB,OAAO,CACL,cAAK,SAAS,EAAC,uEAAuE,+BAEhF,CACP,CAAA;IACH,CAAC;IAED,OAAO,CACL,eAAK,SAAS,EAAC,yBAAyB,aACtC,cAAK,SAAS,EAAC,oBAAoB,YACjC,KAAC,mBAAmB,IAAC,KAAK,EAAC,MAAM,EAAC,MAAM,EAAC,MAAM,YAC7C,KAAC,QAAQ,cACP,KAAC,GAAG,IACF,IAAI,EAAE,IAAI,EACV,EAAE,EAAC,KAAK,EACR,EAAE,EAAC,KAAK,EACR,WAAW,EAAE,EAAE,EACf,WAAW,EAAE,EAAE,EACf,OAAO,EAAC,OAAO,EACf,WAAW,EAAE,CAAC,EACd,MAAM,EAAC,MAAM,YAEZ,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,EAAE,CAAC,CACpB,KAAC,IAAI,IAAW,IAAI,EAAE,MAAM,CAAC,GAAG,CAAC,IAAtB,GAAG,CAAuB,CACtC,CAAC,GACE,GACG,GACS,GAClB,EACN,cAAK,SAAS,EAAC,WAAW,YACvB,IAAI,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,GAAG,EAAE,EAAE;oBACvB,MAAM,GAAG,GAAG,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,KAAK,GAAG,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;oBACnE,OAAO,CACL,eAAsB,SAAS,EAAC,yBAAyB,aACvD,eACE,SAAS,EAAC,mCAAmC,EAC7C,KAAK,EAAE,EAAE,eAAe,EAAE,MAAM,CAAC,GAAG,CAAC,EAAE,GACvC,EACF,eAAK,SAAS,EAAC,SAAS,aACtB,eAAM,SAAS,EAAC,yBAAyB,YAAE,KAAK,CAAC,IAAI,GAAQ,EAC7D,eAAK,SAAS,EAAC,2BAA2B,aACxC,eAAM,SAAS,EAAC,qCAAqC,YAClD,KAAK,CAAC,KAAK,CAAC,cAAc,EAAE,GACxB,EACP,gBAAM,SAAS,EAAC,+BAA+B,aAAE,GAAG,SAAS,IACzD,IACF,KAbE,KAAK,CAAC,IAAI,CAcd,CACP,CAAA;gBACH,CAAC,CAAC,GACE,IACF,CACP,CAAA;AACH,CAAC"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"SEOPerformance.d.ts","sourceRoot":"","sources":["../../src/components/SEOPerformance.tsx"],"names":[],"mappings":"AAyCA,MAAM,WAAW,mBAAmB;IAClC,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;CACpC;AAED,wBAAgB,cAAc,CAAC,EAAE,UAAU,EAAE,EAAE,mBAAmB,kDAqHjE"}
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { Search, FileWarning, ImageOff, LinkIcon, ChevronLeft, ChevronRight, } from 'lucide-react';
|
|
4
|
-
import { useState } from 'react';
|
|
5
|
-
import { gradeFromScore, gradeMeta } from './seo/primitives.js';
|
|
6
|
-
import { useApiData } from '../lib/useApiData.js';
|
|
7
|
-
/**
|
|
8
|
-
* The bands and their colours both come from the SEO primitives — this used to
|
|
9
|
-
* restate 80/60 twice on adjacent lines, and `gradeMeta` already returns
|
|
10
|
-
* exactly the token classes it was hand-writing.
|
|
11
|
-
*/
|
|
12
|
-
function ScoreBadge({ score }) {
|
|
13
|
-
const { text: color, bg } = gradeMeta(gradeFromScore(score));
|
|
14
|
-
return (_jsx("span", { className: `inline-flex h-9 w-9 items-center justify-center rounded-full text-sm font-medium ${color} ${bg}`, children: score }));
|
|
15
|
-
}
|
|
16
|
-
export function SEOPerformance({ onNavigate }) {
|
|
17
|
-
const { data, loading, error } = useApiData('/seo/summary');
|
|
18
|
-
const [page, setPage] = useState(0);
|
|
19
|
-
const perPage = 5;
|
|
20
|
-
if (loading || error || !data)
|
|
21
|
-
return null;
|
|
22
|
-
const issues = data.issuesSummary;
|
|
23
|
-
const totalIssues = issues.missingMetaDescriptions + issues.brokenInternalLinks + issues.missingAltText;
|
|
24
|
-
const topContent = data.topContent ?? [];
|
|
25
|
-
const totalPages = Math.max(1, Math.ceil(topContent.length / perPage));
|
|
26
|
-
const visible = topContent.slice(page * perPage, (page + 1) * perPage);
|
|
27
|
-
return (_jsxs("div", { className: "border-border bg-card rounded-lg border", children: [_jsxs("div", { className: "border-border flex items-center justify-between border-b p-4", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Search, { className: "text-muted-foreground h-4 w-4" }), _jsx("h2", { className: "text-foreground text-sm font-medium", children: "SEO Performance" })] }), totalIssues > 0 && (_jsxs("span", { className: "text-muted-foreground text-xs", children: [totalIssues, " issue", totalIssues !== 1 ? 's' : '', " found"] }))] }), _jsxs("div", { className: "divide-border grid grid-cols-1 divide-y lg:grid-cols-12 lg:divide-x lg:divide-y-0", children: [_jsxs("div", { className: "p-4 lg:col-span-7", children: [_jsx("h3", { className: "text-foreground mb-3 text-sm font-medium", children: "Top Performing Content" }), _jsxs("div", { className: "space-y-2", children: [visible.map((item) => (_jsxs("div", { className: "flex items-center justify-between py-2", children: [_jsxs("div", { className: "mr-3 min-w-0 flex-1", children: [_jsx("p", { className: "text-foreground truncate text-sm font-medium", children: item.title }), _jsx("p", { className: "text-muted-foreground text-xs capitalize", children: item.collection })] }), _jsx(ScoreBadge, { score: item.score })] }, item.id))), visible.length === 0 && (_jsx("p", { className: "text-muted-foreground py-4 text-center text-sm", children: "No published content yet" }))] }), topContent.length > perPage && (_jsxs("div", { className: "text-muted-foreground mt-3 flex items-center justify-between border-t border-gray-100 pt-3 text-xs", children: [_jsxs("span", { children: ["Showing ", page * perPage + 1, "-", Math.min((page + 1) * perPage, topContent.length), " of", ' ', topContent.length] }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx("button", { onClick: () => setPage(Math.max(0, page - 1)), disabled: page === 0, className: "hover:bg-accent rounded p-1 disabled:opacity-30", children: _jsx(ChevronLeft, { className: "h-3.5 w-3.5" }) }), _jsxs("span", { children: ["Page ", page + 1, " of ", totalPages] }), _jsx("button", { onClick: () => setPage(Math.min(totalPages - 1, page + 1)), disabled: page >= totalPages - 1, className: "hover:bg-accent rounded p-1 disabled:opacity-30", children: _jsx(ChevronRight, { className: "h-3.5 w-3.5" }) })] })] }))] }), _jsxs("div", { className: "p-4 lg:col-span-5", children: [_jsx("h3", { className: "text-foreground mb-3 text-sm font-medium", children: "SEO Issues" }), _jsxs("div", { className: "space-y-3", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(FileWarning, { className: "h-4 w-4 text-red-400" }), _jsx("span", { className: "text-foreground text-sm", children: "Missing meta descriptions" })] }), _jsx("span", { className: "text-foreground text-sm font-medium", children: issues.missingMetaDescriptions })] }), _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(LinkIcon, { className: "h-4 w-4 text-red-400" }), _jsx("span", { className: "text-foreground text-sm", children: "Broken internal links" })] }), _jsx("span", { className: "text-foreground text-sm font-medium", children: issues.brokenInternalLinks })] }), _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(ImageOff, { className: "h-4 w-4 text-red-400" }), _jsx("span", { className: "text-foreground text-sm", children: "Missing alt text" })] }), _jsx("span", { className: "text-foreground text-sm font-medium", children: issues.missingAltText })] })] }), totalIssues > 0 && (_jsx("button", { onClick: () => onNavigate?.('/seo'), className: "text-primary hover:text-primary/80 mt-4 text-sm font-medium", children: "View All Issues" }))] })] })] }));
|
|
28
|
-
}
|
|
29
|
-
//# sourceMappingURL=SEOPerformance.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"SEOPerformance.js","sourceRoot":"","sources":["../../src/components/SEOPerformance.tsx"],"names":[],"mappings":"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"}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
type AvatarSize = 'sm' | 'md' | 'lg';
|
|
2
|
-
export interface AvatarProps {
|
|
3
|
-
src?: string | null;
|
|
4
|
-
name?: string;
|
|
5
|
-
size?: AvatarSize;
|
|
6
|
-
}
|
|
7
|
-
export declare function Avatar({ src, name, size }: AvatarProps): import("react/jsx-runtime").JSX.Element;
|
|
8
|
-
export {};
|
|
9
|
-
//# sourceMappingURL=Avatar.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"Avatar.d.ts","sourceRoot":"","sources":["../../../src/components/ui/Avatar.tsx"],"names":[],"mappings":"AAAA,KAAK,UAAU,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAA;AAEpC,MAAM,WAAW,WAAW;IAC1B,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,UAAU,CAAA;CAClB;AAiBD,wBAAgB,MAAM,CAAC,EAAE,GAAG,EAAE,IAAS,EAAE,IAAW,EAAE,EAAE,WAAW,2CAkBlE"}
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
const sizeClasses = {
|
|
3
|
-
sm: 'h-6 w-6 text-[10px]',
|
|
4
|
-
md: 'h-9 w-9 text-xs',
|
|
5
|
-
lg: 'h-12 w-12 text-sm',
|
|
6
|
-
};
|
|
7
|
-
function getInitials(name) {
|
|
8
|
-
return name
|
|
9
|
-
.split(' ')
|
|
10
|
-
.map((part) => part.charAt(0))
|
|
11
|
-
.join('')
|
|
12
|
-
.toUpperCase()
|
|
13
|
-
.slice(0, 2);
|
|
14
|
-
}
|
|
15
|
-
export function Avatar({ src, name = '', size = 'md' }) {
|
|
16
|
-
if (src) {
|
|
17
|
-
return (_jsx("img", { src: src, alt: name, className: `inline-block shrink-0 rounded-full object-cover ${sizeClasses[size]}` }));
|
|
18
|
-
}
|
|
19
|
-
return (_jsx("span", { className: `inline-flex shrink-0 items-center justify-center rounded-full bg-[var(--primary)] font-medium text-[var(--primary-foreground)] ${sizeClasses[size]}`, children: getInitials(name) || '?' }));
|
|
20
|
-
}
|
|
21
|
-
//# sourceMappingURL=Avatar.js.map
|