From 673459877ed1fb2154066fbd873eeb0673a1b6bd Mon Sep 17 00:00:00 2001
From: Ben Senescu <44480372+bensenescu@users.noreply.github.com>
Date: Tue, 7 Apr 2026 11:37:42 -0400
Subject: [PATCH] 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
---
.prettierignore | 1 +
package.json | 1 +
pnpm-lock.yaml | 22 ++
.../backlinks/BacklinksPageSections.tsx | 8 +-
.../backlinks/BacklinksPageTables.tsx | 232 ------------
.../features/backlinks/BacklinksTable.tsx | 90 +++++
.../backlinks/BacklinksTableColumns.tsx | 332 ++++++++++++++++++
.../backlinks/BacklinksTableHeaders.tsx | 72 ----
.../backlinks/ReferringDomainsTable.tsx | 57 +++
.../features/backlinks/TopPagesTable.tsx | 52 +++
.../features/backlinks/backlinksPageTypes.ts | 18 +
.../backlinks/backlinksPageUtils.test.ts | 53 +++
.../features/backlinks/backlinksPageUtils.ts | 70 +++-
.../backlinks/backlinksTableSorting.ts | 50 ---
14 files changed, 698 insertions(+), 360 deletions(-)
delete mode 100644 src/client/features/backlinks/BacklinksPageTables.tsx
create mode 100644 src/client/features/backlinks/BacklinksTable.tsx
create mode 100644 src/client/features/backlinks/BacklinksTableColumns.tsx
create mode 100644 src/client/features/backlinks/ReferringDomainsTable.tsx
create mode 100644 src/client/features/backlinks/TopPagesTable.tsx
create mode 100644 src/client/features/backlinks/backlinksPageUtils.test.ts
diff --git a/.prettierignore b/.prettierignore
index 30d0c09..23020aa 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -2,6 +2,7 @@
**/public
pnpm-lock.yaml
routeTree.gen.ts
+.opencode/package.json
dist/
drizzle/
diff --git a/package.json b/package.json
index a363a35..ce671f1 100644
--- a/package.json
+++ b/package.json
@@ -53,6 +53,7 @@
"@tanstack/react-router": "^1.136.3",
"@tanstack/react-router-devtools": "^1.136.3",
"@tanstack/react-start": "^1.136.3",
+ "@tanstack/react-table": "^8.21.3",
"autumn-js": "^1.1.7",
"better-auth": "^1.5.5",
"cheerio": "^1.2.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d5b8c54..f9fd60b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -29,6 +29,9 @@ importers:
'@tanstack/react-start':
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))
+ '@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:
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)
@@ -1930,6 +1933,13 @@ packages:
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
+ '@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':
resolution: {integrity: sha512-su/X6YP7LIhiy3FWkvCb5JL2LVmfupMXavvfyuPQayWb+E1NJQhYU8dbEcSGB0sNIMZeAmz0iIBSV2MVvuzQ8g==}
engines: {node: '>=20.19'}
@@ -2001,6 +2011,10 @@ packages:
'@tanstack/store@0.9.1':
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':
resolution: {integrity: sha512-42WoRePf8v690qG8yGRe/YOh+oHni9vUaUUfoqlS91U2scd3a5rkLtVsc6b7z60w3RogH0I00vdrC5AaeiZ18w==}
engines: {node: '>=20.19'}
@@ -5040,6 +5054,12 @@ snapshots:
react-dom: 19.2.4(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':
dependencies:
'@tanstack/history': 1.161.4
@@ -5170,6 +5190,8 @@ snapshots:
'@tanstack/store@0.9.1': {}
+ '@tanstack/table-core@8.21.3': {}
+
'@tanstack/virtual-file-routes@1.161.4': {}
'@tybys/wasm-util@0.10.1':
diff --git a/src/client/features/backlinks/BacklinksPageSections.tsx b/src/client/features/backlinks/BacklinksPageSections.tsx
index 528a430..2f66094 100644
--- a/src/client/features/backlinks/BacklinksPageSections.tsx
+++ b/src/client/features/backlinks/BacklinksPageSections.tsx
@@ -4,11 +4,9 @@ import {
BacklinksNewLostChart,
BacklinksTrendChart,
} from "./BacklinksPageCharts";
-import {
- BacklinksTable,
- ReferringDomainsTable,
- TopPagesTable,
-} from "./BacklinksPageTables";
+import { BacklinksTable } from "./BacklinksTable";
+import { ReferringDomainsTable } from "./ReferringDomainsTable";
+import { TopPagesTable } from "./TopPagesTable";
import type {
BacklinksOverviewData,
BacklinksSearchState,
diff --git a/src/client/features/backlinks/BacklinksPageTables.tsx b/src/client/features/backlinks/BacklinksPageTables.tsx
deleted file mode 100644
index 26b11bd..0000000
--- a/src/client/features/backlinks/BacklinksPageTables.tsx
+++ /dev/null
@@ -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 ;
- }
-
- return (
-
-
-
-
- {sortedRows.map((row, index) => (
-
- ))}
-
-
-
- );
-}
-
-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 ;
- }
-
- return (
-
-
-
-
- {sortedRows.map((row, index) => (
-
- | {row.domain ?? "-"} |
- {formatNumber(row.backlinks)} |
- {formatNumber(row.referringPages)} |
- {formatNumber(row.rank)} |
- {formatDecimal(row.spamScore)} |
- {formatCompactDate(row.firstSeen)} |
-
-
- Broken links: {formatNumber(row.brokenBacklinks)}
-
- Broken pages: {formatNumber(row.brokenPages)}
-
-
- |
-
- ))}
-
-
-
- );
-}
-
-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 ;
- }
-
- return (
-
-
-
-
- {sortedRows.map((row, index) => (
-
- |
- {row.page ? (
-
- ) : (
- "-"
- )}
- |
- {formatNumber(row.backlinks)} |
- {formatNumber(row.referringDomains)} |
- {formatNumber(row.rank)} |
- {formatNumber(row.brokenBacklinks)} |
-
- ))}
-
-
-
- );
-}
-
-function BacklinksTableRow({
- row,
-}: {
- row: BacklinksOverviewData["backlinks"][number];
-}) {
- const hasNotableFlags =
- row.isLost ||
- row.isBroken ||
- row.isDofollow === false ||
- (row.linksCount != null && row.linksCount > 1);
-
- return (
-
-
-
-
- {row.domainFrom?.replace(/^www\./, "") ?? "-"}
-
- {row.urlFrom ? (
-
- ) : null}
-
- |
-
- {row.urlTo ? (
-
- ) : (
- "-"
- )}
- |
-
-
- {row.anchor || "No anchor text"}
- {row.itemType ? (
- {row.itemType}
- ) : null}
-
- |
- {hasNotableFlags ? : null} |
-
-
- {formatNumber(row.rank)}
-
- |
-
- {formatNumber(row.domainFromRank)}
- |
-
- {formatCompactDate(row.firstSeen)}
- {row.lastSeen ? (
-
- Last {formatCompactDate(row.lastSeen)}
-
- ) : null}
- |
-
- );
-}
-
-function BacklinkFlags({
- row,
-}: {
- row: BacklinksOverviewData["backlinks"][number];
-}) {
- return (
-
- {row.isLost ? (
- Lost
- ) : null}
- {row.isBroken ? (
-
- Broken
-
- ) : null}
- {row.isDofollow === false ? (
- Nofollow
- ) : null}
- {row.linksCount != null && row.linksCount > 1 ? (
-
- {row.linksCount} links
-
- ) : null}
-
- );
-}
diff --git a/src/client/features/backlinks/BacklinksTable.tsx b/src/client/features/backlinks/BacklinksTable.tsx
new file mode 100644
index 0000000..23bf37d
--- /dev/null
+++ b/src/client/features/backlinks/BacklinksTable.tsx
@@ -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([
+ { 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 ;
+ }
+
+ return (
+
+
+
+ {table.getVisibleLeafColumns().map((column) => (
+
+ ))}
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => (
+ |
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext(),
+ )}
+ |
+ ))}
+
+ ))}
+
+
+ {table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+ |
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+ |
+ ))}
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/client/features/backlinks/BacklinksTableColumns.tsx b/src/client/features/backlinks/BacklinksTableColumns.tsx
new file mode 100644
index 0000000..8981259
--- /dev/null
+++ b/src/client/features/backlinks/BacklinksTableColumns.tsx
@@ -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 (
+
+ {row.isLost ? (
+ Lost
+ ) : null}
+ {row.isBroken ? (
+
+ Broken
+
+ ) : null}
+ {row.isDofollow === false ? (
+ Nofollow
+ ) : null}
+ {row.linksCount != null && row.linksCount > 1 ? (
+
+ {row.linksCount} links
+
+ ) : null}
+
+ );
+}
+
+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 (
+
+ {badges.map((badge) => (
+
+ {badge.label}
+
+ ))}
+
+ );
+}
+
+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 = (
+
+ );
+
+ if (align === "right") {
+ return {content};
+ }
+
+ return content;
+}
+
+export const backlinksColumns: ColumnDef[] = [
+ {
+ id: "source",
+ accessorKey: "domain",
+ header: ({ column }) => (
+
+ ),
+ size: 250,
+ minSize: 180,
+ cell: ({ row }) => {
+ if (row.depth > 0) {
+ const child = row.original._backlink;
+ return (
+
+ {child?.urlFrom ? (
+
+ ) : (
+ -
+ )}
+
+ );
+ }
+
+ const group = row.original;
+ return (
+
+
+
+
{group.domain}
+
+ {group.backlinkCount}{" "}
+ {group.backlinkCount === 1 ? "backlink" : "backlinks"} ·{" "}
+ {group.targetCount} {group.targetCount === 1 ? "page" : "pages"}
+
+
+
+ );
+ },
+ sortingFn: "alphanumeric",
+ },
+ {
+ id: "target",
+ header: () => (
+
+ ),
+ size: 220,
+ minSize: 150,
+ accessorFn: (row) => row.targetCount,
+ enableSorting: false,
+ cell: ({ row }) => {
+ if (row.depth > 0) {
+ const child = row.original._backlink;
+ return (
+
+ {child?.urlTo ? (
+
+ ) : (
+ "-"
+ )}
+
+ );
+ }
+
+ return null;
+ },
+ sortingFn: "basic",
+ },
+ {
+ id: "anchor",
+ header: ({ column }) => (
+
+ ),
+ size: 150,
+ minSize: 100,
+ accessorFn: (row) => row._backlink?.anchor ?? row.domain,
+ cell: ({ row }) => {
+ if (row.depth > 0) {
+ const child = row.original._backlink;
+ return (
+
+
{child?.anchor || "No anchor text"}
+ {child?.itemType ? (
+
+ {child.itemType}
+
+ ) : null}
+
+ );
+ }
+
+ return null;
+ },
+ sortingFn: "alphanumeric",
+ enableSorting: false,
+ },
+ {
+ id: "flags",
+ header: () => (
+
+ ),
+ 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 ? : null;
+ }
+
+ return ;
+ },
+ },
+ {
+ id: "linkAuthority",
+ header: ({ column }) => (
+
+ ),
+ size: 70,
+ minSize: 50,
+ accessorFn: (row) => row._backlink?.rank ?? null,
+ cell: ({ row }) => {
+ if (row.depth > 0) {
+ const child = row.original._backlink;
+ return (
+
+
+ {formatNumber(child?.rank)}
+
+
+ );
+ }
+
+ return null;
+ },
+ sortingFn: "basic",
+ enableSorting: false,
+ },
+ {
+ id: "domainAuthority",
+ accessorKey: "domainAuthority",
+ header: ({ column }) => (
+
+ ),
+ size: 70,
+ minSize: 50,
+ cell: ({ row }) => {
+ if (row.depth > 0) return null;
+
+ return (
+
+ {formatNumber(row.original.domainAuthority)}
+
+ );
+ },
+ sortingFn: "basic",
+ },
+ {
+ id: "firstSeen",
+ accessorKey: "firstSeen",
+ header: ({ column }) => (
+
+ ),
+ size: 110,
+ minSize: 80,
+ cell: ({ row }) => {
+ if (row.depth > 0) {
+ const child = row.original._backlink;
+ return (
+
+
{formatCompactDate(child?.firstSeen)}
+ {child?.lastSeen ? (
+
+ Last {formatCompactDate(child.lastSeen)}
+
+ ) : null}
+
+ );
+ }
+
+ return (
+
+ {formatCompactDate(row.original.firstSeen)}
+
+ );
+ },
+ sortingFn: "datetime",
+ },
+];
diff --git a/src/client/features/backlinks/BacklinksTableHeaders.tsx b/src/client/features/backlinks/BacklinksTableHeaders.tsx
index 795fea2..e323878 100644
--- a/src/client/features/backlinks/BacklinksTableHeaders.tsx
+++ b/src/client/features/backlinks/BacklinksTableHeaders.tsx
@@ -2,83 +2,11 @@ import { ArrowDown, ArrowUp } from "lucide-react";
import { HeaderHelpLabel } from "@/client/features/keywords/components";
import {
getNextSort,
- type BacklinksTableSort,
type ReferringDomainsTableSort,
type SortDirection,
type TopPagesTableSort,
} from "./backlinksTableSorting";
-export function BacklinksTableHeader({
- sort,
- onSortChange,
-}: {
- sort: BacklinksTableSort;
- onSortChange: (sort: BacklinksTableSort) => void;
-}) {
- return (
-
-
-
-
-
- |
-
- |
-
-
-
-
-
- );
-}
-
export function ReferringDomainsTableHeader({
sort,
onSortChange,
diff --git a/src/client/features/backlinks/ReferringDomainsTable.tsx b/src/client/features/backlinks/ReferringDomainsTable.tsx
new file mode 100644
index 0000000..6a32e59
--- /dev/null
+++ b/src/client/features/backlinks/ReferringDomainsTable.tsx
@@ -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 ;
+ }
+
+ return (
+
+
+
+
+ {sortedRows.map((row, index) => (
+
+ | {row.domain ?? "-"} |
+ {formatNumber(row.backlinks)} |
+ {formatNumber(row.referringPages)} |
+ {formatNumber(row.rank)} |
+ {formatDecimal(row.spamScore)} |
+ {formatCompactDate(row.firstSeen)} |
+
+
+ Broken links: {formatNumber(row.brokenBacklinks)}
+
+ Broken pages: {formatNumber(row.brokenPages)}
+
+
+ |
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/client/features/backlinks/TopPagesTable.tsx b/src/client/features/backlinks/TopPagesTable.tsx
new file mode 100644
index 0000000..7e532fe
--- /dev/null
+++ b/src/client/features/backlinks/TopPagesTable.tsx
@@ -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 ;
+ }
+
+ return (
+
+
+
+
+ {sortedRows.map((row, index) => (
+
+ |
+ {row.page ? (
+
+ ) : (
+ "-"
+ )}
+ |
+ {formatNumber(row.backlinks)} |
+ {formatNumber(row.referringDomains)} |
+ {formatNumber(row.rank)} |
+ {formatNumber(row.brokenBacklinks)} |
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/client/features/backlinks/backlinksPageTypes.ts b/src/client/features/backlinks/backlinksPageTypes.ts
index e34faa8..edfc78c 100644
--- a/src/client/features/backlinks/backlinksPageTypes.ts
+++ b/src/client/features/backlinks/backlinksPageTypes.ts
@@ -38,3 +38,21 @@ export type BacklinksPageProps = {
searchState: BacklinksSearchState;
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;
+};
diff --git a/src/client/features/backlinks/backlinksPageUtils.test.ts b/src/client/features/backlinks/backlinksPageUtils.test.ts
new file mode 100644
index 0000000..d9789c2
--- /dev/null
+++ b/src/client/features/backlinks/backlinksPageUtils.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from "vitest";
+import type { BacklinksRow } from "./backlinksPageTypes";
+import { groupBacklinksByDomain } from "./backlinksPageUtils";
+
+function makeBacklinkRow(overrides: Partial = {}): 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]);
+ });
+});
diff --git a/src/client/features/backlinks/backlinksPageUtils.ts b/src/client/features/backlinks/backlinksPageUtils.ts
index 1ad6157..5c81012 100644
--- a/src/client/features/backlinks/backlinksPageUtils.ts
+++ b/src/client/features/backlinks/backlinksPageUtils.ts
@@ -1,5 +1,9 @@
import type { BacklinksTab } from "@/types/schemas/backlinks";
-import type { BacklinksOverviewData } from "./backlinksPageTypes";
+import type {
+ BacklinksOverviewData,
+ BacklinksRow,
+ GroupedBacklinkDomain,
+} from "./backlinksPageTypes";
export const TAB_DESCRIPTIONS: Record = {
backlinks:
@@ -115,6 +119,70 @@ export function formatRelativeTimestamp(value: string) {
});
}
+export function groupBacklinksByDomain(
+ rows: BacklinksRow[],
+): GroupedBacklinkDomain[] {
+ const groups = new Map();
+
+ 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) {
try {
const parsed = new URL(url);
diff --git a/src/client/features/backlinks/backlinksTableSorting.ts b/src/client/features/backlinks/backlinksTableSorting.ts
index 3d083b9..2597aae 100644
--- a/src/client/features/backlinks/backlinksTableSorting.ts
+++ b/src/client/features/backlinks/backlinksTableSorting.ts
@@ -2,14 +2,6 @@ import type { BacklinksOverviewData } from "./backlinksPageTypes";
export type SortDirection = "asc" | "desc";
-export type BacklinksTableSortField =
- | "source"
- | "target"
- | "anchor"
- | "linkAuthority"
- | "domainAuthority"
- | "firstSeen";
-
export type ReferringDomainsTableSortField =
| "domain"
| "backlinks"
@@ -31,16 +23,10 @@ export type TableSort = {
direction: SortDirection;
};
-export type BacklinksTableSort = TableSort;
export type ReferringDomainsTableSort =
TableSort;
export type TopPagesTableSort = TableSort;
-export const DEFAULT_BACKLINKS_SORT: BacklinksTableSort = {
- field: "firstSeen",
- direction: "desc",
-};
-
export const DEFAULT_REFERRING_DOMAINS_SORT: ReferringDomainsTableSort = {
field: "backlinks",
direction: "desc",
@@ -66,42 +52,6 @@ export function getNextSort(
};
}
-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(
rows: BacklinksOverviewData["referringDomains"],
sort: ReferringDomainsTableSort,