@@ -751,7 +766,7 @@ export default function ContentAnalytics({ searchQuery = '' }: ViewProps) {
{vca.urlsByStatus}
{vca.totalCrawled}{' '}
- {Number(summary.total_urls || 0).toLocaleString()}
+ {crawledCount.toLocaleString()}
{summary.success_rate != null && (
<> · {summary.success_rate}% {vca.returned2xx}
>
diff --git a/web/src/views/Home.tsx b/web/src/views/Home.tsx
index 38fe082c..870f120b 100644
--- a/web/src/views/Home.tsx
+++ b/web/src/views/Home.tsx
@@ -1,36 +1,20 @@
-import { Building2, ChevronDown, ExternalLink, Globe, ArrowRight, Search, Trash2 } from 'lucide-react';
+import { Building2, ChevronDown, Search } from 'lucide-react';
import { useMemo, useState, useEffect, useCallback } from 'react';
import AppLogo from '@/components/AppLogo';
import { PageLayout, Card } from '../components';
-import HealthSparkline from '@/components/HealthSparkline';
+import PortfolioPropertyCard from '@/components/portfolio/PortfolioPropertyCard';
+import { healthScoreClass, portfolioCardKey } from '@/components/portfolio/portfolioCardUtils';
import { Skeleton, SkeletonDomainCard } from '../components/Skeleton';
import { useReport } from '../context/useReport';
import { format, strings } from '../lib/strings';
import { extractHostname } from '@/lib/domainSlug';
import { apiUrl, reportApi } from '../lib/publicBase';
-import type { PortfolioGroup, ReportCategory, ViewProps } from '@/types';
-
-function scoreFromCategories(categories: ReportCategory[] = []): number | null {
- const numeric = (categories || [])
- .map((c) => Number(c?.score))
- .filter((n) => Number.isFinite(n));
- if (!numeric.length) return null;
- const avg = numeric.reduce((a, b) => a + b, 0) / numeric.length;
- return Math.round(avg);
-}
-
-function toLocalDateTime(value: string | null | undefined): string {
- if (!value) return '';
- const d = new Date(value);
- if (Number.isNaN(d.getTime())) return '';
- return d.toLocaleString();
-}
-
-function healthScoreClass(score: number): string {
- if (score >= 80) return 'text-emerald-700 dark:text-emerald-400';
- if (score >= 60) return 'text-amber-700 dark:text-amber-400';
- return 'text-rose-700 dark:text-rose-400';
-}
+import {
+ parsePortfolioAuditHistory,
+ type PortfolioAuditHistoryPoint,
+} from '@/lib/portfolioAuditHistory';
+import type { PortfolioCrawlHistoryPoint } from '@/types/api';
+import type { PortfolioGroup, ViewProps } from '@/types';
function portfolioRootDomain(group: PortfolioGroup): string {
const host = extractHostname(group.crawlUrl) || group.domainName.trim().toLowerCase();
@@ -51,7 +35,12 @@ export default function Home({ onNavigate }: ViewProps) {
const [pendingDeleteKey, setPendingDeleteKey] = useState(null);
const [deletingKey, setDeletingKey] = useState(null);
const [deleteError, setDeleteError] = useState(null);
- const [healthHistoryByDomain, setHealthHistoryByDomain] = useState>({});
+ const [auditHistoryByDomain, setAuditHistoryByDomain] = useState<
+ Record
+ >({});
+ const [crawlHistoryByDomain, setCrawlHistoryByDomain] = useState<
+ Record
+ >({});
const [collapsedGroups, setCollapsedGroups] = useState>(() => new Set());
const toggleGroupCollapsed = useCallback((rootDomain: string) => {
@@ -63,9 +52,6 @@ export default function Home({ onNavigate }: ViewProps) {
});
}, []);
- const portfolioCardKey = (group: PortfolioGroup) =>
- `${group.domainParam}-${group.crawlOnly ? 'crawl' : 'report'}-${group.reportId ?? 'nr'}-${group.crawlRunId ?? 'nc'}-${group.generatedAtMs}`;
-
const openSite = useCallback(async (group: PortfolioGroup) => {
if (group.crawlOnly && group.crawlRunId != null) {
setOpeningCrawlId(group.crawlRunId);
@@ -116,6 +102,7 @@ export default function Home({ onNavigate }: ViewProps) {
useEffect(() => {
if (!reportList.length && !crawlRuns.length) {
setDomainGroups([]);
+ setCrawlHistoryByDomain({});
setPortfolioLoading(false);
return;
}
@@ -126,10 +113,18 @@ export default function Home({ onNavigate }: ViewProps) {
fetch(reportApi(`/portfolio${qs}`))
.then((res) => res.json())
.then((body) => {
- if (!cancelled) setDomainGroups(Array.isArray(body.groups) ? body.groups : []);
+ if (cancelled) return;
+ setDomainGroups(Array.isArray(body.groups) ? body.groups : []);
+ const crawlHistory = body.crawlHistoryByDomain;
+ setCrawlHistoryByDomain(
+ crawlHistory && typeof crawlHistory === 'object' ? crawlHistory : {},
+ );
})
.catch(() => {
- if (!cancelled) setDomainGroups([]);
+ if (!cancelled) {
+ setDomainGroups([]);
+ setCrawlHistoryByDomain({});
+ }
})
.finally(() => {
if (!cancelled) setPortfolioLoading(false);
@@ -141,7 +136,7 @@ export default function Home({ onNavigate }: ViewProps) {
useEffect(() => {
if (!domainGroups.length) {
- setHealthHistoryByDomain({});
+ setAuditHistoryByDomain({});
return;
}
let cancelled = false;
@@ -154,22 +149,22 @@ export default function Home({ onNavigate }: ViewProps) {
apiUrl(`/report/history?domain=${encodeURIComponent(g.domainParam)}&limit=8`),
);
const body = await res.json();
- const scores = [...(body.history || [])]
- .map((row: { healthScore?: number | null }) => row.healthScore)
- .filter((n: unknown): n is number => typeof n === 'number' && Number.isFinite(n))
- .reverse();
- return [g.domainParam, scores] as [string, number[]];
+ const points = parsePortfolioAuditHistory(body.history || []);
+ return [g.domainParam, points] as [string, PortfolioAuditHistoryPoint[]];
} catch {
- return [g.domainParam, [] as number[]] as [string, number[]];
+ return [g.domainParam, [] as PortfolioAuditHistoryPoint[]] as [
+ string,
+ PortfolioAuditHistoryPoint[],
+ ];
}
}),
).then((entries) => {
if (cancelled) return;
- const map: Record = {};
- for (const [domain, scores] of entries) {
- if (scores.length) map[domain] = scores;
+ const map: Record = {};
+ for (const [domain, points] of entries) {
+ if (points.length) map[domain] = points;
}
- setHealthHistoryByDomain(map);
+ setAuditHistoryByDomain(map);
});
return () => {
cancelled = true;
@@ -210,16 +205,11 @@ export default function Home({ onNavigate }: ViewProps) {
.toSorted((a, b) => (b.items[0]?.generatedAtMs ?? 0) - (a.items[0]?.generatedAtMs ?? 0));
}, [filteredGroups]);
- const emptyMessage = filterQuery
- ? vh.noSearchResults
- : vh.empty;
+ const emptyMessage = filterQuery ? vh.noSearchResults : vh.empty;
return (
-
+
@@ -228,52 +218,52 @@ export default function Home({ onNavigate }: ViewProps) {
-
-
{vh.title}
-
{vh.subtitle}
-
-
-
- setFilterQuery(e.target.value)}
- placeholder={vh.searchPlaceholder}
- className="w-full rounded-full border border-default bg-brand-900/30 px-9 py-2 text-xs sm:text-sm text-foreground outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20"
- />
-
-
-
-
-
{vh.totalBrandsLabel}
- {portfolioLoading ? (
-
- ) : (
-
{portfolioTotals.totalBrands.toLocaleString()}
- )}
+
-
-
{vh.totalUrlsLabel}
- {portfolioLoading ? (
-
- ) : (
-
{portfolioTotals.totalUrls.toLocaleString()}
- )}
+
{vh.title}
+
{vh.subtitle}
+
+
+
+ setFilterQuery(e.target.value)}
+ placeholder={vh.searchPlaceholder}
+ className="w-full rounded-full border border-default bg-brand-900/30 px-9 py-2 text-xs sm:text-sm text-foreground outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20"
+ />
-
-
{vh.avgHealthLabel}
- {portfolioLoading ? (
-
- ) : (
-
- {portfolioTotals.avgHealth ?? sj.emDash}
-
- )}
+
+
+
+
{vh.totalBrandsLabel}
+ {portfolioLoading ? (
+
+ ) : (
+
{portfolioTotals.totalBrands.toLocaleString()}
+ )}
+
+
+
{vh.totalUrlsLabel}
+ {portfolioLoading ? (
+
+ ) : (
+
{portfolioTotals.totalUrls.toLocaleString()}
+ )}
+
+
+
{vh.avgHealthLabel}
+ {portfolioLoading ? (
+
+ ) : (
+
+ {portfolioTotals.avgHealth ?? sj.emDash}
+
+ )}
+
-
{deleteError ? (
@@ -299,201 +289,59 @@ export default function Home({ onNavigate }: ViewProps) {
{groupedPortfolio.map(({ rootDomain, items }) => {
const collapsed = collapsedGroups.has(rootDomain);
return (
-
- toggleGroupCollapsed(rootDomain)}
- aria-expanded={!collapsed}
- aria-controls={`portfolio-group-${rootDomain}`}
- className="flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2.5 text-left transition-colors hover:bg-brand-900/35"
- >
-
-
- {rootDomain}
-
-
-
- {format(vh.groupPropertyCount, { count: items.length })}
+
+ toggleGroupCollapsed(rootDomain)}
+ aria-expanded={!collapsed}
+ aria-controls={`portfolio-group-${rootDomain}`}
+ className="flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2.5 text-left transition-colors hover:bg-brand-900/35"
+ >
+
+
+ {rootDomain}
-
-
-
- {!collapsed ? (
-
- {items.map((group) => {
- const cardKey = portfolioCardKey(group);
- const confirmOpen = pendingDeleteKey === cardKey;
- const isDeleting = deletingKey === cardKey;
- return (
-
-
-
-
-
{ void openSite(group); }}
- className="min-w-0 flex-1 flex items-start justify-between gap-3 text-left rounded-md -m-1 p-1 hover:bg-brand-900/40 transition-colors disabled:opacity-60"
- >
-
-
-
- {vh.brandLabel}
-
-
{group.domainName}
- {group.crawlOnly ? (
-
- {vh.crawlOnlyBadge}
-
- ) : null}
-
-
-
{vh.healthScoreLabel}
-
-
-
{group.healthScore}
-
-
-
-
{
- e.stopPropagation();
- setDeleteError(null);
- setPendingDeleteKey(confirmOpen ? null : cardKey);
- }}
- className="shrink-0 rounded-md p-1.5 text-muted-foreground hover:text-red-700 hover:bg-red-500/10 dark:hover:text-red-400 transition-colors disabled:opacity-50"
- >
-
-
-
-
- {confirmOpen ? (
-
-
- {vh.deleteConfirmTitle}
-
-
- {group.crawlOnly
- ? format(vh.deleteConfirmCrawlOnly, {
- name: group.domainName,
- count: group.urlCount.toLocaleString(),
- })
- : format(vh.deleteConfirmBody, { name: group.domainName })}
-
-
- setPendingDeleteKey(null)}
- >
- {vh.deleteCancel}
-
- { void handleDeletePortfolioItem(group); }}
- >
- {isDeleting ? vh.deleting : vh.deleteConfirm}
-
-
-
- ) : null}
-
-
-
-
-
-
-
{vh.urlCountLabel}
-
{group.urlCount.toLocaleString()}
-
-
-
{vh.lastCrawlLabel}
-
{group.lastCrawl || sj.emDash}
-
-
-
-
-
{ void openSite(group); }}
- className="w-full rounded-md border border-default px-2 py-1.5 text-left hover:bg-brand-900/40 transition-colors disabled:opacity-60"
+
+
+ {format(vh.groupPropertyCount, { count: items.length })}
+
+
+
+
+ {!collapsed ? (
+
-
-
{vh.statusBreakdownLabel}
-
-
- {group.crawlOnly
- ? format(vh.viewUrlsCta, { count: group.urlCount })
- : vh.openBrandCta}
-
-
-
-
-
- 2xx {group.statusCounts.s2xx}
-
-
- 3xx {group.statusCounts.s3xx}
-
-
- 4xx {group.statusCounts.s4xx}
-
-
- 5xx {group.statusCounts.s5xx}
-
- {group.statusCounts.other > 0 && (
-
- {format(vh.otherStatusPill, { count: group.statusCounts.other })}
-
- )}
-
-
-
-
-
- );
- })}
-
- ) : null}
-
- );
+ {items.map((group) => {
+ const cardKey = portfolioCardKey(group);
+ return (
+
{ void openSite(group); }}
+ onDeleteToggle={() => {
+ setDeleteError(null);
+ setPendingDeleteKey(pendingDeleteKey === cardKey ? null : cardKey);
+ }}
+ onDeleteCancel={() => setPendingDeleteKey(null)}
+ onDeleteConfirm={() => { void handleDeletePortfolioItem(group); }}
+ />
+ );
+ })}
+
+ ) : null}
+
+ );
})}
) : (
diff --git a/web/src/views/Issues.tsx b/web/src/views/Issues.tsx
index d2510194..f5f41835 100644
--- a/web/src/views/Issues.tsx
+++ b/web/src/views/Issues.tsx
@@ -1,11 +1,12 @@
-import { useState, useMemo } from 'react';
+import { useState, useMemo, useEffect } from 'react';
import { Bar, Doughnut } from 'react-chartjs-2';
import type { TooltipItem } from 'chart.js';
-import { AlertTriangle, AlertCircle, Info, ChevronDown, ChevronRight, ExternalLink, Flame, BarChart2, ListChecks } from 'lucide-react';
+import { AlertTriangle, AlertCircle, Info, ExternalLink, Flame, BarChart2, ListChecks } from 'lucide-react';
import { useReport } from '../context/useReport';
import { useOptionalPipeline } from '../context/PipelineContext';
import { strings, format } from '../lib/strings';
-import { PageLayout, PageHeader, Card, Badge, ViewTabs } from '../components';
+import { PageLayout, PageHeader, Card, Badge, ViewTabs, ViewTabPanel, Button } from '../components';
+import { paginateSlice, PAGE_SIZE } from '@/components/google/tableUtils';
import UrlInspectorButton from '@/components/UrlInspectorButton';
import IssueTaskBoard from '@/components/issues/IssueTaskBoard';
import IssueAiFixButton from '@/components/issues/IssueAiFixButton';
@@ -38,85 +39,56 @@ interface CategoryIssueItem {
issue: ReportIssue;
}
-interface CategorySectionProps {
- category: string;
- items: CategoryIssueItem[];
- defaultOpen?: boolean;
+interface IssueCardProps {
+ item: CategoryIssueItem;
vi: (typeof strings.views)['issues'];
emDash: string;
}
-function CategorySection({ category, items, defaultOpen = false, vi, emDash }: CategorySectionProps) {
- const [open, setOpen] = useState(defaultOpen);
+function IssueCard({ item, vi, emDash }: IssueCardProps) {
+ const iss = item.issue;
+ const p = normalizePriority(iss.priority);
+ const cfg = PRIORITY_CONFIG[p];
+ const Icon = PRIORITY_ICONS[p];
return (
-
-
setOpen((v) => !v)}
- className="w-full flex items-center gap-3 py-3 px-4 bg-brand-800 border border-default rounded-xl hover:border-brand-700/80 transition-colors text-left"
- >
- {open ? (
-
- ) : (
-
- )}
- {categoryDisplayName(category)}
-
- {items.length} {items.length === 1 ? vi.issueWord : vi.issuesWord}
-
-
- {open && (
-
- {items.map((item, i) => {
- const iss = item.issue;
- const p = normalizePriority(iss.priority);
- const cfg = PRIORITY_CONFIG[p];
- const Icon = PRIORITY_ICONS[p];
- return (
-
-
-
-
-
- {categoryDisplayName(item.category)}
-
-
{iss.message || emDash}
- {iss.url && (
-
- )}
-
-
-
{vi.fixRecommendation}
-
- {iss.llm_recommendation || iss.recommendation || emDash}
-
- {iss.llm_recommendation && iss.recommendation && iss.llm_recommendation !== iss.recommendation ? (
-
- {vi.ruleRecommendation}:
- {iss.recommendation}
-
- ) : null}
-
-
-
- );
- })}
+
+
+
+
+
+ {categoryDisplayName(item.category)}
- )}
+
{iss.message || emDash}
+ {iss.url && (
+
+ )}
+
+
+
{vi.fixRecommendation}
+
+ {iss.llm_recommendation || iss.recommendation || emDash}
+
+ {iss.llm_recommendation && iss.recommendation && iss.llm_recommendation !== iss.recommendation ? (
+
+ {vi.ruleRecommendation}:
+ {iss.recommendation}
+
+ ) : null}
+
+
);
}
@@ -126,11 +98,13 @@ export default function Issues({ searchQuery = '' }: ViewProps) {
const pipeline = useOptionalPipeline();
const propertyId = Number(pipeline?.configState.active_property_id || 0) || null;
const vi = strings.views.issues;
+ const vlp = vi.pagination;
const sj = strings.common;
const priorityOrder = PRIORITY_ORDER;
const [issuesTab, setIssuesTab] = useState<'audit' | 'board'>('audit');
const [priorityFilter, setPriorityFilter] = useState(sj.all);
- const [categoryFilter, setCategoryFilter] = useState(sj.all);
+ const [activeCategory, setActiveCategory] = useState
(null);
+ const [issuePage, setIssuePage] = useState(1);
const clicksByUrl = useMemo(() => {
const map = new Map();
@@ -160,10 +134,7 @@ export default function Issues({ searchQuery = '' }: ViewProps) {
});
}, [data, q]);
- const forCharts = useMemo(() => {
- if (categoryFilter === sj.all) return list;
- return list.filter((item) => item.category === categoryFilter);
- }, [list, categoryFilter, sj.all]);
+ const forCharts = list;
const { categoryChartLabels, categoryChartValues } = useMemo(() => {
const m = new Map();
@@ -210,15 +181,11 @@ export default function Issues({ searchQuery = '' }: ViewProps) {
return acc;
}, {});
- const categories = [...new Set(list.map((item) => item.category))].filter(Boolean).sort();
let filtered = list;
if (priorityFilter !== sj.all) {
filtered = filtered.filter((item) => (item.issue.priority || 'Medium') === priorityFilter);
}
- if (categoryFilter !== sj.all) {
- filtered = filtered.filter((item) => item.category === categoryFilter);
- }
filtered.sort((a, b) => {
const aImpact = Number(a.issue.impact_score) || 0;
@@ -248,6 +215,36 @@ export default function Issues({ searchQuery = '' }: ViewProps) {
return acc;
}, {});
+ const categoryTabs = useMemo(
+ () =>
+ Object.entries(grouped)
+ .sort((a, b) => b[1].length - a[1].length)
+ .map(([cat, items]) => ({
+ id: cat,
+ label: categoryDisplayName(cat),
+ badge: items.length,
+ })),
+ [grouped],
+ );
+
+ const resolvedCategory =
+ activeCategory && grouped[activeCategory] ? activeCategory : categoryTabs[0]?.id ?? '';
+
+ const activeItems = grouped[resolvedCategory] || [];
+
+ const {
+ slice: visibleIssues,
+ page: safePage,
+ totalPages,
+ total: activeTotal,
+ from,
+ to,
+ } = useMemo(() => paginateSlice(activeItems, issuePage, PAGE_SIZE), [activeItems, issuePage]);
+
+ useEffect(() => {
+ setIssuePage(1);
+ }, [resolvedCategory, priorityFilter, q]);
+
const categoryBarOpts = useMemo(() => {
const base = barOptionsHorizontal();
return {
@@ -412,19 +409,6 @@ export default function Issues({ searchQuery = '' }: ViewProps) {
);
})}
-
- {categories.length > 1 && (
- setCategoryFilter(e.target.value)}
- className="ml-auto bg-brand-800 border border-default text-sm rounded-lg px-3 py-2 text-foreground outline-none hover:border-brand-700/80 transition-colors"
- >
- {vi.allCategories}
- {categories.map((cat) => (
- {cat}
- ))}
-
- )}
)}
@@ -434,17 +418,56 @@ export default function Issues({ searchQuery = '' }: ViewProps) {
{vi.noMatches}
) : (
-
- {Object.entries(grouped).map(([cat, items], idx) => (
-
+ {categoryTabs.length > 1 ? (
+ setActiveCategory(id)}
+ ariaLabel={vi.allCategories}
+ idPrefix="issues-category"
/>
- ))}
+ ) : null}
+
+ {visibleIssues.map((item, i) => (
+
+ ))}
+
+ {activeTotal > 0 ? (
+
+
+
{format(vlp.showingSlice, { from, to, total: activeTotal })}
+
+ {vlp.pageOf}{' '}
+ {safePage} {vlp.of}{' '}
+ {totalPages}
+
+ ({format(vlp.rowsPerPage, { n: PAGE_SIZE })})
+
+
+
+ {totalPages > 1 ? (
+
+ setIssuePage((p) => Math.max(1, p - 1))}
+ disabled={safePage <= 1}
+ className="px-3 py-1 text-foreground touch-manipulation min-h-11 sm:min-h-0"
+ >
+ {vlp.previous}
+
+ setIssuePage((p) => Math.min(totalPages, p + 1))}
+ disabled={safePage >= totalPages}
+ className="px-3 py-1 text-foreground touch-manipulation min-h-11 sm:min-h-0"
+ >
+ {vlp.next}
+
+
+ ) : null}
+
+ ) : null}
))}
diff --git a/web/src/views/JavaScriptErrors.tsx b/web/src/views/JavaScriptErrors.tsx
index 0f3c6be9..dbb855e8 100644
--- a/web/src/views/JavaScriptErrors.tsx
+++ b/web/src/views/JavaScriptErrors.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useMemo, useState, Fragment } from 'react';
+import { useMemo, useState, useEffect, Fragment } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { useUrlTab } from '@/hooks/useUrlTab';
@@ -8,6 +8,7 @@ import { Bug, ChevronDown, ChevronRight, ExternalLink, BarChart3, List } from 'l
import { useReport } from '../context/useReport';
import { strings, format } from '../lib/strings';
import { PageLayout, PageHeader, Card, Button, StatCard, Select, Table, TableHead, TableHeadCell, TableBody, TableRow, TableCell, ViewTabs, ViewTabPanel } from '../components';
+import { paginateSlice, PAGE_SIZE } from '@/components/google/tableUtils';
import type { ViewTabItem } from '../components';
import type { ViewProps } from '@/types';
import {
@@ -32,9 +33,11 @@ export default function JavaScriptErrors({ searchQuery = '' }: ViewProps) {
const trailingQuery = searchParams.toString() ? `?${searchParams.toString()}` : '';
const [typeFilter, setTypeFilter] = useState
('All');
const [expandedRow, setExpandedRow] = useState(null);
+ const [errorsPage, setErrorsPage] = useState(1);
const [activeTab, setActiveTab] = useUrlTab(JS_ERRORS_TABS, 'summary');
const vj = strings.views.javascriptErrors;
+ const vjp = vj.pagination;
const q = (searchQuery || '').toLowerCase().trim();
const scopeInfo = useMemo(() => getBrowserDiagnosticsScope(data), [data]);
@@ -65,6 +68,23 @@ export default function JavaScriptErrors({ searchQuery = '' }: ViewProps) {
});
}, [allRows, typeFilter, q]);
+ const {
+ slice: visibleRows,
+ page: safeErrorsPage,
+ totalPages: errorsTotalPages,
+ total: filteredRowsTotal,
+ from: errorsFrom,
+ to: errorsTo,
+ } = useMemo(
+ () => paginateSlice(filteredRows, errorsPage, PAGE_SIZE),
+ [filteredRows, errorsPage],
+ );
+
+ useEffect(() => {
+ setErrorsPage(1);
+ setExpandedRow(null);
+ }, [typeFilter, q]);
+
const tabItems = useMemo((): ViewTabItem[] => [
{
id: 'summary',
@@ -219,88 +239,131 @@ export default function JavaScriptErrors({ searchQuery = '' }: ViewProps) {
{filteredRows.length === 0 ? (
{vj.emptyFiltered}
) : (
-
-
-
-
-
- {vj.thUrl}
- {vj.thType}
- {vj.thMessage}
- {vj.thSource}
- {vj.thActions}
-
-
-
- {filteredRows.map((row) => {
- const expanded = expandedRow === row.id;
- const canExpand = row.type === 'exception' && Boolean(row.stack);
- return (
-
-
-
- {canExpand ? (
- setExpandedRow(expanded ? null : row.id)}
+ <>
+
+
+
+
+
+ {vj.thUrl}
+ {vj.thType}
+ {vj.thMessage}
+ {vj.thSource}
+ {vj.thActions}
+
+
+
+ {visibleRows.map((row) => {
+ const expanded = expandedRow === row.id;
+ const canExpand = row.type === 'exception' && Boolean(row.stack);
+ return (
+
+
+
+ {canExpand ? (
+ setExpandedRow(expanded ? null : row.id)}
+ >
+ {expanded ? (
+
+ ) : (
+
+ )}
+
+ ) : null}
+
+
+
- {expanded ? (
-
- ) : (
-
- )}
-
- ) : null}
-
-
-
- {row.url}
-
-
-
-
- {row.type === 'console' ? vj.typeConsole : vj.typeException}
-
-
-
-
-
- {formatBrowserErrorSource(row.source_url, row.line)}
-
-
-
- {vj.viewDetails}
-
-
-
- {expanded && row.stack ? (
-
-
-
- {row.stack}
-
-
-
- ) : null}
-
- );
- })}
-
-
-
+ {row.url}
+
+
+
+
+ {row.type === 'console' ? vj.typeConsole : vj.typeException}
+
+
+
+
+
+ {formatBrowserErrorSource(row.source_url, row.line)}
+
+
+
+ {vj.viewDetails}
+
+
+
+ {expanded && row.stack ? (
+
+
+
+ {row.stack}
+
+
+
+ ) : null}
+
+ );
+ })}
+
+
+
+ {filteredRowsTotal > 0 ? (
+
+
+
{format(vjp.showingSlice, { from: errorsFrom, to: errorsTo, total: filteredRowsTotal })}
+
+ {vjp.pageOf}{' '}
+ {safeErrorsPage} {vjp.of}{' '}
+ {errorsTotalPages}
+
+ ({format(vjp.rowsPerPage, { n: PAGE_SIZE })})
+
+
+
+ {errorsTotalPages > 1 ? (
+
+ {
+ setErrorsPage((p) => Math.max(1, p - 1));
+ setExpandedRow(null);
+ }}
+ disabled={safeErrorsPage <= 1}
+ className="px-3 py-1 text-foreground touch-manipulation min-h-11 sm:min-h-0"
+ >
+ {vjp.previous}
+
+ {
+ setErrorsPage((p) => Math.min(errorsTotalPages, p + 1));
+ setExpandedRow(null);
+ }}
+ disabled={safeErrorsPage >= errorsTotalPages}
+ className="px-3 py-1 text-foreground touch-manipulation min-h-11 sm:min-h-0"
+ >
+ {vjp.next}
+
+
+ ) : null}
+
+ ) : null}
+ >
)}
diff --git a/web/src/views/Lighthouse.tsx b/web/src/views/Lighthouse.tsx
index 9fb1cd32..4e715060 100644
--- a/web/src/views/Lighthouse.tsx
+++ b/web/src/views/Lighthouse.tsx
@@ -1,4 +1,4 @@
-import { useState, useMemo, useRef } from 'react';
+import { useState, useMemo, useRef, useEffect } from 'react';
import type {
LighthouseDiagnostic,
LighthouseFailure,
@@ -18,7 +18,8 @@ import {
} from '../lib/domainSlug';
import { goToPipeline } from '../lib/pipelineReturn';
import { strings, format } from '../lib/strings';
-import { PageLayout, PageHeader, Card, Button, ViewTabs, Select } from '../components';
+import { PageLayout, PageHeader, Card, Button, ViewTabs, ViewTabPanel, Select } from '../components';
+import { paginateSlice, PAGE_SIZE } from '@/components/google/tableUtils';
import type { ViewTabItem } from '../components';
import {
CATEGORIES, CATEGORY_LABELS, METRIC_THRESHOLDS, IMPACT_GROUPS, QUICK_WINS,
@@ -26,7 +27,7 @@ import {
import {
ScoreRing,
ThresholdBar,
- DiagnosticGroup,
+ DiagnosticItem,
QuickWinCard,
MultiPageTable,
LhAuditExpandable,
@@ -43,7 +44,10 @@ export default function Lighthouse({ searchQuery = '' }: ViewProps) {
const { data, startUrlByRunId } = useReport();
const searchParams = useSearchParams();
const detailRef = useRef(null);
+ const pageDetailRef = useRef(null);
const [activeTab, setActiveTab] = useUrlTab(LH_TABS, 'overview');
+ const [activeImpactGroup, setActiveImpactGroup] = useState(null);
+ const [diagnosticPage, setDiagnosticPage] = useState(1);
const expectedHost = useMemo(() => {
const fromPayload = canonicalDomainFromPayload(data, startUrlByRunId);
@@ -82,10 +86,14 @@ export default function Lighthouse({ searchQuery = '' }: ViewProps) {
const handleSelectUrl = (url: string) => {
setSelectedUrl(url);
- setActiveTab('overview');
- setTimeout(() => detailRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50);
+ setTimeout(() => pageDetailRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50);
};
+ const selectedPageSummary = useMemo(() => {
+ if (!selectedUrl || !byUrl[selectedUrl]) return null;
+ return byUrl[selectedUrl];
+ }, [selectedUrl, byUrl]);
+
const summary = useMemo(() => {
if (displayUrl && byUrl[displayUrl]) return byUrl[displayUrl];
const global = data?.lighthouse_summary;
@@ -194,6 +202,45 @@ export default function Lighthouse({ searchQuery = '' }: ViewProps) {
return maxId;
}, [groupedDiagnostics]);
+ const impactGroupTabs = useMemo(
+ () =>
+ IMPACT_GROUPS.map((group) => ({
+ group,
+ items: groupedDiagnostics[group.id] || [],
+ }))
+ .filter(({ items }) => items.length > 0)
+ .sort((a, b) => b.items.length - a.items.length)
+ .map(({ group, items }) => ({
+ id: group.id,
+ label: group.label,
+ badge: items.length,
+ })),
+ [groupedDiagnostics],
+ );
+
+ const resolvedImpactGroup =
+ activeImpactGroup && (groupedDiagnostics[activeImpactGroup]?.length ?? 0) > 0
+ ? activeImpactGroup
+ : impactGroupTabs.find((t) => t.id === mostCriticalGroup)?.id ?? impactGroupTabs[0]?.id ?? '';
+
+ const activeDiagnostics = groupedDiagnostics[resolvedImpactGroup] || [];
+
+ const {
+ slice: visibleDiagnostics,
+ page: safeDiagnosticPage,
+ totalPages: diagnosticTotalPages,
+ total: activeDiagnosticTotal,
+ from: diagnosticFrom,
+ to: diagnosticTo,
+ } = useMemo(
+ () => paginateSlice(activeDiagnostics, diagnosticPage, PAGE_SIZE),
+ [activeDiagnostics, diagnosticPage],
+ );
+
+ useEffect(() => {
+ setDiagnosticPage(1);
+ }, [resolvedImpactGroup, q]);
+
const quickWinStatus = useMemo(() => {
const allAuditIds = new Set(
diagnosticsList.map((d) => d.lighthouse_audit_id || d.id).filter(Boolean) as string[],
@@ -211,6 +258,7 @@ export default function Lighthouse({ searchQuery = '' }: ViewProps) {
);
const vlh = strings.views.lighthouse;
+ const vlp = vlh.pagination;
const tabLabels = vlh.tabs as Record;
const lhTabItems = useMemo((): ViewTabItem[] => {
@@ -269,7 +317,7 @@ export default function Lighthouse({ searchQuery = '' }: ViewProps) {
if (!hasData) {
return (
-
+
}
title={vlh.emptyTitle}
@@ -294,7 +342,7 @@ export default function Lighthouse({ searchQuery = '' }: ViewProps) {
}
return (
-
+
}
@@ -393,8 +441,32 @@ export default function Lighthouse({ searchQuery = '' }: ViewProps) {
{vlh.multiCompareHint}
-
+
+ {selectedPageSummary ? (
+
+
+
+ {vlh.categoriesSection}
+
+
+ {CATEGORIES.map(({ id, label }) => {
+ const pageCs = selectedPageSummary.category_scores || {};
+ const score = pageCs[id] != null ? Number(pageCs[id]) : null;
+ return ;
+ })}
+
+
+ {(selectedPageSummary.human_summary_full || selectedPageSummary.human_summary) ? (
+
+ {vlh.summary}
+
+ {selectedPageSummary.human_summary_full || selectedPageSummary.human_summary}
+
+
+ ) : null}
+
+ ) : null}
)}
@@ -449,19 +521,56 @@ export default function Lighthouse({ searchQuery = '' }: ViewProps) {
) : diagnosticsForGroups.length === 0 ? (
{vlh.noDiagnosticsSearch}
) : (
-
- {IMPACT_GROUPS.map((group) => {
- const items = groupedDiagnostics[group.id] || [];
- if (items.length === 0) return null;
- return (
-
- );
- })}
+
+ {impactGroupTabs.length > 1 ? (
+
setActiveImpactGroup(id)}
+ ariaLabel={vlh.diagnostics}
+ idPrefix="lh-diagnostics"
+ />
+ ) : null}
+
+ {visibleDiagnostics.map((d, i) => (
+
+ ))}
+
+ {activeDiagnosticTotal > 0 ? (
+
+
+
{format(vlp.showingSlice, { from: diagnosticFrom, to: diagnosticTo, total: activeDiagnosticTotal })}
+
+ {vlp.pageOf}{' '}
+ {safeDiagnosticPage} {vlp.of}{' '}
+ {diagnosticTotalPages}
+
+ ({format(vlp.rowsPerPage, { n: PAGE_SIZE })})
+
+
+
+ {diagnosticTotalPages > 1 ? (
+
+ setDiagnosticPage((p) => Math.max(1, p - 1))}
+ disabled={safeDiagnosticPage <= 1}
+ className="px-3 py-1 text-foreground touch-manipulation min-h-11 sm:min-h-0"
+ >
+ {vlp.previous}
+
+ setDiagnosticPage((p) => Math.min(diagnosticTotalPages, p + 1))}
+ disabled={safeDiagnosticPage >= diagnosticTotalPages}
+ className="px-3 py-1 text-foreground touch-manipulation min-h-11 sm:min-h-0"
+ >
+ {vlp.next}
+
+
+ ) : null}
+
+ ) : null}
)}
diff --git a/web/src/views/Links.tsx b/web/src/views/Links.tsx
index ec403441..fbca9033 100644
--- a/web/src/views/Links.tsx
+++ b/web/src/views/Links.tsx
@@ -38,6 +38,7 @@ import { exportLinksCsv } from '@/utils/linkExport';
import { useOptionalPipeline } from '../context/PipelineContext';
import AiSuggestionButton from '@/components/ai/AiSuggestionButton';
import { buildTechnicalLinkIssueContext } from '@/lib/fixSuggestionContext';
+import { crawledUrlCount } from '@/lib/crawlCounts';
const EXPLORER_TABS = ['urls', 'anchors'] as const;
type ExplorerTabId = (typeof EXPLORER_TABS)[number];
@@ -97,6 +98,7 @@ export default function Links({ searchQuery = '' }: ViewProps) {
const tableRef = useRef
(null);
const links = useMemo(() => data?.links || [], [data]);
+ const crawledCount = useMemo(() => crawledUrlCount(data), [data]);
const hasLinkAttributes = Boolean(
data?.link_rel_summary || (data?.inlink_anchor_matrix?.length ?? 0) > 0,
@@ -133,7 +135,7 @@ export default function Links({ searchQuery = '' }: ViewProps) {
id: 'urls',
label: vl.tabs.urls,
icon:
,
- badge: links.length > 0 ? links.length : null,
+ badge: crawledCount > 0 ? crawledCount : null,
},
];
if (hasLinkAttributes) {
@@ -145,7 +147,7 @@ export default function Links({ searchQuery = '' }: ViewProps) {
});
}
return items;
- }, [vl.tabs, links.length, hasLinkAttributes, data?.inlink_anchor_matrix?.length]);
+ }, [vl.tabs, crawledCount, hasLinkAttributes, data?.inlink_anchor_matrix?.length]);
const inspectParam = searchParams.get('inspect');
const tabParam = searchParams.get('tab');
diff --git a/web/src/views/Security.tsx b/web/src/views/Security.tsx
index 084ffe05..35984ee9 100644
--- a/web/src/views/Security.tsx
+++ b/web/src/views/Security.tsx
@@ -1,11 +1,12 @@
-import { useState, useMemo } from 'react';
+import { useState, useMemo, useEffect } from 'react';
import { useUrlTab } from '@/hooks/useUrlTab';
import { Bar, Doughnut } from 'react-chartjs-2';
import type { TooltipItem } from 'chart.js';
import { Shield, Flame, AlertTriangle, AlertCircle, Info, ExternalLink, BarChart3, List } from 'lucide-react';
import { useReport } from '../context/useReport';
import { strings, format } from '../lib/strings';
-import { PageLayout, PageHeader, Card, Badge, ViewTabs, ViewTabPanel } from '../components';
+import { PageLayout, PageHeader, Card, Badge, ViewTabs, ViewTabPanel, Button } from '../components';
+import { paginateSlice, PAGE_SIZE } from '@/components/google/tableUtils';
import type { ViewTabItem } from '../components';
import { palette } from '../utils/chartPalette';
import { registerChartJsBase, barOptionsHorizontal } from '../utils/chartJsDefaults';
@@ -97,6 +98,7 @@ export default function Security({ searchQuery = '' }: ViewProps) {
const { data } = useReport();
const [severityFilter, setSeverityFilter] = useState('All');
const [activeTab, setActiveTab] = useUrlTab(SECURITY_TABS, 'findings');
+ const [findingsPage, setFindingsPage] = useState(1);
const q = (searchQuery || '').toLowerCase().trim();
@@ -154,6 +156,44 @@ export default function Security({ searchQuery = '' }: ViewProps) {
}, []);
const vs = strings.views.security;
+ const vsp = vs.pagination;
+
+ const filteredFindings = useMemo(() => {
+ let list: SecurityFinding[] = allFindings;
+ if (severityFilter !== 'All') {
+ list = list.filter((f) => (f.severity || 'Info') === severityFilter);
+ }
+ if (q) {
+ list = list.filter((f) => {
+ const url = (f.url || '').toLowerCase();
+ const msg = (f.message || '').toLowerCase();
+ const rec = (f.recommendation || '').toLowerCase();
+ const typ = securityFindingLabel(f.finding_type).toLowerCase();
+ return url.includes(q) || msg.includes(q) || rec.includes(q) || typ.includes(q);
+ });
+ }
+ return [...list].sort((a, b) => {
+ const ao = (SEVERITY_CONFIG[(a.severity || 'Info') as SeverityKey] ?? SEVERITY_CONFIG.Info).order;
+ const bo = (SEVERITY_CONFIG[(b.severity || 'Info') as SeverityKey] ?? SEVERITY_CONFIG.Info).order;
+ return ao - bo;
+ });
+ }, [allFindings, severityFilter, q]);
+
+ const {
+ slice: visibleFindings,
+ page: safeFindingsPage,
+ totalPages: findingsTotalPages,
+ total: filteredFindingsTotal,
+ from: findingsFrom,
+ to: findingsTo,
+ } = useMemo(
+ () => paginateSlice(filteredFindings, findingsPage, PAGE_SIZE),
+ [filteredFindings, findingsPage],
+ );
+
+ useEffect(() => {
+ setFindingsPage(1);
+ }, [severityFilter, q]);
const tabItems = useMemo((): ViewTabItem[] => {
const chartCount = allFindings.length > 0 ? (typeLabels.length > 0 ? 2 : 1) : 0;
@@ -180,26 +220,6 @@ export default function Security({ searchQuery = '' }: ViewProps) {
return acc;
}, {});
- let findings: SecurityFinding[] = allFindings;
- if (severityFilter !== 'All') {
- findings = findings.filter((f) => (f.severity || 'Info') === severityFilter);
- }
- if (q) {
- findings = findings.filter((f) => {
- const url = (f.url || '').toLowerCase();
- const msg = (f.message || '').toLowerCase();
- const rec = (f.recommendation || '').toLowerCase();
- const typ = securityFindingLabel(f.finding_type).toLowerCase();
- return url.includes(q) || msg.includes(q) || rec.includes(q) || typ.includes(q);
- });
- }
-
- findings = [...findings].sort((a, b) => {
- const ao = (SEVERITY_CONFIG[(a.severity || 'Info') as SeverityKey] ?? SEVERITY_CONFIG.Info).order;
- const bo = (SEVERITY_CONFIG[(b.severity || 'Info') as SeverityKey] ?? SEVERITY_CONFIG.Info).order;
- return ao - bo;
- });
-
return (
)}
- {findings.length === 0 ? (
+ {filteredFindings.length === 0 ? (
@@ -318,49 +338,86 @@ export default function Security({ searchQuery = '' }: ViewProps) {
) : (
-
- {findings.map((f, i) => {
- const sev = (f.severity || 'Info') as SeverityKey;
- const cfg = SEVERITY_CONFIG[sev] ?? SEVERITY_CONFIG.Info;
- const Icon = cfg.icon;
- return (
-
-
-
-
-
+
+
+ {visibleFindings.map((f, i) => {
+ const sev = (f.severity || 'Info') as SeverityKey;
+ const cfg = SEVERITY_CONFIG[sev] ?? SEVERITY_CONFIG.Info;
+ const Icon = cfg.icon;
+ return (
+
+
+
+
+
+
+
+ {securityFindingLabel(f.finding_type)}
+
+ {f.url && (
+
+ {f.url}
+
+
+ )}
-
- {securityFindingLabel(f.finding_type)}
-
- {f.url && (
-
- {f.url}
-
-
+
{f.message || strings.common.emDash}
+ {f.recommendation && (
+
+
+ {vs.recommendation}
+
+ {f.recommendation}
+
)}
+
+
+ );
+ })}
+
+ {filteredFindingsTotal > 0 ? (
+
+
+
{format(vsp.showingSlice, { from: findingsFrom, to: findingsTo, total: filteredFindingsTotal })}
+
+ {vsp.pageOf}{' '}
+ {safeFindingsPage} {vsp.of}{' '}
+ {findingsTotalPages}
+
+ ({format(vsp.rowsPerPage, { n: PAGE_SIZE })})
+
-
{f.message || strings.common.emDash}
- {f.recommendation && (
-
-
- {vs.recommendation}
-
- {f.recommendation}
-
- )}
-
- );
- })}
+ {findingsTotalPages > 1 ? (
+
+ setFindingsPage((p) => Math.max(1, p - 1))}
+ disabled={safeFindingsPage <= 1}
+ className="px-3 py-1 text-foreground touch-manipulation min-h-11 sm:min-h-0"
+ >
+ {vsp.previous}
+
+ setFindingsPage((p) => Math.min(findingsTotalPages, p + 1))}
+ disabled={safeFindingsPage >= findingsTotalPages}
+ className="px-3 py-1 text-foreground touch-manipulation min-h-11 sm:min-h-0"
+ >
+ {vsp.next}
+
+
+ ) : null}
+
+ ) : null}
)}
diff --git a/web/src/views/TextContentAnalysis.tsx b/web/src/views/TextContentAnalysis.tsx
new file mode 100644
index 00000000..5d231508
--- /dev/null
+++ b/web/src/views/TextContentAnalysis.tsx
@@ -0,0 +1,757 @@
+'use client';
+
+import type { Chart, TooltipItem } from 'chart.js';
+import { Fragment, useState, useMemo, useEffect, type ComponentType, type ReactNode } from 'react';
+import { useUrlTab } from '@/hooks/useUrlTab';
+import type {
+ ContentAnalyticsData,
+ TextContentAnalysisData,
+ TextContentKeywordEntry,
+ TopicCluster,
+ ViewProps,
+} from '@/types';
+import { filterTopicClusters } from '@/lib/semanticTextHygiene';
+import { buildByPageTextRows } from '@/lib/textContentAnalysis';
+import { anyChartOptions } from '../utils/chartOptions';
+import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend } from 'chart.js';
+import { Bar } from 'react-chartjs-2';
+import {
+ BookOpen,
+ FileText,
+ BarChart2,
+ Tag,
+ Layers,
+ Sparkles,
+ ChevronDown,
+ ChevronRight,
+ LayoutDashboard,
+ Key,
+ AlignLeft,
+ Globe,
+} from 'lucide-react';
+import { useReport } from '../context/useReport';
+import { strings, format } from '../lib/strings';
+import {
+ PageLayout,
+ PageHeader,
+ Card,
+ Table,
+ TableHead,
+ TableHeadCell,
+ TableBody,
+ TableRow,
+ TableCell,
+ ViewTabs,
+ ViewTabPanel,
+ Button,
+} from '../components';
+import type { ViewTabItem } from '../components';
+import SortablePaginatedTable from '../components/google/SortablePaginatedTable';
+import { PAGE_SIZE, paginateSlice } from '../components/google/tableUtils';
+import { palette, PALETTE_CATEGORICAL } from '../utils/chartPalette';
+import {
+ getGridColor,
+ getChartTitleColor,
+ getChartCanvasTextColor,
+} from '../utils/chartJsDefaults';
+
+ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend);
+
+const TEXT_TABS = ['overview', 'keywords', 'analytics', 'topics'] as const;
+type TextTabId = (typeof TEXT_TABS)[number];
+
+const EMPTY_CA: ContentAnalyticsData = {};
+const EMPTY_TCA: TextContentAnalysisData = {};
+
+const barValueLabelsPlugin = {
+ id: 'tcaBarLabels',
+ afterDatasetsDraw(chart: Chart) {
+ const ctx = chart.ctx;
+ const isHorizontal = chart.options.indexAxis === 'y';
+ const pad = 6;
+ ctx.save();
+ ctx.font = '11px system-ui, sans-serif';
+ ctx.textBaseline = 'middle';
+ (chart.data.datasets || []).forEach((dataset, dsi: number) => {
+ const meta = chart.getDatasetMeta(dsi);
+ if (!meta?.data?.length || !dataset?.data) return;
+ meta.data.forEach((bar, i: number) => {
+ const value = dataset.data[i];
+ if (value == null || value === 0) return;
+ const label = Number(value).toLocaleString();
+ if (isHorizontal) {
+ const textWidth = ctx.measureText(label).width;
+ const fitsOutside = bar.x + pad + textWidth <= chart.chartArea.right;
+ if (fitsOutside) {
+ ctx.textAlign = 'left';
+ ctx.fillStyle = getChartCanvasTextColor();
+ ctx.fillText(label, bar.x + pad, bar.y);
+ } else {
+ ctx.textAlign = 'right';
+ ctx.fillStyle = '#ffffff';
+ ctx.fillText(label, bar.x - pad, bar.y);
+ }
+ } else {
+ ctx.textAlign = 'center';
+ ctx.fillStyle = getChartCanvasTextColor();
+ ctx.fillText(label, bar.x, bar.y - 12);
+ }
+ });
+ });
+ ctx.restore();
+ },
+};
+
+function barOpts(xTitle?: string) {
+ const pagesWord = strings.common.pages;
+ return anyChartOptions({
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: { legend: { display: false } },
+ scales: {
+ x: { grid: { color: getGridColor() }, ...(xTitle ? { title: { display: true, text: xTitle } } : {}) },
+ y: { grid: { color: getGridColor() }, beginAtZero: true, title: { display: true, text: pagesWord } },
+ },
+ });
+}
+
+function barOptsH(xTitle?: string) {
+ const freq = strings.charts.axisFrequency;
+ return anyChartOptions({
+ indexAxis: 'y',
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: { legend: { display: false } },
+ scales: {
+ x: {
+ grid: { color: getGridColor() },
+ beginAtZero: true,
+ grace: '10%',
+ title: { display: true, text: xTitle ?? freq },
+ },
+ y: { grid: { color: getGridColor() } },
+ },
+ });
+}
+
+function SectionHeader({
+ icon,
+ title,
+ description,
+}: {
+ icon: ComponentType<{ className?: string }>;
+ title: ReactNode;
+ description?: ReactNode;
+}) {
+ const Icon = icon;
+ return (
+
+
+
+
+
+
{title}
+ {description ?
{description}
: null}
+
+
+ );
+}
+
+function KeywordIndexTable({
+ rows,
+ vtca,
+ sj,
+}: {
+ rows: TextContentKeywordEntry[];
+ vtca: (typeof strings.views)['textContentAnalysis'];
+ sj: typeof strings.common;
+}) {
+ const [expanded, setExpanded] = useState
>(new Set());
+
+ const toggle = (word: string) => {
+ setExpanded((prev) => {
+ const next = new Set(prev);
+ if (next.has(word)) next.delete(word);
+ else next.add(word);
+ return next;
+ });
+ };
+
+ if (rows.length === 0) {
+ return {vtca.noKeywordData}
;
+ }
+
+ return (
+
+
+
+
+
+ {vtca.thWord}
+ {vtca.thTotalCount}
+ {vtca.thPageCount}
+
+
+
+ {rows.map((row) => {
+ const hasPages = (row.top_pages?.length ?? 0) > 0;
+ const isOpen = expanded.has(row.word);
+ return (
+
+
+
+ {hasPages ? (
+ toggle(row.word)}
+ className="text-muted-foreground hover:text-foreground"
+ aria-expanded={isOpen}
+ aria-label={isOpen ? vtca.collapsePages : vtca.expandPages}
+ >
+ {isOpen ? : }
+
+ ) : null}
+
+ {row.word}
+ {row.total_count.toLocaleString()}
+ {row.page_count.toLocaleString()}
+
+ {isOpen && hasPages ? (
+
+
+
+ {vtca.thTopPages}
+
+
+ {row.top_pages!.map((p) => (
+
+
+ {p.url}
+
+ {p.count}
+
+ ))}
+
+
+
+ ) : null}
+
+ );
+ })}
+
+
+
+ );
+}
+
+export default function TextContentAnalysis({ searchQuery = '' }: ViewProps) {
+ const vtca = strings.views.textContentAnalysis;
+ const sj = strings.common;
+ const ch = strings.charts;
+ const { data } = useReport();
+ const [activeTab, setActiveTab] = useUrlTab(TEXT_TABS, 'overview');
+ const [keywordsChartPage, setKeywordsChartPage] = useState(1);
+
+ const tca: TextContentAnalysisData = data?.text_content_analysis ?? EMPTY_TCA;
+ const ca: ContentAnalyticsData = data?.content_analytics ?? EMPTY_CA;
+ const vocab = tca.vocabulary_stats ?? {};
+ const wcStats = ca.word_count_stats ?? {};
+ const keywordIndex = tca.keyword_index ?? [];
+
+ const keywordIndexFiltered = useMemo(() => {
+ const q = (searchQuery || '').trim().toLowerCase();
+ if (!q) return keywordIndex;
+ return keywordIndex.filter(
+ (k) =>
+ k.word.toLowerCase().includes(q) ||
+ (k.top_pages ?? []).some((p) => p.url.toLowerCase().includes(q)),
+ );
+ }, [keywordIndex, searchQuery]);
+
+ const keywordsChartPagination = useMemo(
+ () => paginateSlice(keywordIndexFiltered, keywordsChartPage, PAGE_SIZE),
+ [keywordIndexFiltered, keywordsChartPage],
+ );
+
+ useEffect(() => {
+ setKeywordsChartPage(1);
+ }, [keywordIndexFiltered]);
+
+ useEffect(() => {
+ setKeywordsChartPage((p) => Math.min(Math.max(1, p), keywordsChartPagination.totalPages));
+ }, [keywordsChartPagination.totalPages]);
+
+ const keywordsChart = useMemo(() => {
+ const slice = keywordsChartPagination.slice;
+ if (slice.length === 0) return null;
+ return {
+ labels: slice.map((k) => k.word),
+ values: slice.map((k) => k.total_count),
+ };
+ }, [keywordsChartPagination.slice]);
+
+ const keywordsChartHeightPx = keywordsChart
+ ? Math.max(320, keywordsChart.labels.length * 28)
+ : 320;
+
+ const histChart = useMemo(() => {
+ const hist = tca.keyword_frequency_histogram;
+ if (!hist) return null;
+ const labels = [vtca.histBucket1, vtca.histBucket2, vtca.histBucket6, vtca.histBucket21];
+ const keys = ['1', '2-5', '6-20', '21+'];
+ const values = keys.map((k) => Number(hist[k]) || 0);
+ if (values.every((v) => v === 0)) return null;
+ return { labels, values };
+ }, [tca.keyword_frequency_histogram, vtca]);
+
+ const byPageRows = useMemo(
+ () => buildByPageTextRows(data?.links, searchQuery),
+ [data?.links, searchQuery],
+ );
+
+ const languageMlChart = useMemo(() => {
+ const c = data?.language_summary?.counts || {};
+ const entries = Object.entries(c)
+ .sort((a, b) => Number(b[1]) - Number(a[1]))
+ .slice(0, 15);
+ if (entries.length === 0) return null;
+ return { labels: entries.map((x) => x[0]), values: entries.map((x) => Number(x[1])) };
+ }, [data?.language_summary?.counts]);
+
+ const nerSiteChart = useMemo(() => {
+ const lc = data?.ner_site_summary?.label_counts;
+ if (!lc || typeof lc !== 'object') return null;
+ const entries = Object.entries(lc)
+ .sort((a, b) => Number(b[1]) - Number(a[1]))
+ .slice(0, 15);
+ if (entries.length === 0) return null;
+ return { labels: entries.map((x) => x[0]), values: entries.map((x) => Number(x[1])) };
+ }, [data?.ner_site_summary?.label_counts]);
+
+ const tokenClusters = useMemo(
+ () => filterTopicClusters(data?.keyword_opportunities?.token_topic_clusters ?? []),
+ [data?.keyword_opportunities?.token_topic_clusters],
+ );
+
+ const semanticClusters = useMemo(
+ () => filterTopicClusters(data?.semantic_keyword_clusters ?? []),
+ [data?.semantic_keyword_clusters],
+ );
+
+ const wcDist = ca.word_count_distribution ?? {};
+ const rlDist = ca.reading_level_distribution ?? {};
+ const crDist = ca.content_ratio_distribution ?? {};
+ const wcLabels = Object.keys(wcDist);
+ const wcValues = Object.values(wcDist).map(Number);
+ const rlLabels = Object.keys(rlDist);
+ const rlValues = Object.values(rlDist).map(Number);
+ const crLabels = Object.keys(crDist);
+ const crValues = Object.values(crDist).map(Number);
+
+ const wcPercLabels = vtca.wcPercLabels;
+ const wcPercRaw = [wcStats.min, wcStats.p25, wcStats.median, wcStats.mean, wcStats.p75, wcStats.max];
+ const wcPercValues = wcPercRaw.map((v) => (v != null && !Number.isNaN(Number(v)) ? Number(v) : null));
+ const hasWcPercBar = wcPercValues.every((v) => v != null) && (wcStats.max ?? 0) > 0;
+
+ const tabItems = useMemo((): ViewTabItem[] => [
+ { id: 'overview', label: vtca.tabs.overview, icon: },
+ { id: 'keywords', label: vtca.tabs.keywords, icon: },
+ { id: 'analytics', label: vtca.tabs.analytics, icon: },
+ { id: 'topics', label: vtca.tabs.topics, icon: },
+ ], [vtca.tabs]);
+
+ const byPageColumns = useMemo(
+ () => [
+ { key: 'url', label: vtca.thUrl },
+ { key: 'word_count', label: vtca.thWords },
+ { key: 'reading_level', label: vtca.thReading },
+ { key: 'top_terms', label: vtca.thTopTerms },
+ ],
+ [vtca],
+ );
+
+ if (!data) return null;
+
+ return (
+
+
+
+ setActiveTab(id as TextTabId)}
+ ariaLabel={vtca.title}
+ idPrefix="text-content-analysis"
+ />
+
+ {activeTab === 'overview' && (
+
+
+
+ {vtca.uniqueTerms}
+ {vocab.unique_terms ?? sj.emDash}
+
+
+ {vtca.pagesWithKeywords}
+ {vocab.pages_with_keywords ?? sj.emDash}
+
+
+
+ {vtca.meanWords}
+
+
+ {wcStats.mean != null ? Math.round(wcStats.mean).toLocaleString() : sj.emDash}
+
+ {vtca.perPage}
+
+
+
+ {vtca.medianWords}
+
+
+ {wcStats.median != null ? Math.round(wcStats.median).toLocaleString() : sj.emDash}
+
+ {vtca.perPage}
+
+
+
+
+
+ {vtca.avgTermsPerPage}
+ {vocab.avg_terms_per_page ?? sj.emDash}
+
+
+ {vtca.totalOccurrences}
+
+ {vocab.total_term_occurrences != null ? vocab.total_term_occurrences.toLocaleString() : sj.emDash}
+
+
+
+
+
+
+
+
+
+ )}
+
+ {activeTab === 'keywords' && (
+
+ {histChart ? (
+
+
+
+
{vtca.keywordFrequencyHist}
+
+
+
+
+
+ ) : null}
+
+
+
+
+
+ )}
+
+ {activeTab === 'analytics' && (
+
+ {keywordsChart ? (
+
+
+
+
+
{vtca.topKeywordsChart}
+
+
+ {keywordsChartPagination.total.toLocaleString()} terms
+
+
+
+
+
+ {keywordsChartPagination.total > 0 ? (
+
+
+
+ {format(vtca.pagination.showingSlice, {
+ from: keywordsChartPagination.from,
+ to: keywordsChartPagination.to,
+ total: keywordsChartPagination.total,
+ })}
+
+
+ {vtca.pagination.pageOf}{' '}
+ {keywordsChartPagination.page} {' '}
+ {vtca.pagination.of}{' '}
+ {keywordsChartPagination.totalPages}
+
+ ({format(vtca.pagination.rowsPerPage, { n: PAGE_SIZE })})
+
+
+
+ {keywordsChartPagination.totalPages > 1 ? (
+
+ setKeywordsChartPage((p) => Math.max(1, p - 1))}
+ disabled={keywordsChartPagination.page <= 1}
+ className="px-3 py-1 text-foreground touch-manipulation min-h-11 sm:min-h-0"
+ >
+ {vtca.pagination.previous}
+
+
+ setKeywordsChartPage((p) => Math.min(keywordsChartPagination.totalPages, p + 1))
+ }
+ disabled={keywordsChartPagination.page >= keywordsChartPagination.totalPages}
+ className="px-3 py-1 text-foreground touch-manipulation min-h-11 sm:min-h-0"
+ >
+ {vtca.pagination.next}
+
+
+ ) : null}
+
+ ) : null}
+
+ ) : (
+ {vtca.noKeywordData}
+ )}
+
+
+
+
+ {vtca.wordCountDist}
+
+ {wcLabels.length > 0 ? (
+
+ ) : (
+
{sj.noData}
+ )}
+
+
+
+
+ {vtca.readingLevelDist}
+
+ {rlLabels.length > 0 ? (
+
) => ` ${ctx.raw} pages` } },
+ },
+ }}
+ plugins={[barValueLabelsPlugin]}
+ />
+ ) : (
+ {sj.noData}
+ )}
+
+
+
+
+ {vtca.contentHtmlRatio}
+
+ {crLabels.length > 0 ? (
+
+ ) : (
+
{sj.noData}
+ )}
+
+
+
+ {hasWcPercBar ? (
+
+ {vtca.wordCountLadder}
+
+
+
+
+ ) : null}
+
+
+ )}
+
+ {activeTab === 'topics' && (
+
+ {languageMlChart ? (
+
+
+
+
{vtca.languageMix}
+
+
+
+
+
+ ) : null}
+
+ {nerSiteChart ? (
+
+
+
+
{vtca.entityLabels}
+
+
+
+
+
+ ) : null}
+
+ {tokenClusters.length > 0 ? (
+
+
+
+
{vtca.parentTopicsToken}
+
+
+
+
+
+ {vtca.thRepresentative}
+ {vtca.thClusterScore}
+ {vtca.thKeywords}
+
+
+
+ {tokenClusters.map((cl: TopicCluster, idx: number) => (
+
+
+ {String(cl.top_keyword ?? cl.representative ?? '')}
+
+
+ {String(cl.cluster_score ?? sj.emDash)}
+
+
+ {Array.isArray(cl.keywords) ? cl.keywords.join(', ') : sj.emDash}
+
+
+ ))}
+
+
+
+
+ ) : null}
+
+ {semanticClusters.length > 0 ? (
+
+
+
+
{vtca.parentTopicsSemantic}
+
+
+
+
+
+ {vtca.thRepresentative}
+ {vtca.thClusterScore}
+ {vtca.thKeywords}
+
+
+
+ {semanticClusters.map((cl: TopicCluster, idx: number) => (
+
+
+ {String(cl.top_keyword ?? cl.representative ?? '')}
+
+
+ {String(cl.cluster_score ?? sj.emDash)}
+
+
+ {Array.isArray(cl.keywords) ? cl.keywords.join(', ') : sj.emDash}
+
+
+ ))}
+
+
+
+
+ ) : null}
+
+ {!languageMlChart && !nerSiteChart && tokenClusters.length === 0 && semanticClusters.length === 0 ? (
+ {sj.noData}
+ ) : null}
+
+ )}
+
+ );
+}