@prenta/admin 1.6.2 → 1.8.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 +12 -0
- package/dist/__tests__/components/seo-editor-pane.render.test.js +2 -1
- package/dist/__tests__/components/seo-editor-pane.render.test.js.map +1 -1
- package/dist/__tests__/components/seo-issue-fix-panel.render.test.d.ts +2 -0
- package/dist/__tests__/components/seo-issue-fix-panel.render.test.d.ts.map +1 -0
- package/dist/__tests__/components/seo-issue-fix-panel.render.test.js +60 -0
- package/dist/__tests__/components/seo-issue-fix-panel.render.test.js.map +1 -0
- package/dist/__tests__/components/seo-linking-section.render.test.d.ts +2 -0
- package/dist/__tests__/components/seo-linking-section.render.test.d.ts.map +1 -0
- package/dist/__tests__/components/seo-linking-section.render.test.js +94 -0
- package/dist/__tests__/components/seo-linking-section.render.test.js.map +1 -0
- package/dist/__tests__/lib/collection-routes.test.js +8 -1
- package/dist/__tests__/lib/collection-routes.test.js.map +1 -1
- package/dist/__tests__/lib/seo-service-errors.test.js +35 -3
- package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
- package/dist/__tests__/views/seo-content-pane.render.test.js +8 -1
- package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -1
- package/dist/components/seo/SeoEditorPane.d.ts +2 -1
- package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
- package/dist/components/seo/SeoEditorPane.js +2 -2
- package/dist/components/seo/SeoEditorPane.js.map +1 -1
- package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
- package/dist/components/seo/SeoIssueFixPanel.js +32 -8
- package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
- package/dist/components/seo/SeoLinkingSection.d.ts +5 -1
- package/dist/components/seo/SeoLinkingSection.d.ts.map +1 -1
- package/dist/components/seo/SeoLinkingSection.js +50 -3
- package/dist/components/seo/SeoLinkingSection.js.map +1 -1
- package/dist/lib/collection-routes.d.ts +2 -0
- package/dist/lib/collection-routes.d.ts.map +1 -1
- package/dist/lib/collection-routes.js +5 -0
- package/dist/lib/collection-routes.js.map +1 -1
- package/dist/lib/seo-service.d.ts +25 -2
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +23 -3
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/views/SEO.d.ts.map +1 -1
- package/dist/views/SEO.js +1 -1
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +1 -1
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +1 -1
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/dist/views/seo/ContentTab.d.ts +2 -1
- package/dist/views/seo/ContentTab.d.ts.map +1 -1
- package/dist/views/seo/ContentTab.js +18 -12
- package/dist/views/seo/ContentTab.js.map +1 -1
- package/dist/views/seo/LinksTab.d.ts +3 -1
- package/dist/views/seo/LinksTab.d.ts.map +1 -1
- package/dist/views/seo/LinksTab.js +36 -6
- package/dist/views/seo/LinksTab.js.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/components/seo-editor-pane.render.test.tsx +4 -1
- package/src/__tests__/components/seo-issue-fix-panel.render.test.tsx +65 -0
- package/src/__tests__/components/seo-linking-section.render.test.tsx +146 -0
- package/src/__tests__/lib/collection-routes.test.ts +13 -1
- package/src/__tests__/lib/seo-service-errors.test.ts +40 -2
- package/src/__tests__/views/seo-content-pane.render.test.tsx +8 -1
- package/src/components/seo/SeoEditorPane.tsx +3 -0
- package/src/components/seo/SeoIssueFixPanel.tsx +34 -10
- package/src/components/seo/SeoLinkingSection.tsx +116 -18
- package/src/lib/collection-routes.ts +13 -0
- package/src/lib/seo-service.ts +44 -4
- package/src/views/SEO.tsx +2 -1
- package/src/views/page-editor/PageSectionEditor.tsx +1 -0
- package/src/views/post-editor/PostSectionEditor.tsx +1 -0
- package/src/views/seo/ContentTab.tsx +40 -2
- package/src/views/seo/LinksTab.tsx +108 -19
|
@@ -2,7 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from 'react'
|
|
4
4
|
import { Link2, Loader2, Sparkles } from 'lucide-react'
|
|
5
|
-
import {
|
|
5
|
+
import { toast } from 'sonner'
|
|
6
|
+
import { sourceEditPath } from '../../lib/collection-routes.js'
|
|
7
|
+
import {
|
|
8
|
+
applySeoInternalLink,
|
|
9
|
+
fetchLinkSuggestions,
|
|
10
|
+
type LinkSuggestion,
|
|
11
|
+
} from '../../lib/seo-service.js'
|
|
12
|
+
import { fetchPlanInfo, hasPlanFeature, type PlanInfo } from '../../lib/plan.js'
|
|
13
|
+
import { PlanUpgradeCallout } from '../PlanUpgradeCallout.js'
|
|
14
|
+
|
|
15
|
+
const approveBtnCls =
|
|
16
|
+
'inline-flex items-center justify-center gap-1.5 rounded-[7px] bg-primary px-2.5 py-1.5 text-[11.5px] font-medium text-primary-foreground transition-colors hover:opacity-90 focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50'
|
|
17
|
+
|
|
18
|
+
const editorBtnCls =
|
|
19
|
+
'inline-flex items-center justify-center gap-1.5 rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] px-2.5 py-1.5 text-[11.5px] font-medium text-[var(--txt)] transition-colors hover:bg-[var(--acc-l)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none'
|
|
6
20
|
|
|
7
21
|
export function SeoLinkingSection({
|
|
8
22
|
entityType,
|
|
@@ -11,6 +25,8 @@ export function SeoLinkingSection({
|
|
|
11
25
|
externalLinks,
|
|
12
26
|
internalLinkOk,
|
|
13
27
|
externalLinkOk,
|
|
28
|
+
onNavigate,
|
|
29
|
+
planInfo: planInfoProp,
|
|
14
30
|
}: {
|
|
15
31
|
entityType: 'page' | 'post'
|
|
16
32
|
entityId: string | null
|
|
@@ -18,10 +34,31 @@ export function SeoLinkingSection({
|
|
|
18
34
|
externalLinks: number
|
|
19
35
|
internalLinkOk: boolean
|
|
20
36
|
externalLinkOk: boolean
|
|
37
|
+
onNavigate?: (path: string) => void
|
|
38
|
+
/** When omitted (page/post editors), fetched on mount. */
|
|
39
|
+
planInfo?: PlanInfo | null
|
|
21
40
|
}) {
|
|
22
41
|
const [loading, setLoading] = useState(false)
|
|
23
42
|
const [suggestions, setSuggestions] = useState<LinkSuggestion[]>([])
|
|
24
43
|
const [error, setError] = useState<string | null>(null)
|
|
44
|
+
const [applyingKey, setApplyingKey] = useState<string | null>(null)
|
|
45
|
+
const [planInfo, setPlanInfo] = useState<PlanInfo | null | undefined>(planInfoProp)
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (planInfoProp !== undefined) {
|
|
49
|
+
setPlanInfo(planInfoProp)
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
let alive = true
|
|
53
|
+
void fetchPlanInfo().then((info) => {
|
|
54
|
+
if (alive) setPlanInfo(info)
|
|
55
|
+
})
|
|
56
|
+
return () => {
|
|
57
|
+
alive = false
|
|
58
|
+
}
|
|
59
|
+
}, [planInfoProp])
|
|
60
|
+
|
|
61
|
+
const inlineApplyEnabled = hasPlanFeature(planInfo, 'seo.inlineApply')
|
|
25
62
|
|
|
26
63
|
useEffect(() => {
|
|
27
64
|
if (!entityId) {
|
|
@@ -48,6 +85,33 @@ export function SeoLinkingSection({
|
|
|
48
85
|
}
|
|
49
86
|
}, [entityType, entityId])
|
|
50
87
|
|
|
88
|
+
async function handleApprove(suggestion: LinkSuggestion) {
|
|
89
|
+
if (!entityId) return
|
|
90
|
+
const key = `${suggestion.sourceEntityId}-${suggestion.fingerprint}`
|
|
91
|
+
setApplyingKey(key)
|
|
92
|
+
const res = await applySeoInternalLink({
|
|
93
|
+
sourceEntityId: suggestion.sourceEntityId,
|
|
94
|
+
targetEntityId: entityId,
|
|
95
|
+
anchorText: suggestion.anchorText,
|
|
96
|
+
fingerprint: suggestion.fingerprint,
|
|
97
|
+
})
|
|
98
|
+
setApplyingKey(null)
|
|
99
|
+
if (res.error) {
|
|
100
|
+
toast.error(res.error)
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
toast.success('Link added.')
|
|
104
|
+
setSuggestions((prev) =>
|
|
105
|
+
prev.filter(
|
|
106
|
+
(row) =>
|
|
107
|
+
!(
|
|
108
|
+
row.fingerprint === suggestion.fingerprint &&
|
|
109
|
+
row.sourceEntityId === suggestion.sourceEntityId
|
|
110
|
+
),
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
51
115
|
return (
|
|
52
116
|
<div className="space-y-4">
|
|
53
117
|
<div className="grid grid-cols-2 gap-3">
|
|
@@ -90,22 +154,56 @@ export function SeoLinkingSection({
|
|
|
90
154
|
<Sparkles className="h-3.5 w-3.5 text-[var(--acc)]" aria-hidden />
|
|
91
155
|
Link opportunities
|
|
92
156
|
</div>
|
|
157
|
+
{!inlineApplyEnabled && suggestions.some((s) => s.wrappable) ? (
|
|
158
|
+
<div className="mb-2">
|
|
159
|
+
<PlanUpgradeCallout feature="seo.inlineApply" upgradeUrl={planInfo?.upgradeUrl} />
|
|
160
|
+
</div>
|
|
161
|
+
) : null}
|
|
93
162
|
<ul className="space-y-2">
|
|
94
|
-
{suggestions.map((s) =>
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
163
|
+
{suggestions.map((s) => {
|
|
164
|
+
const key = `${s.sourceEntityId}-${s.fingerprint || s.anchorText}`
|
|
165
|
+
const busy = applyingKey === key
|
|
166
|
+
return (
|
|
167
|
+
<li
|
|
168
|
+
key={key}
|
|
169
|
+
className="rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] p-2.5"
|
|
170
|
+
>
|
|
171
|
+
<div className="text-[12.5px] font-medium text-[var(--txt)]">{s.sourceTitle}</div>
|
|
172
|
+
<p className="mt-0.5 text-[11px] text-[var(--muted)]">{s.sourceUrl}</p>
|
|
173
|
+
<p className="mt-1 text-[11.5px] text-[var(--sub)]">
|
|
174
|
+
Suggested anchor: <span className="text-[var(--txt)]">{s.anchorText}</span>
|
|
175
|
+
</p>
|
|
176
|
+
{s.excerpt && (
|
|
177
|
+
<p className="mt-1 line-clamp-2 text-[11px] text-[var(--muted)]">{s.excerpt}</p>
|
|
178
|
+
)}
|
|
179
|
+
{s.wrappable && inlineApplyEnabled ? (
|
|
180
|
+
<div className="mt-2">
|
|
181
|
+
<button
|
|
182
|
+
type="button"
|
|
183
|
+
className={approveBtnCls}
|
|
184
|
+
disabled={busy}
|
|
185
|
+
onClick={() => void handleApprove(s)}
|
|
186
|
+
>
|
|
187
|
+
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden /> : null}
|
|
188
|
+
Approve link
|
|
189
|
+
</button>
|
|
190
|
+
</div>
|
|
191
|
+
) : onNavigate ? (
|
|
192
|
+
<div className="mt-2">
|
|
193
|
+
<button
|
|
194
|
+
type="button"
|
|
195
|
+
className={editorBtnCls}
|
|
196
|
+
onClick={() =>
|
|
197
|
+
onNavigate(sourceEditPath(s.sourceCollection, s.sourceEntityId))
|
|
198
|
+
}
|
|
199
|
+
>
|
|
200
|
+
Open in editor
|
|
201
|
+
</button>
|
|
202
|
+
</div>
|
|
203
|
+
) : null}
|
|
204
|
+
</li>
|
|
205
|
+
)
|
|
206
|
+
})}
|
|
109
207
|
</ul>
|
|
110
208
|
</div>
|
|
111
209
|
) : (
|
|
@@ -118,8 +216,8 @@ export function SeoLinkingSection({
|
|
|
118
216
|
<div className="flex items-start gap-2 rounded-[7px] border border-[var(--bdr)] bg-[var(--bg)] p-2.5 text-[11px] text-[var(--sub)]">
|
|
119
217
|
<Link2 className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[var(--acc)]" aria-hidden />
|
|
120
218
|
<span>
|
|
121
|
-
|
|
122
|
-
|
|
219
|
+
Wrappable suggestions can be approved here. Others need an editor pass so we do not invent
|
|
220
|
+
a sentence.
|
|
123
221
|
</span>
|
|
124
222
|
</div>
|
|
125
223
|
</div>
|
|
@@ -24,6 +24,19 @@ export function editPathForDoc(
|
|
|
24
24
|
return `/${collection}/${id}`
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/** Editor route for an inbound-link suggestion source document. */
|
|
28
|
+
export function sourceEditPath(
|
|
29
|
+
sourceCollection: string | undefined,
|
|
30
|
+
sourceEntityId: string,
|
|
31
|
+
): string {
|
|
32
|
+
const collection = sourceCollection ?? 'pages'
|
|
33
|
+
return editPathForDoc(
|
|
34
|
+
{ type: collection === 'pages' ? 'page' : 'post' },
|
|
35
|
+
collection,
|
|
36
|
+
sourceEntityId,
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
27
40
|
/** The route that starts a new document in the correct editor. */
|
|
28
41
|
export function newPathForCollection(
|
|
29
42
|
col: CollectionRouteMeta | undefined,
|
package/src/lib/seo-service.ts
CHANGED
|
@@ -111,6 +111,8 @@ export interface SeoOverview {
|
|
|
111
111
|
searchPerformance: SearchPerformanceRow[]
|
|
112
112
|
searchConsoleConnected: boolean
|
|
113
113
|
lastAuditRunId: string | null
|
|
114
|
+
/** True when the last audit hit AUDIT_MAX_ENTITIES. */
|
|
115
|
+
auditTruncated?: boolean
|
|
114
116
|
}
|
|
115
117
|
|
|
116
118
|
const EMPTY_OVERVIEW: SeoOverview = {
|
|
@@ -122,6 +124,7 @@ const EMPTY_OVERVIEW: SeoOverview = {
|
|
|
122
124
|
searchPerformance: [],
|
|
123
125
|
searchConsoleConnected: false,
|
|
124
126
|
lastAuditRunId: null,
|
|
127
|
+
auditTruncated: false,
|
|
125
128
|
}
|
|
126
129
|
|
|
127
130
|
export async function fetchSeoOverview(): Promise<SeoOverview> {
|
|
@@ -208,10 +211,29 @@ export type LlmsInclude = 'AUTO' | 'ALWAYS' | 'NEVER'
|
|
|
208
211
|
|
|
209
212
|
export type ContentSeoFilter = 'all' | 'pages' | 'posts'
|
|
210
213
|
|
|
211
|
-
export
|
|
212
|
-
|
|
214
|
+
export interface SeoContentList {
|
|
215
|
+
records: SeoContentRecord[]
|
|
216
|
+
total: number
|
|
217
|
+
page: number
|
|
218
|
+
pageSize: number
|
|
219
|
+
truncated: boolean
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function fetchSeoContentRecords(
|
|
223
|
+
opts: { page?: number; pageSize?: number } = {},
|
|
224
|
+
): Promise<SeoContentList> {
|
|
225
|
+
const page = opts.page ?? 1
|
|
226
|
+
const pageSize = opts.pageSize ?? 50
|
|
227
|
+
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
|
228
|
+
const res = await cmsApi<SeoContentList>(`/seo/content?${qs}`)
|
|
213
229
|
throwIfError(res)
|
|
214
|
-
return
|
|
230
|
+
return {
|
|
231
|
+
records: res.data?.records ?? [],
|
|
232
|
+
total: res.data?.total ?? 0,
|
|
233
|
+
page: res.data?.page ?? page,
|
|
234
|
+
pageSize: res.data?.pageSize ?? pageSize,
|
|
235
|
+
truncated: res.data?.truncated === true,
|
|
236
|
+
}
|
|
215
237
|
}
|
|
216
238
|
|
|
217
239
|
export async function fetchSeoContentRecord(
|
|
@@ -417,7 +439,7 @@ export interface SeoFixFieldChange {
|
|
|
417
439
|
export interface SeoIssueFixSuggestion {
|
|
418
440
|
autoFixable: boolean
|
|
419
441
|
source?: 'deterministic' | 'ai'
|
|
420
|
-
fixStrategy?: 'redirect' | 'fix-link'
|
|
442
|
+
fixStrategy?: 'redirect' | 'fix-link' | 'insert-link'
|
|
421
443
|
aiUnavailable?: boolean
|
|
422
444
|
fingerprint: string
|
|
423
445
|
patch: Record<string, unknown>
|
|
@@ -498,6 +520,7 @@ export const SEO_INLINE_FIX_ISSUE_TYPES = new Set([
|
|
|
498
520
|
'duplicate-meta-description',
|
|
499
521
|
'missing-structured-data',
|
|
500
522
|
'broken-internal-link',
|
|
523
|
+
'orphan-page',
|
|
501
524
|
])
|
|
502
525
|
|
|
503
526
|
export function isInlineSeoFixIssue(type: string): boolean {
|
|
@@ -1125,6 +1148,23 @@ export interface LinkSuggestion {
|
|
|
1125
1148
|
anchorText: string
|
|
1126
1149
|
confidence: number
|
|
1127
1150
|
excerpt: string
|
|
1151
|
+
fingerprint: string
|
|
1152
|
+
wrappable: boolean
|
|
1153
|
+
sourceCollection?: string
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
export async function applySeoInternalLink(input: {
|
|
1157
|
+
sourceEntityId: string
|
|
1158
|
+
targetEntityId: string
|
|
1159
|
+
anchorText: string
|
|
1160
|
+
fingerprint: string
|
|
1161
|
+
}): Promise<{ error?: string }> {
|
|
1162
|
+
const res = await cmsApi('/seo/links/apply', {
|
|
1163
|
+
method: 'POST',
|
|
1164
|
+
body: JSON.stringify(input),
|
|
1165
|
+
})
|
|
1166
|
+
if (res.error) return { error: res.error }
|
|
1167
|
+
return {}
|
|
1128
1168
|
}
|
|
1129
1169
|
|
|
1130
1170
|
export async function fetchLinkGraphSnapshot(): Promise<LinkGraphSnapshot> {
|
package/src/views/SEO.tsx
CHANGED
|
@@ -249,11 +249,12 @@ export function SEO({ onNavigate, initialTab = 'overview' }: SEOProps) {
|
|
|
249
249
|
<ContentTab
|
|
250
250
|
initialEntityType={deepLink.entityType}
|
|
251
251
|
initialEntityId={deepLink.entityId}
|
|
252
|
+
onNavigate={onNavigate}
|
|
252
253
|
/>
|
|
253
254
|
)}
|
|
254
255
|
</Tabs.Content>
|
|
255
256
|
<Tabs.Content value="links" tabIndex={-1}>
|
|
256
|
-
{activeTab === 'links' && <LinksTab onNavigate={onNavigate} />}
|
|
257
|
+
{activeTab === 'links' && <LinksTab onNavigate={onNavigate} planInfo={planInfo} />}
|
|
257
258
|
</Tabs.Content>
|
|
258
259
|
<Tabs.Content value="technical" tabIndex={-1}>
|
|
259
260
|
{activeTab === 'technical' && <TechnicalTab />}
|
|
@@ -44,11 +44,18 @@ function parseEntityType(value: string | null | undefined): SeoEntityType | null
|
|
|
44
44
|
export function ContentTab({
|
|
45
45
|
initialEntityType,
|
|
46
46
|
initialEntityId,
|
|
47
|
+
onNavigate,
|
|
47
48
|
}: {
|
|
48
49
|
initialEntityType?: string | null
|
|
49
50
|
initialEntityId?: string | null
|
|
51
|
+
onNavigate?: (path: string) => void
|
|
50
52
|
}) {
|
|
51
|
-
const
|
|
53
|
+
const [page, setPage] = useState(1)
|
|
54
|
+
const pageSize = 50
|
|
55
|
+
const { data, loading, error, refetch } = useSeoResource(
|
|
56
|
+
() => fetchSeoContentRecords({ page, pageSize }),
|
|
57
|
+
[page],
|
|
58
|
+
)
|
|
52
59
|
const [search, setSearch] = useState('')
|
|
53
60
|
const [filter, setFilter] = useState<ContentSeoFilter>('all')
|
|
54
61
|
const [selection, setSelection] = useState<Selection>(() => {
|
|
@@ -57,9 +64,13 @@ export function ContentTab({
|
|
|
57
64
|
})
|
|
58
65
|
|
|
59
66
|
const rows = useMemo(
|
|
60
|
-
() => applyContentSeoQuery(data ?? [], { search, filter }),
|
|
67
|
+
() => applyContentSeoQuery(data?.records ?? [], { search, filter }),
|
|
61
68
|
[data, search, filter],
|
|
62
69
|
)
|
|
70
|
+
const total = data?.total ?? 0
|
|
71
|
+
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
|
72
|
+
const rangeStart = total === 0 ? 0 : (page - 1) * pageSize + 1
|
|
73
|
+
const rangeEnd = Math.min(page * pageSize, total)
|
|
63
74
|
|
|
64
75
|
const toggleRow = (type: SeoEntityType, id: string) => {
|
|
65
76
|
setSelection((prev) => (prev?.type === type && prev.id === id ? null : { type, id }))
|
|
@@ -183,6 +194,32 @@ export function ContentTab({
|
|
|
183
194
|
</table>
|
|
184
195
|
</div>
|
|
185
196
|
)}
|
|
197
|
+
{total > pageSize && (
|
|
198
|
+
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-[var(--bdr)] px-3 py-2">
|
|
199
|
+
<p className="text-sm text-[var(--sub)]">
|
|
200
|
+
{rangeStart}–{rangeEnd} of {total}
|
|
201
|
+
{data?.truncated ? ' (capped)' : ''}
|
|
202
|
+
</p>
|
|
203
|
+
<div className="flex gap-2">
|
|
204
|
+
<button
|
|
205
|
+
type="button"
|
|
206
|
+
disabled={page <= 1}
|
|
207
|
+
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
208
|
+
className="rounded-md px-3 py-1.5 text-sm font-medium text-[var(--txt)] hover:bg-[var(--acc-l)] disabled:cursor-not-allowed disabled:opacity-50"
|
|
209
|
+
>
|
|
210
|
+
Previous
|
|
211
|
+
</button>
|
|
212
|
+
<button
|
|
213
|
+
type="button"
|
|
214
|
+
disabled={page >= totalPages}
|
|
215
|
+
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
216
|
+
className="rounded-md px-3 py-1.5 text-sm font-medium text-[var(--txt)] hover:bg-[var(--acc-l)] disabled:cursor-not-allowed disabled:opacity-50"
|
|
217
|
+
>
|
|
218
|
+
Next
|
|
219
|
+
</button>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
)}
|
|
186
223
|
</SectionCard>
|
|
187
224
|
</SplitPaneList>
|
|
188
225
|
|
|
@@ -194,6 +231,7 @@ export function ContentTab({
|
|
|
194
231
|
entityId={selection.id}
|
|
195
232
|
onClose={() => setSelection(null)}
|
|
196
233
|
onSaved={refetch}
|
|
234
|
+
onNavigate={onNavigate}
|
|
197
235
|
/>
|
|
198
236
|
)}
|
|
199
237
|
</InspectorPane>
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from 'lucide-react'
|
|
18
18
|
import { toast } from 'sonner'
|
|
19
19
|
import {
|
|
20
|
+
applySeoInternalLink,
|
|
20
21
|
fetchLinkGraphSnapshot,
|
|
21
22
|
fetchLinkHealthIssues,
|
|
22
23
|
fetchLinkSuggestions,
|
|
@@ -24,7 +25,9 @@ import {
|
|
|
24
25
|
type LinkGraphEntityRef,
|
|
25
26
|
type LinkSuggestion,
|
|
26
27
|
} from '../../lib/seo-service.js'
|
|
27
|
-
import { editPathForDoc } from '../../lib/collection-routes.js'
|
|
28
|
+
import { editPathForDoc, sourceEditPath } from '../../lib/collection-routes.js'
|
|
29
|
+
import { hasPlanFeature, type PlanInfo } from '../../lib/plan.js'
|
|
30
|
+
import { PlanUpgradeCallout } from '../../components/PlanUpgradeCallout.js'
|
|
28
31
|
import {
|
|
29
32
|
SectionCard,
|
|
30
33
|
SeoLoading,
|
|
@@ -88,10 +91,39 @@ function ActionCard({
|
|
|
88
91
|
function SuggestionList({
|
|
89
92
|
suggestions,
|
|
90
93
|
loading,
|
|
94
|
+
targetEntityId,
|
|
95
|
+
onNavigate,
|
|
96
|
+
onApplied,
|
|
97
|
+
planInfo,
|
|
91
98
|
}: {
|
|
92
99
|
suggestions: LinkSuggestion[]
|
|
93
100
|
loading: boolean
|
|
101
|
+
targetEntityId: string
|
|
102
|
+
onNavigate?: (path: string) => void
|
|
103
|
+
onApplied?: (suggestion: LinkSuggestion) => void
|
|
104
|
+
planInfo?: PlanInfo | null
|
|
94
105
|
}) {
|
|
106
|
+
const [applyingKey, setApplyingKey] = useState<string | null>(null)
|
|
107
|
+
const inlineApplyEnabled = hasPlanFeature(planInfo, 'seo.inlineApply')
|
|
108
|
+
|
|
109
|
+
async function handleApprove(suggestion: LinkSuggestion) {
|
|
110
|
+
const key = `${suggestion.sourceEntityId}-${suggestion.fingerprint}`
|
|
111
|
+
setApplyingKey(key)
|
|
112
|
+
const res = await applySeoInternalLink({
|
|
113
|
+
sourceEntityId: suggestion.sourceEntityId,
|
|
114
|
+
targetEntityId,
|
|
115
|
+
anchorText: suggestion.anchorText,
|
|
116
|
+
fingerprint: suggestion.fingerprint,
|
|
117
|
+
})
|
|
118
|
+
setApplyingKey(null)
|
|
119
|
+
if (res.error) {
|
|
120
|
+
toast.error(res.error)
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
toast.success('Link added.')
|
|
124
|
+
onApplied?.(suggestion)
|
|
125
|
+
}
|
|
126
|
+
|
|
95
127
|
if (loading) {
|
|
96
128
|
return (
|
|
97
129
|
<p className="text-muted-foreground flex items-center gap-2 text-sm">
|
|
@@ -108,26 +140,64 @@ function SuggestionList({
|
|
|
108
140
|
)
|
|
109
141
|
}
|
|
110
142
|
return (
|
|
111
|
-
<
|
|
112
|
-
{suggestions.
|
|
113
|
-
<
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
143
|
+
<div className="space-y-3">
|
|
144
|
+
{!inlineApplyEnabled && suggestions.some((s) => s.wrappable) ? (
|
|
145
|
+
<PlanUpgradeCallout feature="seo.inlineApply" upgradeUrl={planInfo?.upgradeUrl} />
|
|
146
|
+
) : null}
|
|
147
|
+
<ul className="divide-border divide-y rounded-lg border">
|
|
148
|
+
{suggestions.map((s) => {
|
|
149
|
+
const key = `${s.sourceEntityId}-${s.fingerprint || s.anchorText}`
|
|
150
|
+
const busy = applyingKey === key
|
|
151
|
+
return (
|
|
152
|
+
<li key={key} className="px-4 py-3">
|
|
153
|
+
<p className="text-foreground text-sm font-medium">{s.sourceTitle}</p>
|
|
154
|
+
<p className="text-muted-foreground mt-0.5 text-xs">
|
|
155
|
+
Link “{s.anchorText}” → {s.sourceUrl || 'target page'}{' '}
|
|
156
|
+
<span className="text-muted-foreground/80 tabular-nums">
|
|
157
|
+
({Math.round(s.confidence * 100)}% match)
|
|
158
|
+
</span>
|
|
159
|
+
</p>
|
|
160
|
+
{s.excerpt && (
|
|
161
|
+
<p className="text-muted-foreground mt-1 line-clamp-2 text-xs">{s.excerpt}</p>
|
|
162
|
+
)}
|
|
163
|
+
{s.wrappable && inlineApplyEnabled ? (
|
|
164
|
+
<div className="mt-2">
|
|
165
|
+
<button
|
|
166
|
+
type="button"
|
|
167
|
+
className={btnPrimary}
|
|
168
|
+
disabled={busy}
|
|
169
|
+
onClick={() => void handleApprove(s)}
|
|
170
|
+
>
|
|
171
|
+
{busy ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : null}
|
|
172
|
+
Approve link
|
|
173
|
+
</button>
|
|
174
|
+
</div>
|
|
175
|
+
) : onNavigate ? (
|
|
176
|
+
<div className="mt-2">
|
|
177
|
+
<button
|
|
178
|
+
type="button"
|
|
179
|
+
className={btnSecondary}
|
|
180
|
+
onClick={() => onNavigate(sourceEditPath(s.sourceCollection, s.sourceEntityId))}
|
|
181
|
+
>
|
|
182
|
+
Open in editor
|
|
183
|
+
</button>
|
|
184
|
+
</div>
|
|
185
|
+
) : null}
|
|
186
|
+
</li>
|
|
187
|
+
)
|
|
188
|
+
})}
|
|
189
|
+
</ul>
|
|
190
|
+
</div>
|
|
127
191
|
)
|
|
128
192
|
}
|
|
129
193
|
|
|
130
|
-
export function LinksTab({
|
|
194
|
+
export function LinksTab({
|
|
195
|
+
onNavigate,
|
|
196
|
+
planInfo,
|
|
197
|
+
}: {
|
|
198
|
+
onNavigate?: (path: string) => void
|
|
199
|
+
planInfo?: PlanInfo | null
|
|
200
|
+
}) {
|
|
131
201
|
const { data, loading, error, refetch } = useSeoResource(fetchLinkGraphSnapshot, [])
|
|
132
202
|
const [indexing, setIndexing] = useState(false)
|
|
133
203
|
const [healthLoading, setHealthLoading] = useState(false)
|
|
@@ -379,7 +449,26 @@ export function LinksTab({ onNavigate }: { onNavigate?: (path: string) => void }
|
|
|
379
449
|
<p className="text-foreground mb-2 text-sm font-medium">
|
|
380
450
|
AI suggestions for “{selectedOrphan.title}”
|
|
381
451
|
</p>
|
|
382
|
-
<SuggestionList
|
|
452
|
+
<SuggestionList
|
|
453
|
+
suggestions={suggestions}
|
|
454
|
+
loading={suggestLoading}
|
|
455
|
+
targetEntityId={selectedOrphan.id}
|
|
456
|
+
onNavigate={onNavigate}
|
|
457
|
+
planInfo={planInfo}
|
|
458
|
+
onApplied={(applied) => {
|
|
459
|
+
setSuggestions((prev) =>
|
|
460
|
+
prev.filter(
|
|
461
|
+
(row) =>
|
|
462
|
+
!(
|
|
463
|
+
row.fingerprint === applied.fingerprint &&
|
|
464
|
+
row.sourceEntityId === applied.sourceEntityId
|
|
465
|
+
),
|
|
466
|
+
),
|
|
467
|
+
)
|
|
468
|
+
refetch()
|
|
469
|
+
void loadSuggestions(selectedOrphan)
|
|
470
|
+
}}
|
|
471
|
+
/>
|
|
383
472
|
</div>
|
|
384
473
|
)}
|
|
385
474
|
</div>
|