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) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} +
+ {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,