metatron-open-seo/src/client/features/backlinks/BacklinksPageSections.tsx
Ben Senescu 93c95dc8f7 feat: add structured filters to backlinks tables (#94)
* feat: add structured filters to backlinks tables

Add per-tab filter panels (Backlinks, Referring Domains, Top Pages) with
include/exclude text search, numeric range filters, link type selector,
and visibility toggles. Filter values persist in localStorage across
searches and page reloads.

* fix: improve backlinks filter layout and hide domain overview filters on pages tab

- Add w-full to text filter inputs so they fill their grid columns
- Restructure backlinks results card to use border-separated sections
  matching the domain overview pattern
- Hide filter button and panel on domain overview's Top Pages tab since
  keyword filters don't apply there

* fix: move backlinks filter button to its own toolbar row below tabs

Match the domain overview layout: tabs in their own header row, then a
separate toolbar row with the Filters button underneath.

* style: fix prettier formatting in backlinks filter files

* fix: resolve all ci:check issues

- Remove unused BacklinksResultsHeader.tsx and useBacklinksSpamPreferences.ts
- Un-export DEFAULT_BACKLINKS_SPAM_THRESHOLD and normalizeBacklinksSpamThreshold
  (only used internally)
- Remove unused BacklinksAllFilterValues type
- Fix oxlint unsafe type assertions in useBacklinksFilters.ts

* fix: wire spam filter options through to server and remove spam filters from non-backlinks tabs

The client was not passing hideSpam/spamThreshold to the server, so the
server always defaulted to hideSpam:true with threshold 40. This made
the new client-side spam score filters on the backlinks tab silently
filter an already-truncated dataset. Now the client sends hideSpam:false
so the server returns all rows and client-side filtering works correctly.

Removed spam score filters from the Referring Domains tab since spam
filtering is not needed there.

* fix: restore backlinks spam filtering and safe filter hydration

* simplify: remove server-side spam filter wiring, keep client-side only

All backlinks filtering (including spam score) is now done client-side.
Server-side spam filtering adds complexity without meaningful benefit
at current data volumes. This removes the bridging code added in the
last two commits and simplifies filter hydration.
2026-04-08 14:09:02 -04:00

301 lines
8.7 KiB
TypeScript

import { HeaderHelpLabel } from "@/client/features/keywords/components";
import { ArrowLeft, Download, SlidersHorizontal } from "lucide-react";
import {
BacklinksNewLostChart,
BacklinksTrendChart,
} from "./BacklinksPageCharts";
import { BacklinksFilterPanel } from "./BacklinksFilterPanel";
import { BacklinksTable } from "./BacklinksTable";
import { ReferringDomainsTable } from "./ReferringDomainsTable";
import { TopPagesTable } from "./TopPagesTable";
import type {
BacklinksOverviewData,
BacklinksSearchState,
} from "./backlinksPageTypes";
import {
TAB_DESCRIPTIONS,
formatRelativeTimestamp,
} from "./backlinksPageUtils";
import { exportBacklinksTabCsv } from "./export";
import type { BacklinksFiltersState } from "./useBacklinksFilters";
export function BacklinksOverviewPanels({
data,
onShowHistory,
summaryStats,
}: {
data: BacklinksOverviewData;
onShowHistory: () => void;
summaryStats: Array<{ label: string; value: string; description: string }>;
}) {
return (
<>
<div>
<button
type="button"
className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent"
onClick={onShowHistory}
>
<ArrowLeft className="size-4" />
Recent searches
</button>
</div>
<div className="flex flex-wrap items-center gap-2 text-sm text-base-content/65">
<span className="badge badge-outline">{data.scope}</span>
<span>Target: {data.displayTarget}</span>
<span>-</span>
<span>Updated {formatRelativeTimestamp(data.fetchedAt)}</span>
</div>
<OverviewGrid data={data} summaryStats={summaryStats} />
{data.scope === "page" ? (
<div className="alert alert-info">
<span>
Showing backlinks for this exact page. Enter a bare domain for
site-wide results. Trend charts are only shown for domain-level
lookups.
</span>
</div>
) : null}
</>
);
}
export function BacklinksResultsCard({
activeTab,
filteredData,
filters,
isTabLoading,
tabErrorMessage,
onSetActiveTab,
exportTarget,
}: {
activeTab: BacklinksSearchState["tab"];
filteredData: {
backlinks: BacklinksOverviewData["backlinks"];
referringDomains: BacklinksOverviewData["referringDomains"];
topPages: BacklinksOverviewData["topPages"];
};
filters: BacklinksFiltersState;
isTabLoading: boolean;
tabErrorMessage: string | null;
onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void;
exportTarget: string;
}) {
const currentFilterCount = filters[activeTab].activeFilterCount;
return (
<div className="border border-base-300 rounded-xl bg-base-100 overflow-hidden">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 px-4 py-3 border-b border-base-300">
<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>
<button
className="btn btn-sm btn-ghost justify-start lg:justify-center"
onClick={() =>
exportBacklinksTabCsv({
tab: activeTab,
target: exportTarget,
rows: filteredData,
})
}
>
<Download className="size-4" />
Export CSV
</button>
</div>
<div className="flex items-center gap-2 px-4 py-2 border-b border-base-300">
<button
className={`btn btn-ghost btn-sm gap-1.5 ${filters.showFilters ? "btn-active" : ""}`}
onClick={() => filters.setShowFilters((current) => !current)}
title="Toggle table filters"
>
<SlidersHorizontal className="size-3.5" />
Filters
{currentFilterCount > 0 ? (
<span className="badge badge-xs badge-primary border-0 text-primary-content">
{currentFilterCount}
</span>
) : null}
</button>
</div>
{filters.showFilters ? (
<BacklinksFilterPanel activeTab={activeTab} filters={filters} />
) : null}
<div className="p-4">
{tabErrorMessage ? (
<div className="alert alert-error mb-3">
<span>{tabErrorMessage}</span>
</div>
) : null}
{activeTab === "backlinks" ? (
<BacklinksTable rows={filteredData.backlinks} />
) : null}
{activeTab === "domains" && isTabLoading && !tabErrorMessage ? (
<TabLoadingState label="Loading referring domains" />
) : null}
{activeTab === "domains" && !isTabLoading && !tabErrorMessage ? (
<ReferringDomainsTable rows={filteredData.referringDomains} />
) : null}
{activeTab === "pages" && isTabLoading && !tabErrorMessage ? (
<TabLoadingState label="Loading top pages" />
) : null}
{activeTab === "pages" && !isTabLoading && !tabErrorMessage ? (
<TopPagesTable rows={filteredData.topPages} />
) : null}
</div>
</div>
);
}
function OverviewGrid({
data,
summaryStats,
}: {
data: BacklinksOverviewData;
summaryStats: Array<{ label: string; value: string; description: string }>;
}) {
const domainScope = data.scope === "domain";
return (
<div
className={`grid grid-cols-1 gap-3 ${domainScope ? "md:grid-cols-2 xl:grid-cols-3" : ""}`}
>
<SummaryStatsGrid data={data} summaryStats={summaryStats} />
{domainScope ? <TrendPanels data={data} /> : null}
</div>
);
}
function SummaryStatsGrid({
data,
summaryStats,
}: {
data: BacklinksOverviewData;
summaryStats: Array<{ label: string; value: string; description: string }>;
}) {
const cardClassName = `card bg-base-100 border border-base-300 ${data.scope === "domain" ? "md:col-span-2 xl:col-span-1" : ""}`;
return (
<div className={cardClassName}>
<div className="card-body p-4 xl:h-full">
<div className="grid grid-cols-2 gap-x-6 gap-y-5 xl:gap-y-6">
{summaryStats.map((item) => (
<div key={item.label}>
<div className="text-xs uppercase tracking-wide text-base-content/55">
<HeaderHelpLabel
label={item.label}
helpText={item.description}
/>
</div>
<p className="text-2xl font-semibold">{item.value}</p>
</div>
))}
</div>
</div>
</div>
);
}
function TrendPanels({ data }: { data: BacklinksOverviewData }) {
return (
<>
<TrendCard
title="Backlink growth"
description="Backlinks and referring domains over the last year"
>
<BacklinksTrendChart data={data.trends} />
</TrendCard>
<TrendCard
title="New vs lost"
description="Backlink acquisition and attrition"
>
<BacklinksNewLostChart data={data.newLostTrends} />
</TrendCard>
</>
);
}
function TrendCard({
children,
description,
title,
}: {
children: React.ReactNode;
description: string;
title: string;
}) {
return (
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-2 p-4">
<div>
<h2 className="text-sm font-medium">{title}</h2>
<p className="text-xs text-base-content/55">{description}</p>
</div>
{children}
</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>
);
}
function TabLoadingState({ label }: { label: string }) {
return (
<div className="space-y-3 py-2">
<p className="text-sm text-base-content/60">{label}...</p>
<div className="skeleton h-10 w-full" />
<div className="skeleton h-10 w-full" />
<div className="skeleton h-10 w-full" />
</div>
);
}