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,