← back to Open Seo
src/client/features/audit/results/PagesTable.tsx
253 lines
import { useMemo, useState } from "react";
import {
createColumnHelper,
type ColumnDef,
type SortingState,
} from "@tanstack/react-table";
import { ExternalLink } from "lucide-react";
import {
AppDataTable,
useAppTable,
} from "@/client/components/table/AppDataTable";
import { SortableHeader } from "@/client/components/table/SortableHeader";
import {
extractHostname,
extractPathname,
HttpStatusBadge,
} from "@/client/features/audit/shared";
import type { AuditResultsData } from "@/client/features/audit/results/types";
import {
countActiveFilters,
EmptyTableMessage,
PagesFilterBar,
TableFilterToggle,
} from "@/client/features/audit/results/AuditResultsTableFilters";
import {
EMPTY_PAGES_FILTERS,
filterPages,
nullableNumberSort,
nullableStringSort,
type PageRow,
type PagesFilters,
} from "@/client/features/audit/results/AuditResultsTableFilterLogic";
const pageColumnHelper = createColumnHelper<PageRow>();
/**
* Path shown in the URL/redirect cells. Redirect sources on another host
* (e.g. the apex domain 301ing to www) would otherwise render identically
* to their target, so include the host whenever it differs from the
* site's canonical host.
*/
function displayPath(url: string, canonicalHost: string): string {
const host = extractHostname(url);
const path = extractPathname(url);
return host === canonicalHost ? path : host + path;
}
/**
* The host most of the site's real (2xx) pages live on. The start URL's host
* is only a fallback: audits often start from the apex domain of a site that
* canonicalizes to www, and prefixing every row with the host is exactly the
* noise this display is meant to avoid.
*/
function predominantHost(pages: PageRow[], startUrl: string): string {
const counts = new Map<string, number>();
for (const page of pages) {
if (page.statusCode === null || page.statusCode >= 300) continue;
const host = extractHostname(page.url);
counts.set(host, (counts.get(host) ?? 0) + 1);
}
let best = extractHostname(startUrl);
let bestCount = 0;
for (const [host, count] of counts) {
if (count > bestCount) {
best = host;
bestCount = count;
}
}
return best;
}
function isRedirect(row: PageRow): boolean {
return (
row.statusCode !== null && row.statusCode >= 300 && row.statusCode < 400
);
}
/** Redirects and blocked/errored fetches have no analyzed content — their
* zero H1/word/image counts are an artifact, not a finding. */
function hasAnalyzedContent(row: PageRow): boolean {
return row.fetchClass === "ok" && !isRedirect(row);
}
const EmptyCell = () => <span className="text-xs text-base-content/40">-</span>;
function buildPagesColumns({
canonicalHost,
missingTitlePageIds,
}: {
canonicalHost: string;
missingTitlePageIds: Set<string>;
}): ColumnDef<PageRow>[] {
return [
pageColumnHelper.accessor("url", {
header: ({ column }) => <SortableHeader column={column} label="URL" />,
cell: ({ getValue }) => {
const url = getValue();
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="link link-primary inline-flex items-center gap-1 text-xs"
>
<span className="truncate">{displayPath(url, canonicalHost)}</span>
<ExternalLink className="size-3 shrink-0" />
</a>
);
},
meta: { cellClassName: "max-w-[240px] truncate" },
}),
pageColumnHelper.accessor("statusCode", {
header: ({ column }) => <SortableHeader column={column} label="Status" />,
cell: ({ getValue }) => <HttpStatusBadge code={getValue()} />,
sortingFn: nullableNumberSort,
}),
pageColumnHelper.accessor("title", {
header: ({ column }) => <SortableHeader column={column} label="Title" />,
cell: ({ getValue, row }) => {
if (isRedirect(row.original)) {
const target = row.original.redirectUrl;
return (
<span className="text-xs text-base-content/60">
→ {target ? displayPath(target, canonicalHost) : "redirect"}
</span>
);
}
const title = getValue();
if (title) {
return <span className="break-words">{title}</span>;
}
// Red only when the engine flagged it — a 200 that isn't an HTML
// document (robots.txt, security.txt) legitimately has no title.
return missingTitlePageIds.has(row.original.id) ? (
<span className="text-error text-xs">missing</span>
) : (
<EmptyCell />
);
},
sortingFn: nullableStringSort,
meta: { cellClassName: "max-w-[360px]" },
}),
pageColumnHelper.accessor("h1Count", {
header: ({ column }) => <SortableHeader column={column} label="H1" />,
cell: ({ getValue, row }) =>
hasAnalyzedContent(row.original) ? getValue() : <EmptyCell />,
}),
pageColumnHelper.accessor("wordCount", {
header: ({ column }) => <SortableHeader column={column} label="Words" />,
cell: ({ getValue, row }) =>
hasAnalyzedContent(row.original) ? getValue() : <EmptyCell />,
}),
pageColumnHelper.display({
id: "images",
header: ({ column }) => <SortableHeader column={column} label="Images" />,
cell: ({ row }) => {
if (!hasAnalyzedContent(row.original)) return <EmptyCell />;
return row.original.imagesMissingAlt > 0 ? (
<span className="text-warning">
{row.original.imagesMissingAlt}/{row.original.imagesTotal}
</span>
) : (
row.original.imagesTotal
);
},
enableSorting: true,
sortingFn: (left, right) =>
left.original.imagesMissingAlt - right.original.imagesMissingAlt ||
left.original.imagesTotal - right.original.imagesTotal,
}),
pageColumnHelper.accessor("responseTimeMs", {
header: ({ column }) => <SortableHeader column={column} label="Speed" />,
cell: ({ getValue }) => {
const value = getValue();
return value ? (
<span className="text-xs">{value}ms</span>
) : (
<EmptyCell />
);
},
sortingFn: nullableNumberSort,
}),
];
}
export function PagesTable({
pages,
startUrl,
issues,
}: {
pages: AuditResultsData["pages"];
startUrl: string;
issues: AuditResultsData["issues"];
}) {
const [filters, setFilters] = useState<PagesFilters>(EMPTY_PAGES_FILTERS);
const [showFilters, setShowFilters] = useState(false);
// URL order reads as a site inventory; status-first would open the table
// on its most boring rows (redirects) whenever a site has no errors.
const [sorting, setSorting] = useState<SortingState>([
{ id: "url", desc: false },
]);
const activeFilterCount = countActiveFilters(filters, EMPTY_PAGES_FILTERS);
const filteredPages = useMemo(
() => filterPages(pages, filters),
[filters, pages],
);
const columns = useMemo(
() =>
buildPagesColumns({
canonicalHost: predominantHost(pages, startUrl),
missingTitlePageIds: new Set(
issues
.filter((issue) => issue.issueType === "missing-title")
.map((issue) => issue.pageId)
.filter((pageId): pageId is string => pageId !== null),
),
}),
[issues, pages, startUrl],
);
const table = useAppTable({
data: filteredPages,
columns,
state: { sorting },
onSortingChange: setSorting,
withSorting: true,
});
return (
<div className="space-y-3">
<TableFilterToggle
showFilters={showFilters}
onToggle={() => setShowFilters((current) => !current)}
activeFilterCount={activeFilterCount}
resultCount={filteredPages.length}
totalCount={pages.length}
/>
{showFilters ? (
<PagesFilterBar
filters={filters}
onChange={setFilters}
activeFilterCount={activeFilterCount}
onReset={() => setFilters(EMPTY_PAGES_FILTERS)}
/>
) : null}
<AppDataTable
table={table}
className="table table-sm"
empty={<EmptyTableMessage label="No pages match these filters." />}
/>
</div>
);
}