Add per-tab CSV export for backlinks results (#95)

* Add CSV export per backlinks tab

* save
This commit is contained in:
Ben Senescu 2026-04-07 21:53:08 -04:00 committed by Ben Senescu
parent d2fc85342a
commit 868d83fb1c
5 changed files with 394 additions and 137 deletions

View File

@ -155,6 +155,7 @@ export function BacklinksBody({
onSetActiveTab={onSetActiveTab}
onSetHideSpam={onSetHideSpam}
onSetSpamThreshold={onSetSpamThreshold}
exportTarget={mergedData.displayTarget || searchState.target}
/>
</>
);

View File

@ -1,10 +1,9 @@
import { useEffect, useState } from "react";
import { HeaderHelpLabel } from "@/client/features/keywords/components";
import { Search } from "lucide-react";
import {
BacklinksNewLostChart,
BacklinksTrendChart,
} from "./BacklinksPageCharts";
import { ResultsHeader } from "./BacklinksResultsHeader";
import { BacklinksTable } from "./BacklinksTable";
import { ReferringDomainsTable } from "./ReferringDomainsTable";
import { TopPagesTable } from "./TopPagesTable";
@ -12,10 +11,7 @@ import type {
BacklinksOverviewData,
BacklinksSearchState,
} from "./backlinksPageTypes";
import {
TAB_DESCRIPTIONS,
formatRelativeTimestamp,
} from "./backlinksPageUtils";
import { formatRelativeTimestamp } from "./backlinksPageUtils";
export function BacklinksOverviewPanels({
data,
@ -58,6 +54,7 @@ export function BacklinksResultsCard({
onSetActiveTab,
onSetHideSpam,
onSetSpamThreshold,
exportTarget,
}: {
activeTab: BacklinksSearchState["tab"];
filteredData: {
@ -74,6 +71,7 @@ export function BacklinksResultsCard({
onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void;
onSetHideSpam: (hideSpam: boolean) => void;
onSetSpamThreshold: (threshold: number) => void;
exportTarget: string;
}) {
return (
<div className="card bg-base-100 border border-base-300">
@ -87,6 +85,8 @@ export function BacklinksResultsCard({
onSetActiveTab={onSetActiveTab}
onSetHideSpam={onSetHideSpam}
onSetSpamThreshold={onSetSpamThreshold}
filteredData={filteredData}
exportTarget={exportTarget}
/>
{tabErrorMessage ? (
<div className="alert alert-error">
@ -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 (
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-2">
<div role="tablist" className="tabs tabs-box w-fit">
<TabButton
activeTab={activeTab}
tab="backlinks"
onClick={onSetActiveTab}
>
Backlinks
</TabButton>
<TabButton
activeTab={activeTab}
tab="domains"
onClick={onSetActiveTab}
>
Referring Domains
</TabButton>
<TabButton activeTab={activeTab} tab="pages" onClick={onSetActiveTab}>
Top Pages
</TabButton>
</div>
<p className="max-w-xl text-sm text-base-content/60">
{TAB_DESCRIPTIONS[activeTab]}
</p>
</div>
<div className="flex flex-col items-end gap-2">
<label className="input input-bordered input-sm flex w-full max-w-xs items-center gap-2">
<Search className="size-4 text-base-content/60" />
<input
placeholder="Filter current tab"
value={filterText}
onChange={(event) => onFilterTextChange(event.target.value)}
/>
</label>
{activeTab === "backlinks" ? (
<div className="flex items-center gap-3 text-sm">
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
className="checkbox checkbox-xs"
checked={hideSpam}
onChange={(event) => onSetHideSpam(event.target.checked)}
/>
<span className="text-base-content/70">Hide spam</span>
</label>
<label className="flex items-center gap-2 text-base-content/70">
<span>Max spam</span>
<input
type="number"
min={0}
max={100}
step={1}
value={draftSpamThreshold}
disabled={!hideSpam}
className="input input-bordered input-xs w-20"
onBlur={commitSpamThreshold}
onChange={(event) => setDraftSpamThreshold(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
commitSpamThreshold();
event.currentTarget.blur();
}
}}
/>
</label>
</div>
) : null}
</div>
</div>
);
}
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 (
<button
role="tab"
className={`tab ${activeTab === tab ? "tab-active" : ""}`}
onClick={() => onClick(tab)}
>
{children}
</button>
);
}
function TabLoadingState({ label }: { label: string }) {
return (
<div className="space-y-3 py-2">

View File

@ -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 (
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-2">
<div role="tablist" className="tabs tabs-box w-fit">
<TabButton
activeTab={activeTab}
tab="backlinks"
onClick={onSetActiveTab}
>
Backlinks
</TabButton>
<TabButton
activeTab={activeTab}
tab="domains"
onClick={onSetActiveTab}
>
Referring Domains
</TabButton>
<TabButton activeTab={activeTab} tab="pages" onClick={onSetActiveTab}>
Top Pages
</TabButton>
</div>
<p className="max-w-xl text-sm text-base-content/60">
{TAB_DESCRIPTIONS[activeTab]}
</p>
</div>
<div className="flex flex-col items-end gap-2">
<button
className="btn btn-sm btn-ghost w-full max-w-xs justify-start lg:justify-center"
onClick={() =>
exportBacklinksTabCsv({
tab: activeTab,
target: exportTarget,
rows: filteredData,
})
}
>
<Download className="size-4" />
Export CSV
</button>
<label className="input input-bordered input-sm flex w-full max-w-xs items-center gap-2">
<Search className="size-4 text-base-content/60" />
<input
placeholder="Filter current tab"
value={filterText}
onChange={(event) => onFilterTextChange(event.target.value)}
/>
</label>
{activeTab === "backlinks" ? (
<div className="flex items-center gap-3 text-sm">
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
className="checkbox checkbox-xs"
checked={hideSpam}
onChange={(event) => onSetHideSpam(event.target.checked)}
/>
<span className="text-base-content/70">Hide spam</span>
</label>
<label className="flex items-center gap-2 text-base-content/70">
<span>Max spam</span>
<input
type="number"
min={0}
max={100}
step={1}
value={draftSpamThreshold}
disabled={!hideSpam}
className="input input-bordered input-xs w-20"
onBlur={commitSpamThreshold}
onChange={(event) => setDraftSpamThreshold(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
commitSpamThreshold();
event.currentTarget.blur();
}
}}
/>
</label>
</div>
) : null}
</div>
</div>
);
}
function TabButton({
activeTab,
children,
onClick,
tab,
}: {
activeTab: BacklinksSearchState["tab"];
children: string;
onClick: (tab: BacklinksSearchState["tab"]) => void;
tab: BacklinksSearchState["tab"];
}) {
return (
<button
role="tab"
className={`tab ${activeTab === tab ? "tab-active" : ""}`}
onClick={() => onClick(tab)}
>
{children}
</button>
);
}

View File

@ -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"');
});
});

View File

@ -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`;
}