@@ -132,115 +132,6 @@ function OverviewGrid({
);
}
-function ResultsHeader({
- activeTab,
- filterText,
- hideSpam,
- spamThreshold,
- onFilterTextChange,
- onSetActiveTab,
- onSetHideSpam,
- onSetSpamThreshold,
-}: {
- activeTab: BacklinksSearchState["tab"];
- filterText: string;
- hideSpam: boolean;
- spamThreshold: number;
- onFilterTextChange: (value: string) => void;
- onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void;
- onSetHideSpam: (hideSpam: boolean) => void;
- onSetSpamThreshold: (threshold: number) => void;
-}) {
- const [draftSpamThreshold, setDraftSpamThreshold] = useState(
- String(spamThreshold),
- );
-
- useEffect(() => {
- setDraftSpamThreshold(String(spamThreshold));
- }, [spamThreshold]);
-
- function commitSpamThreshold() {
- onSetSpamThreshold(
- draftSpamThreshold.trim() === ""
- ? spamThreshold
- : Number(draftSpamThreshold),
- );
- }
-
- return (
-
- );
-}
-
function SummaryStatsGrid({
data,
summaryStats,
@@ -312,28 +203,6 @@ function TrendCard({
);
}
-function TabButton({
- activeTab,
- children,
- onClick,
- tab,
-}: {
- activeTab: BacklinksSearchState["tab"];
- children: string;
- onClick: (tab: BacklinksSearchState["tab"]) => void;
- tab: BacklinksSearchState["tab"];
-}) {
- return (
-
- );
-}
-
function TabLoadingState({ label }: { label: string }) {
return (
diff --git a/src/client/features/backlinks/BacklinksResultsHeader.tsx b/src/client/features/backlinks/BacklinksResultsHeader.tsx
new file mode 100644
index 0000000..ce0b7e5
--- /dev/null
+++ b/src/client/features/backlinks/BacklinksResultsHeader.tsx
@@ -0,0 +1,162 @@
+import { useEffect, useState } from "react";
+import { Download, Search } from "lucide-react";
+import type {
+ BacklinksOverviewData,
+ BacklinksSearchState,
+} from "./backlinksPageTypes";
+import { TAB_DESCRIPTIONS } from "./backlinksPageUtils";
+import { exportBacklinksTabCsv } from "./export";
+
+type BacklinksResultsData = {
+ backlinks: BacklinksOverviewData["backlinks"];
+ referringDomains: BacklinksOverviewData["referringDomains"];
+ topPages: BacklinksOverviewData["topPages"];
+};
+
+export function ResultsHeader({
+ activeTab,
+ filterText,
+ hideSpam,
+ spamThreshold,
+ onFilterTextChange,
+ onSetActiveTab,
+ onSetHideSpam,
+ onSetSpamThreshold,
+ filteredData,
+ exportTarget,
+}: {
+ activeTab: BacklinksSearchState["tab"];
+ filterText: string;
+ hideSpam: boolean;
+ spamThreshold: number;
+ onFilterTextChange: (value: string) => void;
+ onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void;
+ onSetHideSpam: (hideSpam: boolean) => void;
+ onSetSpamThreshold: (threshold: number) => void;
+ filteredData: BacklinksResultsData;
+ exportTarget: string;
+}) {
+ const [draftSpamThreshold, setDraftSpamThreshold] = useState(
+ String(spamThreshold),
+ );
+
+ useEffect(() => {
+ setDraftSpamThreshold(String(spamThreshold));
+ }, [spamThreshold]);
+
+ function commitSpamThreshold() {
+ onSetSpamThreshold(
+ draftSpamThreshold.trim() === ""
+ ? spamThreshold
+ : Number(draftSpamThreshold),
+ );
+ }
+
+ return (
+
+
+
+
+ Backlinks
+
+
+ Referring Domains
+
+
+ Top Pages
+
+
+
+ {TAB_DESCRIPTIONS[activeTab]}
+
+
+
+
+
+ );
+}
+
+function TabButton({
+ activeTab,
+ children,
+ onClick,
+ tab,
+}: {
+ activeTab: BacklinksSearchState["tab"];
+ children: string;
+ onClick: (tab: BacklinksSearchState["tab"]) => void;
+ tab: BacklinksSearchState["tab"];
+}) {
+ return (
+
+ );
+}
diff --git a/src/client/features/backlinks/export.test.ts b/src/client/features/backlinks/export.test.ts
new file mode 100644
index 0000000..74e3711
--- /dev/null
+++ b/src/client/features/backlinks/export.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, it } from "vitest";
+import { buildBacklinksTabCsvFile } from "./export";
+
+describe("buildBacklinksTabCsvFile", () => {
+ it("builds backlinks csv with backlink-specific columns", () => {
+ const file = buildBacklinksTabCsvFile({
+ tab: "backlinks",
+ target: "https://Example.com/path?q=1",
+ rows: {
+ backlinks: [
+ {
+ domainFrom: "example.org",
+ urlFrom: "https://example.org/post",
+ urlTo: "https://example.com/path",
+ anchor: "Example",
+ itemType: "organic",
+ isDofollow: true,
+ relAttributes: ["noopener", "noreferrer"],
+ rank: 123,
+ domainFromRank: 45,
+ pageFromRank: 12,
+ spamScore: 10,
+ firstSeen: "2025-01-01",
+ lastSeen: "2025-01-15",
+ isLost: false,
+ isBroken: false,
+ linksCount: 2,
+ },
+ ],
+ referringDomains: [],
+ topPages: [],
+ },
+ });
+
+ expect(file.filename).toBe("backlinks-backlinks-example.com-path-q-1.csv");
+ expect(file.content).toContain('"Domain","Source URL","Target URL"');
+ expect(file.content).toContain('"example.org"');
+ expect(file.content).toContain('"noopener, noreferrer"');
+ });
+
+ it("builds referring domains csv", () => {
+ const file = buildBacklinksTabCsvFile({
+ tab: "domains",
+ target: "Example.com",
+ rows: {
+ backlinks: [],
+ referringDomains: [
+ {
+ domain: "source.com",
+ backlinks: 12,
+ referringPages: 7,
+ rank: 101,
+ spamScore: 4,
+ firstSeen: "2024-05-10",
+ brokenBacklinks: 1,
+ brokenPages: 0,
+ },
+ ],
+ topPages: [],
+ },
+ });
+
+ expect(file.filename).toBe("backlinks-referring-domains-example.com.csv");
+ expect(file.content).toContain('"Domain","Backlinks","Referring Pages"');
+ expect(file.content).toContain('"source.com"');
+ });
+
+ it("builds top pages csv", () => {
+ const file = buildBacklinksTabCsvFile({
+ tab: "pages",
+ target: "docs.example.com",
+ rows: {
+ backlinks: [],
+ referringDomains: [],
+ topPages: [
+ {
+ page: "https://docs.example.com/start",
+ backlinks: 22,
+ referringDomains: 9,
+ rank: 88,
+ brokenBacklinks: 0,
+ },
+ ],
+ },
+ });
+
+ expect(file.filename).toBe("backlinks-top-pages-docs.example.com.csv");
+ expect(file.content).toContain(
+ '"Page","Backlinks","Referring Domains","Rank","Broken Backlinks"',
+ );
+ expect(file.content).toContain('"https://docs.example.com/start"');
+ });
+});
diff --git a/src/client/features/backlinks/export.ts b/src/client/features/backlinks/export.ts
new file mode 100644
index 0000000..279e3e0
--- /dev/null
+++ b/src/client/features/backlinks/export.ts
@@ -0,0 +1,132 @@
+import { buildCsv, downloadCsv } from "@/client/lib/csv";
+import type {
+ BacklinksOverviewData,
+ BacklinksSearchState,
+} from "./backlinksPageTypes";
+
+type BacklinksFilteredData = {
+ backlinks: BacklinksOverviewData["backlinks"];
+ referringDomains: BacklinksOverviewData["referringDomains"];
+ topPages: BacklinksOverviewData["topPages"];
+};
+
+export function buildBacklinksTabCsvFile(args: {
+ tab: BacklinksSearchState["tab"];
+ target: string;
+ rows: BacklinksFilteredData;
+}) {
+ const { tab, target, rows } = args;
+
+ if (tab === "backlinks") {
+ const headers = [
+ "Domain",
+ "Source URL",
+ "Target URL",
+ "Anchor",
+ "Type",
+ "Dofollow",
+ "Rel Attributes",
+ "Domain Rank",
+ "Source Page Rank",
+ "Target Rank",
+ "Spam Score",
+ "First Seen",
+ "Last Seen",
+ "Lost",
+ "Broken",
+ "Links Count",
+ ];
+ const lines = rows.backlinks.map((row) => [
+ row.domainFrom,
+ row.urlFrom,
+ row.urlTo,
+ row.anchor,
+ row.itemType,
+ row.isDofollow,
+ row.relAttributes.join(", "),
+ row.domainFromRank,
+ row.pageFromRank,
+ row.rank,
+ row.spamScore,
+ row.firstSeen,
+ row.lastSeen,
+ row.isLost,
+ row.isBroken,
+ row.linksCount,
+ ]);
+
+ return {
+ filename: buildFilename("backlinks", target),
+ content: buildCsv(headers, lines),
+ };
+ }
+
+ if (tab === "domains") {
+ const headers = [
+ "Domain",
+ "Backlinks",
+ "Referring Pages",
+ "Rank",
+ "Spam Score",
+ "First Seen",
+ "Broken Backlinks",
+ "Broken Pages",
+ ];
+ const lines = rows.referringDomains.map((row) => [
+ row.domain,
+ row.backlinks,
+ row.referringPages,
+ row.rank,
+ row.spamScore,
+ row.firstSeen,
+ row.brokenBacklinks,
+ row.brokenPages,
+ ]);
+
+ return {
+ filename: buildFilename("referring-domains", target),
+ content: buildCsv(headers, lines),
+ };
+ }
+
+ const headers = [
+ "Page",
+ "Backlinks",
+ "Referring Domains",
+ "Rank",
+ "Broken Backlinks",
+ ];
+ const lines = rows.topPages.map((row) => [
+ row.page,
+ row.backlinks,
+ row.referringDomains,
+ row.rank,
+ row.brokenBacklinks,
+ ]);
+
+ return {
+ filename: buildFilename("top-pages", target),
+ content: buildCsv(headers, lines),
+ };
+}
+
+export function exportBacklinksTabCsv(args: {
+ tab: BacklinksSearchState["tab"];
+ target: string;
+ rows: BacklinksFilteredData;
+}) {
+ const file = buildBacklinksTabCsvFile(args);
+ downloadCsv(file.filename, file.content);
+}
+
+function buildFilename(tabPrefix: string, target: string) {
+ const normalizedTarget = target
+ .toLowerCase()
+ .trim()
+ .replace(/https?:\/\//g, "")
+ .replace(/[^a-z0-9.-]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, 80);
+
+ return `backlinks-${tabPrefix}${normalizedTarget ? `-${normalizedTarget}` : ""}.csv`;
+}