@actuate-media/cms-admin 0.73.0 → 0.75.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 +28 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +3 -2
- package/dist/AdminRoot.js.map +1 -1
- package/dist/LoginRoot.d.ts.map +1 -1
- package/dist/LoginRoot.js +2 -1
- package/dist/LoginRoot.js.map +1 -1
- package/dist/__tests__/components/theme-provider-accent.test.d.ts +2 -0
- package/dist/__tests__/components/theme-provider-accent.test.d.ts.map +1 -0
- package/dist/__tests__/components/theme-provider-accent.test.js +64 -0
- package/dist/__tests__/components/theme-provider-accent.test.js.map +1 -0
- package/dist/__tests__/views/portal-container.test.js +13 -0
- package/dist/__tests__/views/portal-container.test.js.map +1 -1
- package/dist/__tests__/views/post-fields-modal.render.test.js +3 -6
- package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -1
- package/dist/__tests__/views/redirects-pane.render.test.js +6 -0
- package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-content-pane.render.test.js +15 -4
- package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/CoauthorResultPopover.js +1 -1
- package/dist/components/EditorSeoAside.d.ts +23 -0
- package/dist/components/EditorSeoAside.d.ts.map +1 -0
- package/dist/components/EditorSeoAside.js +21 -0
- package/dist/components/EditorSeoAside.js.map +1 -0
- package/dist/components/MediaPickerModal.js +1 -1
- package/dist/components/SEOConfigPanel.js +1 -1
- package/dist/components/SchedulePublishDialog.js +1 -1
- package/dist/components/SharePreviewLinkDialog.js +1 -1
- package/dist/components/ThemeProvider.d.ts.map +1 -1
- package/dist/components/ThemeProvider.js +2 -1
- package/dist/components/ThemeProvider.js.map +1 -1
- package/dist/components/VersionHistory.js +1 -1
- package/dist/components/seo/EditorSeoScoreBadge.d.ts +7 -0
- package/dist/components/seo/EditorSeoScoreBadge.d.ts.map +1 -0
- package/dist/components/seo/EditorSeoScoreBadge.js +9 -0
- package/dist/components/seo/EditorSeoScoreBadge.js.map +1 -0
- package/dist/components/seo/SeoAccordion.d.ts +16 -0
- package/dist/components/seo/SeoAccordion.d.ts.map +1 -0
- package/dist/components/seo/SeoAccordion.js +21 -0
- package/dist/components/seo/SeoAccordion.js.map +1 -0
- package/dist/components/seo/SeoAnalysisList.d.ts +7 -0
- package/dist/components/seo/SeoAnalysisList.d.ts.map +1 -0
- package/dist/components/seo/SeoAnalysisList.js +32 -0
- package/dist/components/seo/SeoAnalysisList.js.map +1 -0
- package/dist/components/seo/SeoEditorPane.d.ts +15 -4
- package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
- package/dist/components/seo/SeoEditorPane.js +155 -35
- package/dist/components/seo/SeoEditorPane.js.map +1 -1
- package/dist/components/seo/SeoInsightsMetrics.d.ts +6 -0
- package/dist/components/seo/SeoInsightsMetrics.d.ts.map +1 -0
- package/dist/components/seo/SeoInsightsMetrics.js +16 -0
- package/dist/components/seo/SeoInsightsMetrics.js.map +1 -0
- package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
- package/dist/components/seo/SeoIssueFixPanel.js +13 -3
- package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
- package/dist/components/seo/SeoLinkingSection.d.ts +9 -0
- package/dist/components/seo/SeoLinkingSection.d.ts.map +1 -0
- package/dist/components/seo/SeoLinkingSection.js +44 -0
- package/dist/components/seo/SeoLinkingSection.js.map +1 -0
- package/dist/components/seo/primitives.d.ts.map +1 -1
- package/dist/components/seo/primitives.js +3 -3
- package/dist/components/seo/primitives.js.map +1 -1
- package/dist/components/ui/Card.js +5 -5
- package/dist/components/ui/Card.js.map +1 -1
- package/dist/components/ui/InspectorPane.js +3 -3
- package/dist/components/ui/InspectorPane.js.map +1 -1
- package/dist/components/ui/PanelSection.d.ts +16 -0
- package/dist/components/ui/PanelSection.d.ts.map +1 -0
- package/dist/components/ui/PanelSection.js +15 -0
- package/dist/components/ui/PanelSection.js.map +1 -0
- package/dist/lib/admin-root-element.d.ts +9 -0
- package/dist/lib/admin-root-element.d.ts.map +1 -0
- package/dist/lib/admin-root-element.js +14 -0
- package/dist/lib/admin-root-element.js.map +1 -0
- package/dist/lib/editor-seo-content.d.ts +16 -0
- package/dist/lib/editor-seo-content.d.ts.map +1 -0
- package/dist/lib/editor-seo-content.js +139 -0
- package/dist/lib/editor-seo-content.js.map +1 -0
- package/dist/lib/portal-container.d.ts.map +1 -1
- package/dist/lib/portal-container.js +2 -3
- package/dist/lib/portal-container.js.map +1 -1
- package/dist/lib/seo-service.d.ts +91 -0
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +55 -2
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/views/ApiKeys.js +2 -2
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +12 -14
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +35 -6
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/PageEditor.js +1 -1
- package/dist/views/Posts/PostTypeEditor.js +3 -3
- package/dist/views/SEO.d.ts.map +1 -1
- package/dist/views/SEO.js +4 -2
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/ScriptTags.js +1 -1
- package/dist/views/ScriptTags.js.map +1 -1
- package/dist/views/Users.js +1 -1
- package/dist/views/page-editor/EditorTopBar.d.ts +6 -1
- package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
- package/dist/views/page-editor/EditorTopBar.js +5 -2
- package/dist/views/page-editor/EditorTopBar.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +36 -1
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.d.ts +1 -1
- package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.js +2 -13
- package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
- package/dist/views/page-editor/SectionInspector.js +2 -2
- package/dist/views/page-editor/SectionsPanel.js +1 -1
- package/dist/views/post-editor/PostFieldsModal.d.ts +1 -1
- package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.js +17 -79
- package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
- package/dist/views/post-editor/PostHeaderPanel.js +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +37 -1
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/dist/views/seo/ContentTab.d.ts.map +1 -1
- package/dist/views/seo/ContentTab.js +7 -1
- package/dist/views/seo/ContentTab.js.map +1 -1
- package/dist/views/seo/LinksTab.d.ts +4 -0
- package/dist/views/seo/LinksTab.d.ts.map +1 -0
- package/dist/views/seo/LinksTab.js +111 -0
- package/dist/views/seo/LinksTab.js.map +1 -0
- package/dist/views/seo/OverviewTab.js +1 -1
- package/dist/views/seo/OverviewTab.js.map +1 -1
- package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
- package/dist/views/seo/RedirectsTab.js +39 -6
- package/dist/views/seo/RedirectsTab.js.map +1 -1
- package/dist/views/seo/TechnicalTab.js +2 -2
- package/dist/views/settings/AISettingsTab.js +1 -1
- package/dist/views/settings/AiFeaturesCard.js +1 -1
- package/dist/views/settings/AiFeaturesCard.js.map +1 -1
- package/dist/views/settings/AppearanceTab.js +1 -1
- package/dist/views/settings/BrandVoiceCard.js +1 -1
- package/dist/views/settings/EmailSettingsTab.js +2 -2
- package/dist/views/settings/GeneralSettingsTab.js +1 -1
- package/dist/views/settings/SecurityStatusCard.js +1 -1
- package/dist/views/settings/SeoSettingsTab.js +4 -4
- package/dist/views/settings/UpdatesTab.js +2 -2
- package/dist/views/settings/components.js +3 -3
- package/dist/views/settings/components.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +3 -2
- package/src/LoginRoot.tsx +2 -1
- package/src/__tests__/components/theme-provider-accent.test.tsx +84 -0
- package/src/__tests__/views/portal-container.test.tsx +14 -0
- package/src/__tests__/views/post-fields-modal.render.test.tsx +3 -9
- package/src/__tests__/views/redirects-pane.render.test.tsx +6 -0
- package/src/__tests__/views/seo-content-pane.render.test.tsx +16 -4
- package/src/components/CoauthorResultPopover.tsx +1 -1
- package/src/components/EditorSeoAside.tsx +68 -0
- package/src/components/MediaPickerModal.tsx +1 -1
- package/src/components/SEOConfigPanel.tsx +2 -2
- package/src/components/SchedulePublishDialog.tsx +1 -1
- package/src/components/SharePreviewLinkDialog.tsx +1 -1
- package/src/components/ThemeProvider.tsx +3 -1
- package/src/components/VersionHistory.tsx +1 -1
- package/src/components/seo/EditorSeoScoreBadge.tsx +26 -0
- package/src/components/seo/SeoAccordion.tsx +76 -0
- package/src/components/seo/SeoAnalysisList.tsx +113 -0
- package/src/components/seo/SeoEditorPane.tsx +532 -250
- package/src/components/seo/SeoInsightsMetrics.tsx +109 -0
- package/src/components/seo/SeoIssueFixPanel.tsx +16 -4
- package/src/components/seo/SeoLinkingSection.tsx +151 -0
- package/src/components/seo/primitives.tsx +7 -3
- package/src/components/ui/Card.tsx +5 -5
- package/src/components/ui/InspectorPane.tsx +3 -3
- package/src/components/ui/PanelSection.tsx +53 -0
- package/src/lib/admin-root-element.ts +15 -0
- package/src/lib/editor-seo-content.ts +144 -0
- package/src/lib/portal-container.ts +3 -2
- package/src/lib/seo-service.ts +150 -2
- package/src/views/ApiKeys.tsx +2 -2
- package/src/views/Dashboard.tsx +40 -53
- package/src/views/MediaBrowser.tsx +62 -12
- package/src/views/PageEditor.tsx +1 -1
- package/src/views/Posts/PostTypeEditor.tsx +3 -3
- package/src/views/SEO.tsx +6 -0
- package/src/views/ScriptTags.tsx +2 -2
- package/src/views/Users.tsx +1 -1
- package/src/views/page-editor/EditorTopBar.tsx +27 -0
- package/src/views/page-editor/PageSectionEditor.tsx +58 -3
- package/src/views/page-editor/PageSettingsModal.tsx +1 -45
- package/src/views/page-editor/SectionInspector.tsx +2 -2
- package/src/views/page-editor/SectionsPanel.tsx +1 -1
- package/src/views/post-editor/PostFieldsModal.tsx +19 -138
- package/src/views/post-editor/PostHeaderPanel.tsx +1 -1
- package/src/views/post-editor/PostSectionEditor.tsx +60 -3
- package/src/views/seo/ContentTab.tsx +11 -5
- package/src/views/seo/LinksTab.tsx +462 -0
- package/src/views/seo/OverviewTab.tsx +1 -1
- package/src/views/seo/RedirectsTab.tsx +97 -4
- package/src/views/seo/TechnicalTab.tsx +2 -2
- package/src/views/settings/AISettingsTab.tsx +3 -3
- package/src/views/settings/AiFeaturesCard.tsx +1 -1
- package/src/views/settings/AppearanceTab.tsx +1 -1
- package/src/views/settings/BrandVoiceCard.tsx +2 -2
- package/src/views/settings/EmailSettingsTab.tsx +2 -2
- package/src/views/settings/GeneralSettingsTab.tsx +3 -3
- package/src/views/settings/SecurityStatusCard.tsx +1 -1
- package/src/views/settings/SeoSettingsTab.tsx +6 -6
- package/src/views/settings/UpdatesTab.tsx +3 -3
- package/src/views/settings/components.tsx +3 -3
|
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
|
4
4
|
import { AlertTriangle, Loader2, Lock } from 'lucide-react'
|
|
5
5
|
import { toast } from 'sonner'
|
|
6
6
|
import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
|
|
7
|
+
import { EditorSeoAside } from '../../components/EditorSeoAside.js'
|
|
7
8
|
import { ErrorBoundary } from '../../components/ErrorBoundary.js'
|
|
8
9
|
import { VersionHistory } from '../../components/VersionHistory.js'
|
|
9
10
|
import {
|
|
@@ -49,6 +50,7 @@ import { SectionInspector } from '../page-editor/SectionInspector.js'
|
|
|
49
50
|
import { AddSectionModal } from '../page-editor/AddSectionModal.js'
|
|
50
51
|
import { ValidationSummary } from '../page-editor/ValidationSummary.js'
|
|
51
52
|
import { resolveDefaultSitePreview } from '../../lib/editor-preview.js'
|
|
53
|
+
import { computeLiveSeoScore } from '../../lib/editor-seo-content.js'
|
|
52
54
|
import { PostEditorCanvas } from './PostEditorCanvas.js'
|
|
53
55
|
import { PostFieldsModal, type PostFieldConfig } from './PostFieldsModal.js'
|
|
54
56
|
|
|
@@ -122,6 +124,7 @@ export function PostSectionEditor({
|
|
|
122
124
|
|
|
123
125
|
const [addOpen, setAddOpen] = useState(false)
|
|
124
126
|
const [fieldsOpen, setFieldsOpen] = useState(false)
|
|
127
|
+
const [seoOpen, setSeoOpen] = useState(false)
|
|
125
128
|
const [historyOpen, setHistoryOpen] = useState(false)
|
|
126
129
|
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null)
|
|
127
130
|
const [confirmPublish, setConfirmPublish] = useState(false)
|
|
@@ -156,6 +159,18 @@ export function PostSectionEditor({
|
|
|
156
159
|
[post],
|
|
157
160
|
)
|
|
158
161
|
const publishReady = Boolean(post) && publishCheck.errors.length === 0
|
|
162
|
+
|
|
163
|
+
const liveSeoScore = useMemo(() => {
|
|
164
|
+
if (!post) return 0
|
|
165
|
+
return computeLiveSeoScore({
|
|
166
|
+
title: post.title,
|
|
167
|
+
slug: post.slug,
|
|
168
|
+
seoTitle: post.seoTitle,
|
|
169
|
+
seoDescription: post.seoDescription,
|
|
170
|
+
sections: post.sections,
|
|
171
|
+
bodyHtml: post.body,
|
|
172
|
+
})
|
|
173
|
+
}, [post])
|
|
159
174
|
const publishDisabledReason = publishCheck.errors[0]
|
|
160
175
|
|
|
161
176
|
// ─── Load ──────────────────────────────────────────────────────────
|
|
@@ -386,6 +401,7 @@ export function PostSectionEditor({
|
|
|
386
401
|
.then((p) => {
|
|
387
402
|
setPost(p)
|
|
388
403
|
setSelectedId(null)
|
|
404
|
+
setSeoOpen(false)
|
|
389
405
|
setDirty(false)
|
|
390
406
|
setSaveState('idle')
|
|
391
407
|
setValidation(null)
|
|
@@ -395,6 +411,27 @@ export function PostSectionEditor({
|
|
|
395
411
|
})
|
|
396
412
|
}, [postType, post?.id])
|
|
397
413
|
|
|
414
|
+
const handleSeoSaved = useCallback(() => {
|
|
415
|
+
if (!post?.id) return
|
|
416
|
+
fetchPostForEditor(postType, post.id)
|
|
417
|
+
.then((p) => setPost(p))
|
|
418
|
+
.catch(() => {
|
|
419
|
+
/* SEO API saved; editor meta will sync on next full load */
|
|
420
|
+
})
|
|
421
|
+
}, [postType, post?.id])
|
|
422
|
+
|
|
423
|
+
const handleToggleSeo = useCallback(() => {
|
|
424
|
+
setSeoOpen((open) => {
|
|
425
|
+
if (!open) setSelectedId(null)
|
|
426
|
+
return !open
|
|
427
|
+
})
|
|
428
|
+
}, [])
|
|
429
|
+
|
|
430
|
+
const handleSelectSection = useCallback((id: string | null) => {
|
|
431
|
+
setSeoOpen(false)
|
|
432
|
+
setSelectedId(id)
|
|
433
|
+
}, [])
|
|
434
|
+
|
|
398
435
|
const guardedNavigate = useCallback(
|
|
399
436
|
(path: string) => {
|
|
400
437
|
if (dirty) setLeaveTarget(path)
|
|
@@ -534,6 +571,9 @@ export function PostSectionEditor({
|
|
|
534
571
|
onToggleSitePreview={post.id && post.slug ? sitePreview.toggle : undefined}
|
|
535
572
|
onHistory={post.id ? () => setHistoryOpen(true) : undefined}
|
|
536
573
|
onOpenSettings={() => setFieldsOpen(true)}
|
|
574
|
+
onOpenSeo={handleToggleSeo}
|
|
575
|
+
seoOpen={seoOpen}
|
|
576
|
+
seoScore={liveSeoScore}
|
|
537
577
|
onSaveDraft={handleSaveDraft}
|
|
538
578
|
onPublish={requestPublish}
|
|
539
579
|
structuralTypes={structuralTypes}
|
|
@@ -569,7 +609,7 @@ export function PostSectionEditor({
|
|
|
569
609
|
sections={post.sections}
|
|
570
610
|
selectedId={selectedId}
|
|
571
611
|
canEdit={canEdit}
|
|
572
|
-
onSelect={
|
|
612
|
+
onSelect={handleSelectSection}
|
|
573
613
|
onReorder={handleReorder}
|
|
574
614
|
onToggleVisibility={handleToggleVisibility}
|
|
575
615
|
onDuplicate={handleDuplicate}
|
|
@@ -592,7 +632,7 @@ export function PostSectionEditor({
|
|
|
592
632
|
context={context}
|
|
593
633
|
selectedId={selectedId}
|
|
594
634
|
viewport={viewport}
|
|
595
|
-
onSelect={
|
|
635
|
+
onSelect={handleSelectSection}
|
|
596
636
|
onAddSection={() => setAddOpen(true)}
|
|
597
637
|
structuralTypes={structuralTypes}
|
|
598
638
|
sitePreviewAvailable={sitePreviewAvailable && !sitePreview.active}
|
|
@@ -600,7 +640,7 @@ export function PostSectionEditor({
|
|
|
600
640
|
)}
|
|
601
641
|
</div>
|
|
602
642
|
|
|
603
|
-
{selected && (
|
|
643
|
+
{selected && !seoOpen && (
|
|
604
644
|
<SectionInspector
|
|
605
645
|
section={selected}
|
|
606
646
|
canEdit={canEdit}
|
|
@@ -613,6 +653,23 @@ export function PostSectionEditor({
|
|
|
613
653
|
onPostBodyChange={handlePostBodyChange}
|
|
614
654
|
/>
|
|
615
655
|
)}
|
|
656
|
+
|
|
657
|
+
{seoOpen && (
|
|
658
|
+
<EditorSeoAside
|
|
659
|
+
entityType="post"
|
|
660
|
+
entityId={post.id}
|
|
661
|
+
title={post.title}
|
|
662
|
+
slug={post.slug}
|
|
663
|
+
seoTitle={post.seoTitle}
|
|
664
|
+
seoDescription={post.seoDescription}
|
|
665
|
+
sections={post.sections}
|
|
666
|
+
bodyHtml={post.body}
|
|
667
|
+
canEdit={canEdit}
|
|
668
|
+
onClose={() => setSeoOpen(false)}
|
|
669
|
+
onDraftChange={handleFields}
|
|
670
|
+
onSaved={handleSeoSaved}
|
|
671
|
+
/>
|
|
672
|
+
)}
|
|
616
673
|
</div>
|
|
617
674
|
</div>
|
|
618
675
|
|
|
@@ -34,7 +34,12 @@ const FILTERS: { id: ContentSeoFilter; label: string }[] = [
|
|
|
34
34
|
const inputCls =
|
|
35
35
|
'w-full rounded-md border border-[var(--bdr)] bg-input-background py-2 pl-9 pr-3 text-base text-[var(--txt)] placeholder:text-[var(--sub)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]'
|
|
36
36
|
|
|
37
|
-
type
|
|
37
|
+
type SeoEntityType = 'page' | 'post'
|
|
38
|
+
type Selection = { type: SeoEntityType; id: string } | null
|
|
39
|
+
|
|
40
|
+
function parseEntityType(value: string | null | undefined): SeoEntityType | null {
|
|
41
|
+
return value === 'page' || value === 'post' ? value : null
|
|
42
|
+
}
|
|
38
43
|
|
|
39
44
|
export function ContentTab({
|
|
40
45
|
initialEntityType,
|
|
@@ -46,16 +51,17 @@ export function ContentTab({
|
|
|
46
51
|
const { data, loading, error, refetch } = useSeoResource(fetchSeoContentRecords, [])
|
|
47
52
|
const [search, setSearch] = useState('')
|
|
48
53
|
const [filter, setFilter] = useState<ContentSeoFilter>('all')
|
|
49
|
-
const [selection, setSelection] = useState<Selection>(
|
|
50
|
-
|
|
51
|
-
|
|
54
|
+
const [selection, setSelection] = useState<Selection>(() => {
|
|
55
|
+
const type = parseEntityType(initialEntityType)
|
|
56
|
+
return type && initialEntityId ? { type, id: initialEntityId } : null
|
|
57
|
+
})
|
|
52
58
|
|
|
53
59
|
const rows = useMemo(
|
|
54
60
|
() => applyContentSeoQuery(data ?? [], { search, filter }),
|
|
55
61
|
[data, search, filter],
|
|
56
62
|
)
|
|
57
63
|
|
|
58
|
-
const toggleRow = (type:
|
|
64
|
+
const toggleRow = (type: SeoEntityType, id: string) => {
|
|
59
65
|
setSelection((prev) => (prev?.type === type && prev.id === id ? null : { type, id }))
|
|
60
66
|
}
|
|
61
67
|
|
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Links tab — internal / external link tracking with AI-powered suggestions
|
|
5
|
+
* (orphan recovery, under-linked coverage). Backed by `/seo/links/*` APIs.
|
|
6
|
+
*/
|
|
7
|
+
import { useCallback, useMemo, useState } from 'react'
|
|
8
|
+
import {
|
|
9
|
+
Link2,
|
|
10
|
+
Sparkles,
|
|
11
|
+
AlertTriangle,
|
|
12
|
+
CheckCircle2,
|
|
13
|
+
Loader2,
|
|
14
|
+
RefreshCw,
|
|
15
|
+
ExternalLink,
|
|
16
|
+
Bot,
|
|
17
|
+
} from 'lucide-react'
|
|
18
|
+
import { toast } from 'sonner'
|
|
19
|
+
import {
|
|
20
|
+
fetchLinkGraphSnapshot,
|
|
21
|
+
fetchLinkHealthIssues,
|
|
22
|
+
fetchLinkSuggestions,
|
|
23
|
+
runLinkAiIndex,
|
|
24
|
+
type LinkGraphEntityRef,
|
|
25
|
+
type LinkSuggestion,
|
|
26
|
+
} from '../../lib/seo-service.js'
|
|
27
|
+
import { editPathForDoc } from '../../lib/collection-routes.js'
|
|
28
|
+
import {
|
|
29
|
+
SectionCard,
|
|
30
|
+
SeoLoading,
|
|
31
|
+
SeoErrorState,
|
|
32
|
+
SeoEmptyState,
|
|
33
|
+
btnPrimary,
|
|
34
|
+
btnSecondary,
|
|
35
|
+
gradeMeta,
|
|
36
|
+
} from '../../components/seo/primitives.js'
|
|
37
|
+
import { useSeoResource } from './useSeoResource.js'
|
|
38
|
+
|
|
39
|
+
function scoreFromCoverage(coverage: number, orphanCount: number, broken: number): number {
|
|
40
|
+
let score = coverage
|
|
41
|
+
score -= Math.min(30, orphanCount * 0.5)
|
|
42
|
+
score -= Math.min(20, broken * 2)
|
|
43
|
+
return Math.max(0, Math.min(100, Math.round(score)))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function ActionCard({
|
|
47
|
+
icon: Icon,
|
|
48
|
+
title,
|
|
49
|
+
description,
|
|
50
|
+
impact,
|
|
51
|
+
actionLabel,
|
|
52
|
+
onAction,
|
|
53
|
+
busy,
|
|
54
|
+
disabled,
|
|
55
|
+
}: {
|
|
56
|
+
icon: typeof Link2
|
|
57
|
+
title: string
|
|
58
|
+
description: string
|
|
59
|
+
impact: 'High' | 'Medium' | 'Low'
|
|
60
|
+
actionLabel: string
|
|
61
|
+
onAction: () => void
|
|
62
|
+
busy?: boolean
|
|
63
|
+
disabled?: boolean
|
|
64
|
+
}) {
|
|
65
|
+
const impactTone = impact === 'High' ? 'text-destructive' : 'text-warning'
|
|
66
|
+
return (
|
|
67
|
+
<div className="border-border bg-card flex flex-col gap-4 rounded-xl border p-5 shadow-sm sm:flex-row sm:items-center sm:justify-between">
|
|
68
|
+
<div className="flex min-w-0 gap-4">
|
|
69
|
+
<div className="bg-primary/10 text-primary flex h-11 w-11 shrink-0 items-center justify-center rounded-lg">
|
|
70
|
+
<Icon className="h-5 w-5" aria-hidden />
|
|
71
|
+
</div>
|
|
72
|
+
<div className="min-w-0">
|
|
73
|
+
<h3 className="text-foreground text-sm font-medium">{title}</h3>
|
|
74
|
+
<p className="text-muted-foreground mt-1 text-sm">{description}</p>
|
|
75
|
+
<div className="mt-2 flex flex-wrap items-center gap-2">
|
|
76
|
+
<span className={`text-xs font-medium ${impactTone}`}>Impact: {impact}</span>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
<button type="button" className={btnPrimary} onClick={onAction} disabled={disabled || busy}>
|
|
81
|
+
{busy ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : null}
|
|
82
|
+
{actionLabel}
|
|
83
|
+
</button>
|
|
84
|
+
</div>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function SuggestionList({
|
|
89
|
+
suggestions,
|
|
90
|
+
loading,
|
|
91
|
+
}: {
|
|
92
|
+
suggestions: LinkSuggestion[]
|
|
93
|
+
loading: boolean
|
|
94
|
+
}) {
|
|
95
|
+
if (loading) {
|
|
96
|
+
return (
|
|
97
|
+
<p className="text-muted-foreground flex items-center gap-2 text-sm">
|
|
98
|
+
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
|
99
|
+
Finding AI link opportunities…
|
|
100
|
+
</p>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
if (suggestions.length === 0) {
|
|
104
|
+
return (
|
|
105
|
+
<p className="text-muted-foreground text-sm">
|
|
106
|
+
No suggestions yet. Run AI indexing first, or add more published content.
|
|
107
|
+
</p>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
return (
|
|
111
|
+
<ul className="divide-border divide-y rounded-lg border">
|
|
112
|
+
{suggestions.map((s) => (
|
|
113
|
+
<li key={`${s.sourceEntityId}-${s.anchorText}`} className="px-4 py-3">
|
|
114
|
+
<p className="text-foreground text-sm font-medium">{s.sourceTitle}</p>
|
|
115
|
+
<p className="text-muted-foreground mt-0.5 text-xs">
|
|
116
|
+
Link “{s.anchorText}” → {s.sourceUrl || 'target page'}{' '}
|
|
117
|
+
<span className="text-muted-foreground/80 tabular-nums">
|
|
118
|
+
({Math.round(s.confidence * 100)}% match)
|
|
119
|
+
</span>
|
|
120
|
+
</p>
|
|
121
|
+
{s.excerpt && (
|
|
122
|
+
<p className="text-muted-foreground mt-1 line-clamp-2 text-xs">{s.excerpt}</p>
|
|
123
|
+
)}
|
|
124
|
+
</li>
|
|
125
|
+
))}
|
|
126
|
+
</ul>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function LinksTab({ onNavigate }: { onNavigate?: (path: string) => void }) {
|
|
131
|
+
const { data, loading, error, refetch } = useSeoResource(fetchLinkGraphSnapshot, [])
|
|
132
|
+
const [indexing, setIndexing] = useState(false)
|
|
133
|
+
const [healthLoading, setHealthLoading] = useState(false)
|
|
134
|
+
const [healthIssues, setHealthIssues] = useState<
|
|
135
|
+
Awaited<ReturnType<typeof fetchLinkHealthIssues>>['data'] | null
|
|
136
|
+
>(null)
|
|
137
|
+
const [selectedOrphan, setSelectedOrphan] = useState<LinkGraphEntityRef | null>(null)
|
|
138
|
+
const [suggestions, setSuggestions] = useState<LinkSuggestion[]>([])
|
|
139
|
+
const [suggestLoading, setSuggestLoading] = useState(false)
|
|
140
|
+
|
|
141
|
+
const totalLinks = (data?.internalLinks ?? 0) + (data?.externalLinks ?? 0)
|
|
142
|
+
const internalPct = totalLinks > 0 ? Math.round((data!.internalLinks / totalLinks) * 100) : 0
|
|
143
|
+
const externalPct = totalLinks > 0 ? 100 - internalPct : 0
|
|
144
|
+
const healthScore = useMemo(
|
|
145
|
+
() =>
|
|
146
|
+
data
|
|
147
|
+
? scoreFromCoverage(data.linkCoveragePercent, data.orphanCount, data.brokenLinkCount)
|
|
148
|
+
: null,
|
|
149
|
+
[data],
|
|
150
|
+
)
|
|
151
|
+
const healthGrade =
|
|
152
|
+
healthScore !== null
|
|
153
|
+
? gradeMeta(
|
|
154
|
+
healthScore >= 80
|
|
155
|
+
? 'good'
|
|
156
|
+
: healthScore >= 60
|
|
157
|
+
? 'fair'
|
|
158
|
+
: healthScore >= 40
|
|
159
|
+
? 'poor'
|
|
160
|
+
: 'critical',
|
|
161
|
+
)
|
|
162
|
+
: null
|
|
163
|
+
|
|
164
|
+
const handleAiIndex = useCallback(async () => {
|
|
165
|
+
setIndexing(true)
|
|
166
|
+
const res = await runLinkAiIndex()
|
|
167
|
+
setIndexing(false)
|
|
168
|
+
if (res.error) {
|
|
169
|
+
toast.error(res.error)
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
toast.success(res.data?.message ?? 'Link index updated.')
|
|
173
|
+
refetch()
|
|
174
|
+
}, [refetch])
|
|
175
|
+
|
|
176
|
+
const handleLinkHealthScan = useCallback(async () => {
|
|
177
|
+
setHealthLoading(true)
|
|
178
|
+
const res = await fetchLinkHealthIssues()
|
|
179
|
+
setHealthLoading(false)
|
|
180
|
+
if (res.error) {
|
|
181
|
+
toast.error(res.error)
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
setHealthIssues(res.data ?? null)
|
|
185
|
+
toast.success(`Checked ${res.data?.checked ?? 0} links.`)
|
|
186
|
+
}, [])
|
|
187
|
+
|
|
188
|
+
const loadSuggestions = useCallback(async (orphan: LinkGraphEntityRef) => {
|
|
189
|
+
setSelectedOrphan(orphan)
|
|
190
|
+
setSuggestLoading(true)
|
|
191
|
+
setSuggestions([])
|
|
192
|
+
const res = await fetchLinkSuggestions({
|
|
193
|
+
targetEntityId: orphan.id,
|
|
194
|
+
targetEntityType: orphan.entityType,
|
|
195
|
+
})
|
|
196
|
+
setSuggestLoading(false)
|
|
197
|
+
if (res.error) {
|
|
198
|
+
toast.error(res.error)
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
setSuggestions(res.data?.suggestions ?? [])
|
|
202
|
+
}, [])
|
|
203
|
+
|
|
204
|
+
const openEditor = (ref: LinkGraphEntityRef) => {
|
|
205
|
+
const path = editPathForDoc(
|
|
206
|
+
{ type: ref.entityType === 'post' ? 'post' : 'page' },
|
|
207
|
+
ref.collection,
|
|
208
|
+
ref.id,
|
|
209
|
+
)
|
|
210
|
+
onNavigate?.(path)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (loading) return <SeoLoading label="Analyzing link graph…" />
|
|
214
|
+
if (error) return <SeoErrorState message={error} onRetry={refetch} />
|
|
215
|
+
if (!data)
|
|
216
|
+
return <SeoEmptyState title="No link data" description="Publish content to track links." />
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<div className="space-y-6">
|
|
220
|
+
<div>
|
|
221
|
+
<h2 className="text-foreground text-lg font-medium">Internal linking snapshot</h2>
|
|
222
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
223
|
+
Track internal and external links across published pages and posts. Use AI to recover
|
|
224
|
+
orphaned content and improve link coverage.
|
|
225
|
+
</p>
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
{/* Distribution */}
|
|
229
|
+
<SectionCard title="Link distribution" description="Published content only">
|
|
230
|
+
<div className="space-y-3">
|
|
231
|
+
<div className="flex h-3 overflow-hidden rounded-full">
|
|
232
|
+
<div
|
|
233
|
+
className="bg-primary motion-safe:transition-all"
|
|
234
|
+
style={{ width: `${internalPct}%` }}
|
|
235
|
+
title={`Internal ${internalPct}%`}
|
|
236
|
+
/>
|
|
237
|
+
<div
|
|
238
|
+
className="bg-success motion-safe:transition-all"
|
|
239
|
+
style={{ width: `${externalPct}%` }}
|
|
240
|
+
title={`External ${externalPct}%`}
|
|
241
|
+
/>
|
|
242
|
+
</div>
|
|
243
|
+
<div className="flex flex-wrap gap-6 text-sm">
|
|
244
|
+
<span className="text-foreground">
|
|
245
|
+
<span className="bg-primary mr-2 inline-block h-2 w-2 rounded-full" aria-hidden />
|
|
246
|
+
Internal: {data.internalLinks.toLocaleString()} ({internalPct}%)
|
|
247
|
+
</span>
|
|
248
|
+
<span className="text-foreground">
|
|
249
|
+
<span className="bg-success mr-2 inline-block h-2 w-2 rounded-full" aria-hidden />
|
|
250
|
+
External: {data.externalLinks.toLocaleString()} ({externalPct}%)
|
|
251
|
+
</span>
|
|
252
|
+
</div>
|
|
253
|
+
{externalPct > 0 && externalPct < 15 && (
|
|
254
|
+
<p className="border-info/30 bg-info/10 text-info flex items-start gap-2 rounded-lg border p-4 text-sm">
|
|
255
|
+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
|
|
256
|
+
External ratio is low. Consider citing relevant high-authority sources where
|
|
257
|
+
appropriate.
|
|
258
|
+
</p>
|
|
259
|
+
)}
|
|
260
|
+
</div>
|
|
261
|
+
</SectionCard>
|
|
262
|
+
|
|
263
|
+
{/* Score cards */}
|
|
264
|
+
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
|
265
|
+
<SectionCard title="Site link health">
|
|
266
|
+
{healthScore !== null && healthGrade && (
|
|
267
|
+
<div>
|
|
268
|
+
<span className={`text-3xl font-medium tabular-nums ${healthGrade.text}`}>
|
|
269
|
+
{healthScore}
|
|
270
|
+
</span>
|
|
271
|
+
<p className="text-muted-foreground mt-1 text-xs">{healthGrade.label}</p>
|
|
272
|
+
</div>
|
|
273
|
+
)}
|
|
274
|
+
</SectionCard>
|
|
275
|
+
<SectionCard title="Link coverage">
|
|
276
|
+
<span className="text-foreground text-3xl font-medium tabular-nums">
|
|
277
|
+
{data.linkCoveragePercent}%
|
|
278
|
+
</span>
|
|
279
|
+
<p className="text-muted-foreground mt-1 text-xs">Pages with inbound internal links</p>
|
|
280
|
+
</SectionCard>
|
|
281
|
+
<SectionCard title="Orphaned pages">
|
|
282
|
+
<span
|
|
283
|
+
className={`text-3xl font-medium tabular-nums ${data.orphanCount > 0 ? 'text-destructive' : 'text-success'}`}
|
|
284
|
+
>
|
|
285
|
+
{data.orphanCount}
|
|
286
|
+
</span>
|
|
287
|
+
<p className="text-muted-foreground mt-1 text-xs">No inbound internal links</p>
|
|
288
|
+
</SectionCard>
|
|
289
|
+
<SectionCard title="Broken links">
|
|
290
|
+
<span
|
|
291
|
+
className={`text-3xl font-medium tabular-nums ${data.brokenLinkCount > 0 ? 'text-warning' : 'text-success'}`}
|
|
292
|
+
>
|
|
293
|
+
{data.brokenLinkCount}
|
|
294
|
+
</span>
|
|
295
|
+
<p className="text-muted-foreground mt-1 text-xs">From latest SEO audit</p>
|
|
296
|
+
</SectionCard>
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
{/* AI actions */}
|
|
300
|
+
<div className="space-y-4">
|
|
301
|
+
<ActionCard
|
|
302
|
+
icon={Bot}
|
|
303
|
+
title="Perform basic AI scanning"
|
|
304
|
+
description="Index published content so AI can suggest which pages should link to each other. Re-run after publishing a significant batch of content."
|
|
305
|
+
impact="High"
|
|
306
|
+
actionLabel={indexing ? 'Indexing…' : 'Run AI index'}
|
|
307
|
+
onAction={handleAiIndex}
|
|
308
|
+
busy={indexing}
|
|
309
|
+
/>
|
|
310
|
+
{data.aiIndexLastRunAt && (
|
|
311
|
+
<p className="text-muted-foreground -mt-2 text-xs">
|
|
312
|
+
Last indexed {new Date(data.aiIndexLastRunAt).toLocaleString()}
|
|
313
|
+
{data.aiIndexReady ? ' · AI ready' : ''}
|
|
314
|
+
</p>
|
|
315
|
+
)}
|
|
316
|
+
<ActionCard
|
|
317
|
+
icon={Link2}
|
|
318
|
+
title="Orphaned posts & pages"
|
|
319
|
+
description="These published URLs have no inbound internal links. Add links from related content so visitors and search engines can find them."
|
|
320
|
+
impact="High"
|
|
321
|
+
actionLabel="Review list below"
|
|
322
|
+
onAction={() => {
|
|
323
|
+
document.getElementById('orphan-list')?.scrollIntoView({ behavior: 'smooth' })
|
|
324
|
+
}}
|
|
325
|
+
/>
|
|
326
|
+
<ActionCard
|
|
327
|
+
icon={Sparkles}
|
|
328
|
+
title="Link coverage"
|
|
329
|
+
description="Pages with fewer than two outbound internal links. Strengthen topical clusters by linking to related content."
|
|
330
|
+
impact="High"
|
|
331
|
+
actionLabel="Review under-linked"
|
|
332
|
+
onAction={() => {
|
|
333
|
+
document.getElementById('under-linked-list')?.scrollIntoView({ behavior: 'smooth' })
|
|
334
|
+
}}
|
|
335
|
+
/>
|
|
336
|
+
</div>
|
|
337
|
+
|
|
338
|
+
{/* Orphan list */}
|
|
339
|
+
<SectionCard
|
|
340
|
+
title="Orphaned content"
|
|
341
|
+
description={`${data.orphanCount} published item${data.orphanCount === 1 ? '' : 's'} with no inbound internal links`}
|
|
342
|
+
>
|
|
343
|
+
<div id="orphan-list" className="space-y-4">
|
|
344
|
+
{data.orphaned.length === 0 ? (
|
|
345
|
+
<p className="text-muted-foreground flex items-center gap-2 text-sm">
|
|
346
|
+
<CheckCircle2 className="text-success h-4 w-4" aria-hidden />
|
|
347
|
+
No orphaned pages detected.
|
|
348
|
+
</p>
|
|
349
|
+
) : (
|
|
350
|
+
<ul className="divide-border divide-y rounded-lg border">
|
|
351
|
+
{data.orphaned.map((row) => (
|
|
352
|
+
<li
|
|
353
|
+
key={row.id}
|
|
354
|
+
className="flex flex-wrap items-center justify-between gap-3 px-4 py-3"
|
|
355
|
+
>
|
|
356
|
+
<div className="min-w-0">
|
|
357
|
+
<p className="text-foreground truncate text-sm font-medium">{row.title}</p>
|
|
358
|
+
<p className="text-muted-foreground truncate text-xs">{row.url || row.path}</p>
|
|
359
|
+
</div>
|
|
360
|
+
<div className="flex shrink-0 flex-wrap gap-2">
|
|
361
|
+
<button type="button" className={btnSecondary} onClick={() => openEditor(row)}>
|
|
362
|
+
Edit
|
|
363
|
+
</button>
|
|
364
|
+
<button
|
|
365
|
+
type="button"
|
|
366
|
+
className={btnPrimary}
|
|
367
|
+
onClick={() => void loadSuggestions(row)}
|
|
368
|
+
>
|
|
369
|
+
<Sparkles className="h-3.5 w-3.5" aria-hidden />
|
|
370
|
+
AI suggest
|
|
371
|
+
</button>
|
|
372
|
+
</div>
|
|
373
|
+
</li>
|
|
374
|
+
))}
|
|
375
|
+
</ul>
|
|
376
|
+
)}
|
|
377
|
+
{selectedOrphan && (
|
|
378
|
+
<div className="border-border bg-muted/30 rounded-lg border p-4">
|
|
379
|
+
<p className="text-foreground mb-2 text-sm font-medium">
|
|
380
|
+
AI suggestions for “{selectedOrphan.title}”
|
|
381
|
+
</p>
|
|
382
|
+
<SuggestionList suggestions={suggestions} loading={suggestLoading} />
|
|
383
|
+
</div>
|
|
384
|
+
)}
|
|
385
|
+
</div>
|
|
386
|
+
</SectionCard>
|
|
387
|
+
|
|
388
|
+
{/* Under-linked */}
|
|
389
|
+
<SectionCard title="Under-linked pages" description="Fewer than 2 outbound internal links">
|
|
390
|
+
<div id="under-linked-list">
|
|
391
|
+
{data.underLinked.length === 0 ? (
|
|
392
|
+
<p className="text-muted-foreground text-sm">
|
|
393
|
+
All crawled pages have adequate outbound links.
|
|
394
|
+
</p>
|
|
395
|
+
) : (
|
|
396
|
+
<ul className="divide-border max-h-80 divide-y overflow-y-auto rounded-lg border">
|
|
397
|
+
{data.underLinked.map((row) => (
|
|
398
|
+
<li
|
|
399
|
+
key={row.id}
|
|
400
|
+
className="flex items-center justify-between gap-3 px-4 py-3 text-sm"
|
|
401
|
+
>
|
|
402
|
+
<span className="text-foreground min-w-0 truncate font-medium">{row.title}</span>
|
|
403
|
+
<span className="text-muted-foreground shrink-0 tabular-nums">
|
|
404
|
+
{row.outboundInternal} outbound
|
|
405
|
+
</span>
|
|
406
|
+
</li>
|
|
407
|
+
))}
|
|
408
|
+
</ul>
|
|
409
|
+
)}
|
|
410
|
+
</div>
|
|
411
|
+
</SectionCard>
|
|
412
|
+
|
|
413
|
+
{/* Live link health */}
|
|
414
|
+
<SectionCard
|
|
415
|
+
title="Live link health scan"
|
|
416
|
+
description="Probe outbound URLs (rate-limited). Complements audit broken-link issues."
|
|
417
|
+
action={
|
|
418
|
+
<button
|
|
419
|
+
type="button"
|
|
420
|
+
className={btnSecondary}
|
|
421
|
+
onClick={() => void handleLinkHealthScan()}
|
|
422
|
+
disabled={healthLoading}
|
|
423
|
+
>
|
|
424
|
+
{healthLoading ? (
|
|
425
|
+
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
|
426
|
+
) : (
|
|
427
|
+
<RefreshCw className="h-4 w-4" aria-hidden />
|
|
428
|
+
)}
|
|
429
|
+
Scan now
|
|
430
|
+
</button>
|
|
431
|
+
}
|
|
432
|
+
>
|
|
433
|
+
{healthIssues && healthIssues.issues.length > 0 ? (
|
|
434
|
+
<ul className="divide-border max-h-64 divide-y overflow-y-auto rounded-lg border">
|
|
435
|
+
{healthIssues.issues.map((issue) => (
|
|
436
|
+
<li key={issue.id} className="px-4 py-3 text-sm">
|
|
437
|
+
<p className="text-foreground font-medium">{issue.page}</p>
|
|
438
|
+
<p className="text-muted-foreground mt-0.5 flex items-center gap-1 truncate text-xs">
|
|
439
|
+
<ExternalLink className="h-3 w-3 shrink-0" aria-hidden />
|
|
440
|
+
{issue.url}
|
|
441
|
+
<span className="text-destructive ml-1">HTTP {issue.status || 'error'}</span>
|
|
442
|
+
<span className="text-muted-foreground">· {issue.type}</span>
|
|
443
|
+
</p>
|
|
444
|
+
</li>
|
|
445
|
+
))}
|
|
446
|
+
</ul>
|
|
447
|
+
) : (
|
|
448
|
+
<p className="text-muted-foreground text-sm">
|
|
449
|
+
{healthIssues
|
|
450
|
+
? 'No failing links in the last scan.'
|
|
451
|
+
: 'Run a scan to check live URL responses across published content.'}
|
|
452
|
+
</p>
|
|
453
|
+
)}
|
|
454
|
+
</SectionCard>
|
|
455
|
+
|
|
456
|
+
<p className="text-muted-foreground text-xs">
|
|
457
|
+
Crawled {data.postsCrawled.toLocaleString()} published documents · Updated{' '}
|
|
458
|
+
{new Date(data.scannedAt).toLocaleString()}
|
|
459
|
+
</p>
|
|
460
|
+
</div>
|
|
461
|
+
)
|
|
462
|
+
}
|
|
@@ -171,7 +171,7 @@ export function OverviewTab({
|
|
|
171
171
|
? 'bg-warning/10 text-warning'
|
|
172
172
|
: 'bg-success/10 text-success'
|
|
173
173
|
return (
|
|
174
|
-
<li key={w.id} className="flex items-start justify-between gap-3 py-3
|
|
174
|
+
<li key={w.id} className="flex items-start justify-between gap-3 py-3">
|
|
175
175
|
<div className="min-w-0">
|
|
176
176
|
<p className="text-foreground text-sm font-medium">{w.title}</p>
|
|
177
177
|
<p className="text-muted-foreground mt-0.5 text-sm">{w.explanation}</p>
|