feat: group backlinks table by domain with expandable rows (#88)

* feat: group backlinks table by domain with expandable rows

Group individual backlinks by source domain in the Backlinks tab,
showing a compact domain summary row that expands to reveal individual
backlink entries. Uses TanStack Table for expand/collapse state
management with table-fixed layout for stable column widths.

* fix: ci:check fixes for grouped backlinks table

Extract BacklinkChildRow to its own file to stay under max-lines lint
rule. Remove unused export of sortBacklinkRows. Format files with
prettier.

* refactor: simplify grouped backlinks table

* refactor: split backlinks page tables into focused files

Break the backlinks, referring domains, and top pages tables into dedicated components so the feature stays easier to navigate and no longer needs a max-lines ignore.

* fix: correct grouped backlink totals and remove target sorting

* fix: stabilize backlinks table column layout

Keep the DA header aligned with numeric cells and lock the backlinks table to fixed column widths so expanded rows with long anchor text do not shift the layout.

* refactor: inline backlinks table imports
This commit is contained in:
Ben Senescu 2026-04-07 11:37:42 -04:00 committed by Ben Senescu
parent 6f307b9cf6
commit 673459877e
14 changed files with 698 additions and 360 deletions

View File

@ -2,6 +2,7 @@
**/public **/public
pnpm-lock.yaml pnpm-lock.yaml
routeTree.gen.ts routeTree.gen.ts
.opencode/package.json
dist/ dist/
drizzle/ drizzle/

View File

@ -53,6 +53,7 @@
"@tanstack/react-router": "^1.136.3", "@tanstack/react-router": "^1.136.3",
"@tanstack/react-router-devtools": "^1.136.3", "@tanstack/react-router-devtools": "^1.136.3",
"@tanstack/react-start": "^1.136.3", "@tanstack/react-start": "^1.136.3",
"@tanstack/react-table": "^8.21.3",
"autumn-js": "^1.1.7", "autumn-js": "^1.1.7",
"better-auth": "^1.5.5", "better-auth": "^1.5.5",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",

22
pnpm-lock.yaml generated
View File

@ -29,6 +29,9 @@ importers:
'@tanstack/react-start': '@tanstack/react-start':
specifier: ^1.136.3 specifier: ^1.136.3
version: 1.162.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) version: 1.162.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
'@tanstack/react-table':
specifier: ^8.21.3
version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
autumn-js: autumn-js:
specifier: ^1.1.7 specifier: ^1.1.7
version: 1.1.7(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.162.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))(react@19.2.4) version: 1.1.7(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.162.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))(react@19.2.4)
@ -1930,6 +1933,13 @@ packages:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/react-table@8.21.3':
resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==}
engines: {node: '>=12'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
'@tanstack/router-core@1.162.2': '@tanstack/router-core@1.162.2':
resolution: {integrity: sha512-su/X6YP7LIhiy3FWkvCb5JL2LVmfupMXavvfyuPQayWb+E1NJQhYU8dbEcSGB0sNIMZeAmz0iIBSV2MVvuzQ8g==} resolution: {integrity: sha512-su/X6YP7LIhiy3FWkvCb5JL2LVmfupMXavvfyuPQayWb+E1NJQhYU8dbEcSGB0sNIMZeAmz0iIBSV2MVvuzQ8g==}
engines: {node: '>=20.19'} engines: {node: '>=20.19'}
@ -2001,6 +2011,10 @@ packages:
'@tanstack/store@0.9.1': '@tanstack/store@0.9.1':
resolution: {integrity: sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg==} resolution: {integrity: sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg==}
'@tanstack/table-core@8.21.3':
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'}
'@tanstack/virtual-file-routes@1.161.4': '@tanstack/virtual-file-routes@1.161.4':
resolution: {integrity: sha512-42WoRePf8v690qG8yGRe/YOh+oHni9vUaUUfoqlS91U2scd3a5rkLtVsc6b7z60w3RogH0I00vdrC5AaeiZ18w==} resolution: {integrity: sha512-42WoRePf8v690qG8yGRe/YOh+oHni9vUaUUfoqlS91U2scd3a5rkLtVsc6b7z60w3RogH0I00vdrC5AaeiZ18w==}
engines: {node: '>=20.19'} engines: {node: '>=20.19'}
@ -5040,6 +5054,12 @@ snapshots:
react-dom: 19.2.4(react@19.2.4) react-dom: 19.2.4(react@19.2.4)
use-sync-external-store: 1.6.0(react@19.2.4) use-sync-external-store: 1.6.0(react@19.2.4)
'@tanstack/react-table@8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@tanstack/table-core': 8.21.3
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@tanstack/router-core@1.162.2': '@tanstack/router-core@1.162.2':
dependencies: dependencies:
'@tanstack/history': 1.161.4 '@tanstack/history': 1.161.4
@ -5170,6 +5190,8 @@ snapshots:
'@tanstack/store@0.9.1': {} '@tanstack/store@0.9.1': {}
'@tanstack/table-core@8.21.3': {}
'@tanstack/virtual-file-routes@1.161.4': {} '@tanstack/virtual-file-routes@1.161.4': {}
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':

View File

@ -4,11 +4,9 @@ import {
BacklinksNewLostChart, BacklinksNewLostChart,
BacklinksTrendChart, BacklinksTrendChart,
} from "./BacklinksPageCharts"; } from "./BacklinksPageCharts";
import { import { BacklinksTable } from "./BacklinksTable";
BacklinksTable, import { ReferringDomainsTable } from "./ReferringDomainsTable";
ReferringDomainsTable, import { TopPagesTable } from "./TopPagesTable";
TopPagesTable,
} from "./BacklinksPageTables";
import type { import type {
BacklinksOverviewData, BacklinksOverviewData,
BacklinksSearchState, BacklinksSearchState,

View File

@ -1,232 +0,0 @@
import { useMemo, useState } from "react";
import { EmptyTableState } from "./BacklinksPageEmptyTableState";
import {
BacklinksTableHeader,
ReferringDomainsTableHeader,
TopPagesTableHeader,
} from "./BacklinksTableHeaders";
import {
BacklinksExternalLink,
BacklinksSourceLink,
} from "./BacklinksPageLinks";
import type { BacklinksOverviewData } from "./backlinksPageTypes";
import {
DEFAULT_BACKLINKS_SORT,
DEFAULT_REFERRING_DOMAINS_SORT,
DEFAULT_TOP_PAGES_SORT,
sortBacklinkRows,
sortReferringDomainRows,
sortTopPageRows,
} from "./backlinksTableSorting";
import {
formatCompactDate,
formatDecimal,
formatNumber,
} from "./backlinksPageUtils";
export function BacklinksTable({
rows,
}: {
rows: BacklinksOverviewData["backlinks"];
}) {
const [sort, setSort] = useState(DEFAULT_BACKLINKS_SORT);
const sortedRows = useMemo(() => sortBacklinkRows(rows, sort), [rows, sort]);
if (rows.length === 0) {
return <EmptyTableState label="No backlinks match this filter." />;
}
return (
<div className="overflow-x-auto">
<table className="table table-sm table-zebra">
<BacklinksTableHeader sort={sort} onSortChange={setSort} />
<tbody>
{sortedRows.map((row, index) => (
<BacklinksTableRow
key={`${row.urlFrom ?? row.domainFrom ?? "row"}-${index}`}
row={row}
/>
))}
</tbody>
</table>
</div>
);
}
export function ReferringDomainsTable({
rows,
}: {
rows: BacklinksOverviewData["referringDomains"];
}) {
const [sort, setSort] = useState(DEFAULT_REFERRING_DOMAINS_SORT);
const sortedRows = useMemo(
() => sortReferringDomainRows(rows, sort),
[rows, sort],
);
if (rows.length === 0) {
return <EmptyTableState label="No referring domains match this filter." />;
}
return (
<div className="overflow-x-auto">
<table className="table table-sm">
<ReferringDomainsTableHeader sort={sort} onSortChange={setSort} />
<tbody>
{sortedRows.map((row, index) => (
<tr key={`${row.domain ?? "domain"}-${index}`}>
<td className="font-medium break-all">{row.domain ?? "-"}</td>
<td>{formatNumber(row.backlinks)}</td>
<td>{formatNumber(row.referringPages)}</td>
<td>{formatNumber(row.rank)}</td>
<td>{formatDecimal(row.spamScore)}</td>
<td>{formatCompactDate(row.firstSeen)}</td>
<td>
<div className="text-sm">
<div>Broken links: {formatNumber(row.brokenBacklinks)}</div>
<div className="text-base-content/55">
Broken pages: {formatNumber(row.brokenPages)}
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export function TopPagesTable({
rows,
}: {
rows: BacklinksOverviewData["topPages"];
}) {
const [sort, setSort] = useState(DEFAULT_TOP_PAGES_SORT);
const sortedRows = useMemo(() => sortTopPageRows(rows, sort), [rows, sort]);
if (rows.length === 0) {
return <EmptyTableState label="No top pages match this filter." />;
}
return (
<div className="overflow-x-auto">
<table className="table table-sm">
<TopPagesTableHeader sort={sort} onSortChange={setSort} />
<tbody>
{sortedRows.map((row, index) => (
<tr key={`${row.page ?? "page"}-${index}`}>
<td className="min-w-80">
{row.page ? (
<BacklinksExternalLink
url={row.page}
label={row.page}
className="link link-hover break-all inline-flex items-center gap-1"
/>
) : (
"-"
)}
</td>
<td>{formatNumber(row.backlinks)}</td>
<td>{formatNumber(row.referringDomains)}</td>
<td>{formatNumber(row.rank)}</td>
<td>{formatNumber(row.brokenBacklinks)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function BacklinksTableRow({
row,
}: {
row: BacklinksOverviewData["backlinks"][number];
}) {
const hasNotableFlags =
row.isLost ||
row.isBroken ||
row.isDofollow === false ||
(row.linksCount != null && row.linksCount > 1);
return (
<tr>
<td className="min-w-52">
<div className="space-y-0.5">
<div className="font-medium">
{row.domainFrom?.replace(/^www\./, "") ?? "-"}
</div>
{row.urlFrom ? (
<BacklinksSourceLink url={row.urlFrom} maxLength={48} muted />
) : null}
</div>
</td>
<td className="min-w-44">
{row.urlTo ? (
<BacklinksSourceLink url={row.urlTo} maxLength={40} />
) : (
"-"
)}
</td>
<td className="min-w-36">
<div className="space-y-0.5">
<span className="text-sm">{row.anchor || "No anchor text"}</span>
{row.itemType ? (
<div className="text-xs text-base-content/55">{row.itemType}</div>
) : null}
</div>
</td>
<td>{hasNotableFlags ? <BacklinkFlags row={row} /> : null}</td>
<td className="text-right tabular-nums text-sm">
<span
title={
row.spamScore != null
? `Spam score: ${formatDecimal(row.spamScore)}`
: undefined
}
>
{formatNumber(row.rank)}
</span>
</td>
<td className="text-right tabular-nums text-sm">
{formatNumber(row.domainFromRank)}
</td>
<td className="whitespace-nowrap text-sm">
<div>{formatCompactDate(row.firstSeen)}</div>
{row.lastSeen ? (
<div className="text-xs text-base-content/55">
Last {formatCompactDate(row.lastSeen)}
</div>
) : null}
</td>
</tr>
);
}
function BacklinkFlags({
row,
}: {
row: BacklinksOverviewData["backlinks"][number];
}) {
return (
<div className="flex flex-wrap gap-1">
{row.isLost ? (
<span className="badge badge-sm badge-error badge-outline">Lost</span>
) : null}
{row.isBroken ? (
<span className="badge badge-sm badge-warning badge-outline">
Broken
</span>
) : null}
{row.isDofollow === false ? (
<span className="badge badge-sm badge-outline">Nofollow</span>
) : null}
{row.linksCount != null && row.linksCount > 1 ? (
<span className="badge badge-sm badge-outline inline-flex min-w-fit items-center whitespace-nowrap">
{row.linksCount} links
</span>
) : null}
</div>
);
}

View File

@ -0,0 +1,90 @@
import {
flexRender,
getCoreRowModel,
getExpandedRowModel,
getSortedRowModel,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import { useMemo, useState } from "react";
import { EmptyTableState } from "./BacklinksPageEmptyTableState";
import { backlinksColumns } from "./BacklinksTableColumns";
import type { BacklinksOverviewData } from "./backlinksPageTypes";
import { groupBacklinksByDomain } from "./backlinksPageUtils";
export function BacklinksTable({
rows,
}: {
rows: BacklinksOverviewData["backlinks"];
}) {
const [sorting, setSorting] = useState<SortingState>([
{ id: "firstSeen", desc: true },
]);
const groupedData = useMemo(() => groupBacklinksByDomain(rows), [rows]);
const table = useReactTable({
data: groupedData,
columns: backlinksColumns,
state: { sorting },
onSortingChange: setSorting,
getSubRows: (row) => row.subRows,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getRowCanExpand: (row) => row.depth === 0,
});
if (rows.length === 0) {
return <EmptyTableState label="No backlinks match this filter." />;
}
return (
<div className="overflow-x-auto">
<table className="table table-sm" style={{ tableLayout: "fixed" }}>
<colgroup>
{table.getVisibleLeafColumns().map((column) => (
<col key={column.id} style={{ width: column.getSize() }} />
))}
</colgroup>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} style={{ width: header.getSize() }}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr
key={row.id}
className={
row.depth === 0
? "cursor-pointer bg-base-200/50 transition-colors hover:bg-base-200/80"
: "bg-base-100"
}
onClick={
row.depth === 0 ? row.getToggleExpandedHandler() : undefined
}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@ -0,0 +1,332 @@
import type { ColumnDef } from "@tanstack/react-table";
import { ArrowDown, ArrowUp, ChevronRight } from "lucide-react";
import { HeaderHelpLabel } from "@/client/features/keywords/components";
import { BacklinksSourceLink } from "./BacklinksPageLinks";
import type { BacklinksRow, GroupedBacklinkDomain } from "./backlinksPageTypes";
import {
formatCompactDate,
formatDecimal,
formatNumber,
} from "./backlinksPageUtils";
function BacklinkFlags({ row }: { row: BacklinksRow }) {
return (
<div className="flex flex-wrap gap-1">
{row.isLost ? (
<span className="badge badge-sm badge-error badge-outline">Lost</span>
) : null}
{row.isBroken ? (
<span className="badge badge-sm badge-warning badge-outline">
Broken
</span>
) : null}
{row.isDofollow === false ? (
<span className="badge badge-sm badge-outline">Nofollow</span>
) : null}
{row.linksCount != null && row.linksCount > 1 ? (
<span className="badge badge-sm badge-outline inline-flex min-w-fit items-center whitespace-nowrap">
{row.linksCount} links
</span>
) : null}
</div>
);
}
function DomainFlagBadges({ group }: { group: GroupedBacklinkDomain }) {
const badges: Array<{ label: string; className: string }> = [];
if (group.lostCount > 0) {
badges.push({
label: `${group.lostCount} Lost`,
className: "badge badge-sm badge-error badge-outline",
});
}
if (group.brokenCount > 0) {
badges.push({
label: `${group.brokenCount} Broken`,
className: "badge badge-sm badge-warning badge-outline",
});
}
if (group.nofollowCount > 0) {
badges.push({
label: `${group.nofollowCount} Nofollow`,
className: "badge badge-sm badge-outline",
});
}
if (badges.length === 0) return null;
return (
<div className="flex gap-1">
{badges.map((badge) => (
<span key={badge.label} className={badge.className}>
{badge.label}
</span>
))}
</div>
);
}
function SortableHeader({
column,
label,
helpText,
align,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
getToggleSortingHandler: () => ((event: unknown) => void) | undefined;
};
label: string;
helpText: string;
align?: "left" | "right";
}) {
const sorted = column.getIsSorted();
const content = (
<button
type="button"
className="inline-flex items-center gap-1 font-medium transition-colors hover:text-base-content"
onClick={column.getToggleSortingHandler()}
aria-label={`Sort by ${label}`}
aria-pressed={!!sorted}
>
<HeaderHelpLabel label={label} helpText={helpText} />
{sorted === "asc" ? (
<ArrowUp className="size-3 shrink-0" />
) : sorted === "desc" ? (
<ArrowDown className="size-3 shrink-0" />
) : null}
</button>
);
if (align === "right") {
return <span className="flex w-full justify-end">{content}</span>;
}
return content;
}
export const backlinksColumns: ColumnDef<GroupedBacklinkDomain>[] = [
{
id: "source",
accessorKey: "domain",
header: ({ column }) => (
<SortableHeader
column={column}
label="Source"
helpText="Page or domain linking to you"
/>
),
size: 250,
minSize: 180,
cell: ({ row }) => {
if (row.depth > 0) {
const child = row.original._backlink;
return (
<div className="pl-6 break-all">
{child?.urlFrom ? (
<BacklinksSourceLink url={child.urlFrom} maxLength={48} muted />
) : (
<span className="text-base-content/55">-</span>
)}
</div>
);
}
const group = row.original;
return (
<div className="flex items-center gap-2">
<ChevronRight
className={`size-4 shrink-0 transition-transform ${row.getIsExpanded() ? "rotate-90" : ""}`}
/>
<div>
<div className="font-semibold">{group.domain}</div>
<div className="text-xs text-base-content/55">
{group.backlinkCount}{" "}
{group.backlinkCount === 1 ? "backlink" : "backlinks"} &middot;{" "}
{group.targetCount} {group.targetCount === 1 ? "page" : "pages"}
</div>
</div>
</div>
);
},
sortingFn: "alphanumeric",
},
{
id: "target",
header: () => (
<HeaderHelpLabel label="Target" helpText="Destination on your site" />
),
size: 220,
minSize: 150,
accessorFn: (row) => row.targetCount,
enableSorting: false,
cell: ({ row }) => {
if (row.depth > 0) {
const child = row.original._backlink;
return (
<div className="break-all">
{child?.urlTo ? (
<BacklinksSourceLink url={child.urlTo} maxLength={40} />
) : (
"-"
)}
</div>
);
}
return null;
},
sortingFn: "basic",
},
{
id: "anchor",
header: ({ column }) => (
<SortableHeader
column={column}
label="Anchor"
helpText="Text or format of the link"
/>
),
size: 150,
minSize: 100,
accessorFn: (row) => row._backlink?.anchor ?? row.domain,
cell: ({ row }) => {
if (row.depth > 0) {
const child = row.original._backlink;
return (
<div className="space-y-0.5 break-words">
<span className="text-sm">{child?.anchor || "No anchor text"}</span>
{child?.itemType ? (
<div className="text-xs text-base-content/55">
{child.itemType}
</div>
) : null}
</div>
);
}
return null;
},
sortingFn: "alphanumeric",
enableSorting: false,
},
{
id: "flags",
header: () => (
<HeaderHelpLabel
label="Flags"
helpText="Special backlink attributes, such as lost, broken, nofollow, or multiple links from the same source."
/>
),
size: 130,
minSize: 80,
enableSorting: false,
cell: ({ row }) => {
if (row.depth > 0) {
const child = row.original._backlink;
const hasFlags =
child?.isLost ||
child?.isBroken ||
child?.isDofollow === false ||
(child?.linksCount != null && child.linksCount > 1);
return hasFlags && child ? <BacklinkFlags row={child} /> : null;
}
return <DomainFlagBadges group={row.original} />;
},
},
{
id: "linkAuthority",
header: ({ column }) => (
<SortableHeader
column={column}
label="Link"
helpText="Authority of the linking page"
align="right"
/>
),
size: 70,
minSize: 50,
accessorFn: (row) => row._backlink?.rank ?? null,
cell: ({ row }) => {
if (row.depth > 0) {
const child = row.original._backlink;
return (
<div className="text-right tabular-nums text-sm">
<span
title={
child?.spamScore != null
? `Spam score: ${formatDecimal(child.spamScore)}`
: undefined
}
>
{formatNumber(child?.rank)}
</span>
</div>
);
}
return null;
},
sortingFn: "basic",
enableSorting: false,
},
{
id: "domainAuthority",
accessorKey: "domainAuthority",
header: ({ column }) => (
<SortableHeader
column={column}
label="DA"
helpText="Authority of the linking domain"
align="right"
/>
),
size: 70,
minSize: 50,
cell: ({ row }) => {
if (row.depth > 0) return null;
return (
<div className="text-right tabular-nums text-sm">
{formatNumber(row.original.domainAuthority)}
</div>
);
},
sortingFn: "basic",
},
{
id: "firstSeen",
accessorKey: "firstSeen",
header: ({ column }) => (
<SortableHeader
column={column}
label="First Seen"
helpText="When this link was first discovered by the crawler"
/>
),
size: 110,
minSize: 80,
cell: ({ row }) => {
if (row.depth > 0) {
const child = row.original._backlink;
return (
<div className="whitespace-nowrap text-sm">
<div>{formatCompactDate(child?.firstSeen)}</div>
{child?.lastSeen ? (
<div className="text-xs text-base-content/55">
Last {formatCompactDate(child.lastSeen)}
</div>
) : null}
</div>
);
}
return (
<div className="whitespace-nowrap text-sm">
{formatCompactDate(row.original.firstSeen)}
</div>
);
},
sortingFn: "datetime",
},
];

View File

@ -2,83 +2,11 @@ import { ArrowDown, ArrowUp } from "lucide-react";
import { HeaderHelpLabel } from "@/client/features/keywords/components"; import { HeaderHelpLabel } from "@/client/features/keywords/components";
import { import {
getNextSort, getNextSort,
type BacklinksTableSort,
type ReferringDomainsTableSort, type ReferringDomainsTableSort,
type SortDirection, type SortDirection,
type TopPagesTableSort, type TopPagesTableSort,
} from "./backlinksTableSorting"; } from "./backlinksTableSorting";
export function BacklinksTableHeader({
sort,
onSortChange,
}: {
sort: BacklinksTableSort;
onSortChange: (sort: BacklinksTableSort) => void;
}) {
return (
<thead>
<tr>
<SortableHeaderCell
label="Source"
helpText="Page or domain linking to you"
field="source"
defaultDirection="asc"
sort={sort}
onSortChange={onSortChange}
/>
<SortableHeaderCell
label="Target"
helpText="Destination on your site"
field="target"
defaultDirection="asc"
sort={sort}
onSortChange={onSortChange}
/>
<SortableHeaderCell
label="Anchor"
helpText="Text or format of the link"
field="anchor"
defaultDirection="asc"
sort={sort}
onSortChange={onSortChange}
/>
<th>
<HeaderHelpLabel
label="Flags"
helpText="Special backlink attributes, such as lost, broken, nofollow, or multiple links from the same source."
/>
</th>
<SortableHeaderCell
align="right"
label="Link"
helpText="Authority of the linking page"
field="linkAuthority"
defaultDirection="desc"
sort={sort}
onSortChange={onSortChange}
/>
<SortableHeaderCell
align="right"
label="DA"
helpText="Authority of the linking domain"
field="domainAuthority"
defaultDirection="desc"
sort={sort}
onSortChange={onSortChange}
/>
<SortableHeaderCell
label="First Seen"
helpText="When this link was first discovered by the crawler"
field="firstSeen"
defaultDirection="desc"
sort={sort}
onSortChange={onSortChange}
/>
</tr>
</thead>
);
}
export function ReferringDomainsTableHeader({ export function ReferringDomainsTableHeader({
sort, sort,
onSortChange, onSortChange,

View File

@ -0,0 +1,57 @@
import { useMemo, useState } from "react";
import { EmptyTableState } from "./BacklinksPageEmptyTableState";
import { ReferringDomainsTableHeader } from "./BacklinksTableHeaders";
import type { BacklinksOverviewData } from "./backlinksPageTypes";
import {
DEFAULT_REFERRING_DOMAINS_SORT,
sortReferringDomainRows,
} from "./backlinksTableSorting";
import {
formatCompactDate,
formatDecimal,
formatNumber,
} from "./backlinksPageUtils";
export function ReferringDomainsTable({
rows,
}: {
rows: BacklinksOverviewData["referringDomains"];
}) {
const [sort, setSort] = useState(DEFAULT_REFERRING_DOMAINS_SORT);
const sortedRows = useMemo(
() => sortReferringDomainRows(rows, sort),
[rows, sort],
);
if (rows.length === 0) {
return <EmptyTableState label="No referring domains match this filter." />;
}
return (
<div className="overflow-x-auto">
<table className="table table-sm">
<ReferringDomainsTableHeader sort={sort} onSortChange={setSort} />
<tbody>
{sortedRows.map((row, index) => (
<tr key={`${row.domain ?? "domain"}-${index}`}>
<td className="font-medium break-all">{row.domain ?? "-"}</td>
<td>{formatNumber(row.backlinks)}</td>
<td>{formatNumber(row.referringPages)}</td>
<td>{formatNumber(row.rank)}</td>
<td>{formatDecimal(row.spamScore)}</td>
<td>{formatCompactDate(row.firstSeen)}</td>
<td>
<div className="text-sm">
<div>Broken links: {formatNumber(row.brokenBacklinks)}</div>
<div className="text-base-content/55">
Broken pages: {formatNumber(row.brokenPages)}
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@ -0,0 +1,52 @@
import { useMemo, useState } from "react";
import { EmptyTableState } from "./BacklinksPageEmptyTableState";
import { BacklinksExternalLink } from "./BacklinksPageLinks";
import { TopPagesTableHeader } from "./BacklinksTableHeaders";
import type { BacklinksOverviewData } from "./backlinksPageTypes";
import {
DEFAULT_TOP_PAGES_SORT,
sortTopPageRows,
} from "./backlinksTableSorting";
import { formatNumber } from "./backlinksPageUtils";
export function TopPagesTable({
rows,
}: {
rows: BacklinksOverviewData["topPages"];
}) {
const [sort, setSort] = useState(DEFAULT_TOP_PAGES_SORT);
const sortedRows = useMemo(() => sortTopPageRows(rows, sort), [rows, sort]);
if (rows.length === 0) {
return <EmptyTableState label="No top pages match this filter." />;
}
return (
<div className="overflow-x-auto">
<table className="table table-sm">
<TopPagesTableHeader sort={sort} onSortChange={setSort} />
<tbody>
{sortedRows.map((row, index) => (
<tr key={`${row.page ?? "page"}-${index}`}>
<td className="min-w-80">
{row.page ? (
<BacklinksExternalLink
url={row.page}
label={row.page}
className="link link-hover break-all inline-flex items-center gap-1"
/>
) : (
"-"
)}
</td>
<td>{formatNumber(row.backlinks)}</td>
<td>{formatNumber(row.referringDomains)}</td>
<td>{formatNumber(row.rank)}</td>
<td>{formatNumber(row.brokenBacklinks)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@ -38,3 +38,21 @@ export type BacklinksPageProps = {
searchState: BacklinksSearchState; searchState: BacklinksSearchState;
navigate: BacklinksNavigate; navigate: BacklinksNavigate;
}; };
export type BacklinksRow = BacklinksOverviewData["backlinks"][number];
export type GroupedBacklinkDomain = {
domain: string;
domainAuthority: number | null;
spamScore: number | null;
firstSeen: string | null;
backlinkCount: number;
targetCount: number;
lostCount: number;
brokenCount: number;
nofollowCount: number;
/** Child rows for TanStack Table's getSubRows — each wraps a BacklinksRow */
subRows: GroupedBacklinkDomain[];
/** Set on child rows only — the original backlink data */
_backlink?: BacklinksRow;
};

View File

@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import type { BacklinksRow } from "./backlinksPageTypes";
import { groupBacklinksByDomain } from "./backlinksPageUtils";
function makeBacklinkRow(overrides: Partial<BacklinksRow> = {}): BacklinksRow {
return {
domainFrom: "source.example",
urlFrom: "https://source.example/post",
urlTo: "https://target.example/",
anchor: null,
itemType: null,
isDofollow: true,
relAttributes: [],
rank: null,
domainFromRank: null,
pageFromRank: null,
spamScore: null,
firstSeen: null,
lastSeen: null,
isLost: false,
isBroken: false,
linksCount: null,
...overrides,
};
}
describe("groupBacklinksByDomain", () => {
it("sums grouped backlink totals from linksCount", () => {
const groups = groupBacklinksByDomain([
makeBacklinkRow({ linksCount: 5 }),
makeBacklinkRow({
urlFrom: "https://source.example/second-post",
urlTo: "https://target.example/pricing",
}),
]);
expect(groups).toHaveLength(1);
expect(groups[0]?.backlinkCount).toBe(6);
});
it("falls back to one backlink when linksCount is missing", () => {
const groups = groupBacklinksByDomain([
makeBacklinkRow({ linksCount: null }),
makeBacklinkRow({
domainFrom: "other.example",
urlFrom: "https://other.example/post",
linksCount: 0,
}),
]);
expect(groups.map((group) => group.backlinkCount)).toEqual([1, 1]);
});
});

View File

@ -1,5 +1,9 @@
import type { BacklinksTab } from "@/types/schemas/backlinks"; import type { BacklinksTab } from "@/types/schemas/backlinks";
import type { BacklinksOverviewData } from "./backlinksPageTypes"; import type {
BacklinksOverviewData,
BacklinksRow,
GroupedBacklinkDomain,
} from "./backlinksPageTypes";
export const TAB_DESCRIPTIONS: Record<BacklinksTab, string> = { export const TAB_DESCRIPTIONS: Record<BacklinksTab, string> = {
backlinks: backlinks:
@ -115,6 +119,70 @@ export function formatRelativeTimestamp(value: string) {
}); });
} }
export function groupBacklinksByDomain(
rows: BacklinksRow[],
): GroupedBacklinkDomain[] {
const groups = new Map<string, BacklinksRow[]>();
for (const row of rows) {
const key = row.domainFrom?.replace(/^www\./, "") ?? "unknown";
const existing = groups.get(key);
if (existing) {
existing.push(row);
} else {
groups.set(key, [row]);
}
}
return Array.from(groups.entries()).map(([domain, children]) => ({
domain,
domainAuthority: maxNullable(children.map((r) => r.domainFromRank)),
spamScore: maxNullable(children.map((r) => r.spamScore)),
firstSeen: minDateString(children.map((r) => r.firstSeen)),
backlinkCount: children.reduce(
(total, child) => total + getBacklinkCount(child),
0,
),
targetCount: new Set(children.map((r) => r.urlTo).filter(Boolean)).size,
lostCount: children.filter((r) => r.isLost).length,
brokenCount: children.filter((r) => r.isBroken).length,
nofollowCount: children.filter((r) => r.isDofollow === false).length,
subRows: children.map((child) => ({
domain: child.domainFrom?.replace(/^www\./, "") ?? "unknown",
domainAuthority: child.domainFromRank,
spamScore: child.spamScore,
firstSeen: child.firstSeen,
backlinkCount: 1,
targetCount: 1,
lostCount: child.isLost ? 1 : 0,
brokenCount: child.isBroken ? 1 : 0,
nofollowCount: child.isDofollow === false ? 1 : 0,
subRows: [],
_backlink: child,
})),
}));
}
function getBacklinkCount(row: BacklinksRow) {
return row.linksCount != null && row.linksCount > 0 ? row.linksCount : 1;
}
function maxNullable(values: (number | null)[]): number | null {
let result: number | null = null;
for (const v of values) {
if (v != null && (result == null || v > result)) result = v;
}
return result;
}
function minDateString(values: (string | null)[]): string | null {
let result: string | null = null;
for (const v of values) {
if (v && (result == null || v < result)) result = v;
}
return result;
}
export function extractUrlPath(url: string) { export function extractUrlPath(url: string) {
try { try {
const parsed = new URL(url); const parsed = new URL(url);

View File

@ -2,14 +2,6 @@ import type { BacklinksOverviewData } from "./backlinksPageTypes";
export type SortDirection = "asc" | "desc"; export type SortDirection = "asc" | "desc";
export type BacklinksTableSortField =
| "source"
| "target"
| "anchor"
| "linkAuthority"
| "domainAuthority"
| "firstSeen";
export type ReferringDomainsTableSortField = export type ReferringDomainsTableSortField =
| "domain" | "domain"
| "backlinks" | "backlinks"
@ -31,16 +23,10 @@ export type TableSort<TField extends string> = {
direction: SortDirection; direction: SortDirection;
}; };
export type BacklinksTableSort = TableSort<BacklinksTableSortField>;
export type ReferringDomainsTableSort = export type ReferringDomainsTableSort =
TableSort<ReferringDomainsTableSortField>; TableSort<ReferringDomainsTableSortField>;
export type TopPagesTableSort = TableSort<TopPagesTableSortField>; export type TopPagesTableSort = TableSort<TopPagesTableSortField>;
export const DEFAULT_BACKLINKS_SORT: BacklinksTableSort = {
field: "firstSeen",
direction: "desc",
};
export const DEFAULT_REFERRING_DOMAINS_SORT: ReferringDomainsTableSort = { export const DEFAULT_REFERRING_DOMAINS_SORT: ReferringDomainsTableSort = {
field: "backlinks", field: "backlinks",
direction: "desc", direction: "desc",
@ -66,42 +52,6 @@ export function getNextSort<TField extends string>(
}; };
} }
export function sortBacklinkRows(
rows: BacklinksOverviewData["backlinks"],
sort: BacklinksTableSort,
) {
return rows.toSorted((left, right) => {
switch (sort.field) {
case "source":
return compareStrings(
left.domainFrom?.replace(/^www\./, "") ?? left.urlFrom,
right.domainFrom?.replace(/^www\./, "") ?? right.urlFrom,
sort.direction,
);
case "target":
return compareStrings(left.urlTo, right.urlTo, sort.direction);
case "anchor":
return compareStrings(
left.anchor ?? left.itemType,
right.anchor ?? right.itemType,
sort.direction,
);
case "linkAuthority":
return compareNumbers(left.rank, right.rank, sort.direction);
case "domainAuthority":
return compareNumbers(
left.domainFromRank,
right.domainFromRank,
sort.direction,
);
case "firstSeen":
return compareDates(left.firstSeen, right.firstSeen, sort.direction);
default:
return 0;
}
});
}
export function sortReferringDomainRows( export function sortReferringDomainRows(
rows: BacklinksOverviewData["referringDomains"], rows: BacklinksOverviewData["referringDomains"],
sort: ReferringDomainsTableSort, sort: ReferringDomainsTableSort,