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.
This commit is contained in:
Ben Senescu 2026-04-07 23:47:51 -04:00 committed by Ben Senescu
parent b3d543d206
commit 93c95dc8f7
13 changed files with 841 additions and 402 deletions

View File

@ -0,0 +1,347 @@
import { RotateCcw } from "lucide-react";
import type { BacklinksTab } from "@/types/schemas/backlinks";
import type { BacklinksFiltersState } from "./useBacklinksFilters";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyForm = { Field: React.ComponentType<any> };
function FilterTextInput({
form,
name,
label,
placeholder,
}: {
form: AnyForm;
name: string;
label: string;
placeholder: string;
}) {
return (
<label className="form-control gap-1.5">
<span className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
{label}
</span>
<form.Field name={name}>
{(field: {
state: { value: string };
handleChange: (v: string) => void;
}) => (
<input
className="input input-bordered input-sm w-full bg-base-100"
placeholder={placeholder}
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
/>
)}
</form.Field>
</label>
);
}
function FilterRangeInputs({
form,
title,
minName,
maxName,
step,
}: {
form: AnyForm;
title: string;
minName: string;
maxName: string;
step?: string;
}) {
return (
<div className="rounded-lg border border-base-300 bg-base-100 p-2.5 space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
{title}
</p>
<div className="grid grid-cols-2 gap-2">
<CompactRangeInput
form={form}
name={minName}
placeholder="Min"
step={step}
/>
<CompactRangeInput
form={form}
name={maxName}
placeholder="Max"
step={step}
/>
</div>
</div>
);
}
function CompactRangeInput({
form,
name,
placeholder,
step,
}: {
form: AnyForm;
name: string;
placeholder: string;
step?: string;
}) {
return (
<form.Field name={name}>
{(field: {
state: { value: string };
handleChange: (v: string) => void;
}) => (
<input
className="input input-bordered input-xs bg-base-100"
placeholder={placeholder}
type="number"
step={step}
value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)}
/>
)}
</form.Field>
);
}
function BacklinksTabFilters({
form,
}: {
form: BacklinksFiltersState["backlinks"]["form"];
}) {
return (
<>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<FilterTextInput
form={form}
name="include"
label="Include Terms"
placeholder="example.com, blog"
/>
<FilterTextInput
form={form}
name="exclude"
label="Exclude Terms"
placeholder="spam, forum"
/>
</div>
<div className="grid grid-cols-1 gap-2 lg:grid-cols-3">
<FilterRangeInputs
form={form}
title="Domain Authority"
minName="minDomainRank"
maxName="maxDomainRank"
/>
<FilterRangeInputs
form={form}
title="Link Authority"
minName="minLinkAuthority"
maxName="maxLinkAuthority"
/>
<FilterRangeInputs
form={form}
title="Spam Score"
minName="minSpamScore"
maxName="maxSpamScore"
step="0.1"
/>
</div>
<div className="flex flex-wrap items-center gap-4">
<div className="space-y-1.5">
<p className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
Link Type
</p>
<form.Field name="linkType">
{(field) => (
<div className="flex items-center gap-1">
{(["", "dofollow", "nofollow"] as const).map((value) => (
<button
key={value || "all"}
type="button"
className={`btn btn-xs ${field.state.value === value ? "btn-soft" : "btn-ghost"}`}
onClick={() => field.handleChange(value)}
>
{value === ""
? "All"
: value === "dofollow"
? "Dofollow"
: "Nofollow"}
</button>
))}
</div>
)}
</form.Field>
</div>
<div className="space-y-1.5">
<p className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
Visibility
</p>
<div className="flex items-center gap-3">
<form.Field name="hideLost">
{(field) => (
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
className="checkbox checkbox-xs"
checked={field.state.value === "true"}
onChange={(event) =>
field.handleChange(event.target.checked ? "true" : "")
}
/>
<span className="text-xs">Hide lost</span>
</label>
)}
</form.Field>
<form.Field name="hideBroken">
{(field) => (
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
className="checkbox checkbox-xs"
checked={field.state.value === "true"}
onChange={(event) =>
field.handleChange(event.target.checked ? "true" : "")
}
/>
<span className="text-xs">Hide broken</span>
</label>
)}
</form.Field>
</div>
</div>
</div>
</>
);
}
function ReferringDomainsFilters({
form,
}: {
form: BacklinksFiltersState["domains"]["form"];
}) {
return (
<>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<FilterTextInput
form={form}
name="include"
label="Include Terms"
placeholder="example.com, blog"
/>
<FilterTextInput
form={form}
name="exclude"
label="Exclude Terms"
placeholder="spam, forum"
/>
</div>
<div className="grid grid-cols-1 gap-2 lg:grid-cols-2">
<FilterRangeInputs
form={form}
title="Backlinks"
minName="minBacklinks"
maxName="maxBacklinks"
/>
<FilterRangeInputs
form={form}
title="Rank"
minName="minRank"
maxName="maxRank"
/>
</div>
</>
);
}
function TopPagesFilters({
form,
}: {
form: BacklinksFiltersState["pages"]["form"];
}) {
return (
<>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<FilterTextInput
form={form}
name="include"
label="Include Terms"
placeholder="/blog, /products"
/>
<FilterTextInput
form={form}
name="exclude"
label="Exclude Terms"
placeholder="/tag, /author"
/>
</div>
<div className="grid grid-cols-1 gap-2 lg:grid-cols-3">
<FilterRangeInputs
form={form}
title="Backlinks"
minName="minBacklinks"
maxName="maxBacklinks"
/>
<FilterRangeInputs
form={form}
title="Referring Domains"
minName="minReferringDomains"
maxName="maxReferringDomains"
/>
<FilterRangeInputs
form={form}
title="Rank"
minName="minRank"
maxName="maxRank"
/>
</div>
</>
);
}
export function BacklinksFilterPanel({
activeTab,
filters,
}: {
activeTab: BacklinksTab;
filters: BacklinksFiltersState;
}) {
const current = filters[activeTab];
return (
<div className="shrink-0 border-b border-base-300 bg-gradient-to-b from-base-100 to-base-200/30 px-4 py-3 space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold">Refine results</p>
{current.activeFilterCount > 0 ? (
<span className="badge badge-xs badge-primary border-0 text-primary-content">
{current.activeFilterCount} active
</span>
) : null}
</div>
<button
className="btn btn-xs btn-ghost gap-1"
onClick={current.reset}
disabled={current.activeFilterCount === 0}
>
<RotateCcw className="size-3" />
Clear all
</button>
</div>
{activeTab === "backlinks" ? (
<BacklinksTabFilters form={filters.backlinks.form} />
) : null}
{activeTab === "domains" ? (
<ReferringDomainsFilters form={filters.domains.form} />
) : null}
{activeTab === "pages" ? (
<TopPagesFilters form={filters.pages.form} />
) : null}
</div>
);
}

View File

@ -7,8 +7,8 @@ import {
navigateToBacklinksTab, navigateToBacklinksTab,
useBacklinksPageData, useBacklinksPageData,
} from "./useBacklinksPageData"; } from "./useBacklinksPageData";
import { useBacklinksFilters } from "./useBacklinksFilters";
import { useBacklinksSearchHistory } from "@/client/hooks/useBacklinksSearchHistory"; import { useBacklinksSearchHistory } from "@/client/hooks/useBacklinksSearchHistory";
import { useBacklinksSpamPreferences } from "./useBacklinksSpamPreferences";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
export function BacklinksPage({ export function BacklinksPage({
@ -16,8 +16,7 @@ export function BacklinksPage({
searchState, searchState,
navigate, navigate,
}: BacklinksPageProps) { }: BacklinksPageProps) {
const { hideSpam, setHideSpam, setSpamThreshold, spamThreshold } = const filters = useBacklinksFilters();
useBacklinksSpamPreferences();
const { const {
accessStatus, accessStatus,
accessStatusErrorMessage, accessStatusErrorMessage,
@ -95,6 +94,7 @@ export function BacklinksPage({
overviewLoading={overviewQuery.isLoading} overviewLoading={overviewQuery.isLoading}
referringDomains={referringDomainsQuery.data} referringDomains={referringDomainsQuery.data}
searchState={searchState} searchState={searchState}
filters={filters}
tabErrorMessage={activeTabErrorMessage} tabErrorMessage={activeTabErrorMessage}
tabLoading={ tabLoading={
(searchState.tab === "domains" && (searchState.tab === "domains" &&
@ -111,8 +111,6 @@ export function BacklinksPage({
} }
testIsPending={testAccessMutation.isPending} testIsPending={testAccessMutation.isPending}
topPages={topPagesQuery.data} topPages={topPagesQuery.data}
hideSpam={hideSpam}
spamThreshold={spamThreshold}
onRemoveHistoryItem={removeHistoryItem} onRemoveHistoryItem={removeHistoryItem}
onRetryAccess={() => void accessStatusQuery.refetch()} onRetryAccess={() => void accessStatusQuery.refetch()}
onSelectHistoryItem={handleHistorySelect} onSelectHistoryItem={handleHistorySelect}
@ -120,8 +118,6 @@ export function BacklinksPage({
onSetActiveTab={(tab) => navigateToBacklinksTab(navigate, tab)} onSetActiveTab={(tab) => navigateToBacklinksTab(navigate, tab)}
onRetryOverview={() => void overviewQuery.refetch()} onRetryOverview={() => void overviewQuery.refetch()}
onTestAccess={() => testAccessMutation.mutate()} onTestAccess={() => testAccessMutation.mutate()}
onSetHideSpam={setHideSpam}
onSetSpamThreshold={setSpamThreshold}
/> />
</div> </div>
</div> </div>

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react"; import { useMemo } from "react";
import { import {
BacklinksOverviewPanels, BacklinksOverviewPanels,
BacklinksResultsCard, BacklinksResultsCard,
@ -19,6 +19,12 @@ import type {
BacklinksTopPagesData, BacklinksTopPagesData,
} from "./backlinksPageTypes"; } from "./backlinksPageTypes";
import { buildSummaryStats } from "./backlinksPageUtils"; import { buildSummaryStats } from "./backlinksPageUtils";
import {
filterBacklinkRows,
filterReferringDomainRows,
filterTopPageRows,
} from "./backlinksFiltering";
import type { BacklinksFiltersState } from "./useBacklinksFilters";
type BacklinksBodyProps = { type BacklinksBodyProps = {
accessStatus: BacklinksAccessStatusData | undefined; accessStatus: BacklinksAccessStatusData | undefined;
@ -28,13 +34,12 @@ type BacklinksBodyProps = {
history: BacklinksSearchHistoryItem[]; history: BacklinksSearchHistoryItem[];
historyLoaded: boolean; historyLoaded: boolean;
isAccessStatusLoading: boolean; isAccessStatusLoading: boolean;
hideSpam: boolean;
overviewData: BacklinksOverviewData | undefined; overviewData: BacklinksOverviewData | undefined;
overviewError: string | null; overviewError: string | null;
overviewLoading: boolean; overviewLoading: boolean;
referringDomains: BacklinksReferringDomainsData | undefined; referringDomains: BacklinksReferringDomainsData | undefined;
searchState: BacklinksSearchState; searchState: BacklinksSearchState;
spamThreshold: number; filters: BacklinksFiltersState;
tabErrorMessage: string | null; tabErrorMessage: string | null;
tabLoading: boolean; tabLoading: boolean;
testError: string | null; testError: string | null;
@ -47,8 +52,6 @@ type BacklinksBodyProps = {
onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void; onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void;
onRetryOverview: () => void; onRetryOverview: () => void;
onTestAccess: () => void; onTestAccess: () => void;
onSetHideSpam: (hideSpam: boolean) => void;
onSetSpamThreshold: (threshold: number) => void;
}; };
export function BacklinksBody({ export function BacklinksBody({
@ -59,13 +62,12 @@ export function BacklinksBody({
history, history,
historyLoaded, historyLoaded,
isAccessStatusLoading, isAccessStatusLoading,
hideSpam,
overviewData, overviewData,
overviewError, overviewError,
overviewLoading, overviewLoading,
referringDomains, referringDomains,
searchState, searchState,
spamThreshold, filters,
tabErrorMessage, tabErrorMessage,
tabLoading, tabLoading,
testError, testError,
@ -78,31 +80,32 @@ export function BacklinksBody({
onSetActiveTab, onSetActiveTab,
onRetryOverview, onRetryOverview,
onTestAccess, onTestAccess,
onSetHideSpam,
onSetSpamThreshold,
}: BacklinksBodyProps) { }: BacklinksBodyProps) {
const [filterText, setFilterText] = useState("");
useEffect(() => {
setFilterText("");
}, [searchState.target, searchState.tab]);
const mergedData = useMemo( const mergedData = useMemo(
() => mergeTabData(overviewData, referringDomains, topPages), () => mergeTabData(overviewData, referringDomains, topPages),
[overviewData, referringDomains, topPages], [overviewData, referringDomains, topPages],
); );
const normalizedFilter = filterText.trim().toLowerCase(); const filteredData = useMemo(() => {
const filteredData = useMemo( if (!mergedData) {
() => return { backlinks: [], referringDomains: [], topPages: [] };
filterOverviewData( }
mergedData, return {
normalizedFilter, backlinks: filterBacklinkRows(
searchState.tab, mergedData.backlinks,
hideSpam, filters.backlinks.values,
spamThreshold,
), ),
[hideSpam, mergedData, normalizedFilter, searchState.tab, spamThreshold], referringDomains: filterReferringDomainRows(
); mergedData.referringDomains,
filters.domains.values,
),
topPages: filterTopPageRows(mergedData.topPages, filters.pages.values),
};
}, [
mergedData,
filters.backlinks.values,
filters.domains.values,
filters.pages.values,
]);
const summaryStats = useMemo( const summaryStats = useMemo(
() => buildSummaryStats(mergedData), () => buildSummaryStats(mergedData),
[mergedData], [mergedData],
@ -166,17 +169,12 @@ export function BacklinksBody({
<BacklinksResultsCard <BacklinksResultsCard
activeTab={searchState.tab} activeTab={searchState.tab}
filteredData={filteredData} filteredData={filteredData}
filterText={filterText} filters={filters}
hideSpam={hideSpam}
spamThreshold={spamThreshold}
isTabLoading={searchState.tab !== "backlinks" && tabLoading} isTabLoading={searchState.tab !== "backlinks" && tabLoading}
tabErrorMessage={ tabErrorMessage={
searchState.tab !== "backlinks" ? tabErrorMessage : null searchState.tab !== "backlinks" ? tabErrorMessage : null
} }
onFilterTextChange={setFilterText}
onSetActiveTab={onSetActiveTab} onSetActiveTab={onSetActiveTab}
onSetHideSpam={onSetHideSpam}
onSetSpamThreshold={onSetSpamThreshold}
exportTarget={mergedData.displayTarget || searchState.target} exportTarget={mergedData.displayTarget || searchState.target}
/> />
</> </>
@ -198,39 +196,3 @@ function mergeTabData(
topPages: topPages ?? data.topPages, topPages: topPages ?? data.topPages,
}; };
} }
function filterOverviewData(
data: BacklinksOverviewData | undefined,
normalizedFilter: string,
activeTab: BacklinksSearchState["tab"],
hideSpam: boolean,
spamThreshold: number,
) {
if (!data) {
return { backlinks: [], referringDomains: [], topPages: [] };
}
const backlinksRows =
activeTab === "backlinks" && hideSpam
? data.backlinks.filter(
(row) => row.spamScore == null || row.spamScore <= spamThreshold,
)
: data.backlinks;
return {
backlinks: backlinksRows.filter((row) => {
if (!normalizedFilter) return true;
return [row.domainFrom, row.urlFrom, row.urlTo, row.anchor, row.itemType]
.filter((value): value is string => Boolean(value))
.some((value) => value.toLowerCase().includes(normalizedFilter));
}),
referringDomains: data.referringDomains.filter((row) => {
if (!normalizedFilter) return true;
return row.domain?.toLowerCase().includes(normalizedFilter) ?? false;
}),
topPages: data.topPages.filter((row) => {
if (!normalizedFilter) return true;
return row.page?.toLowerCase().includes(normalizedFilter) ?? false;
}),
};
}

View File

@ -1,10 +1,10 @@
import { HeaderHelpLabel } from "@/client/features/keywords/components"; import { HeaderHelpLabel } from "@/client/features/keywords/components";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft, Download, SlidersHorizontal } from "lucide-react";
import { import {
BacklinksNewLostChart, BacklinksNewLostChart,
BacklinksTrendChart, BacklinksTrendChart,
} from "./BacklinksPageCharts"; } from "./BacklinksPageCharts";
import { ResultsHeader } from "./BacklinksResultsHeader"; import { BacklinksFilterPanel } from "./BacklinksFilterPanel";
import { BacklinksTable } from "./BacklinksTable"; import { BacklinksTable } from "./BacklinksTable";
import { ReferringDomainsTable } from "./ReferringDomainsTable"; import { ReferringDomainsTable } from "./ReferringDomainsTable";
import { TopPagesTable } from "./TopPagesTable"; import { TopPagesTable } from "./TopPagesTable";
@ -12,7 +12,12 @@ import type {
BacklinksOverviewData, BacklinksOverviewData,
BacklinksSearchState, BacklinksSearchState,
} from "./backlinksPageTypes"; } from "./backlinksPageTypes";
import { formatRelativeTimestamp } from "./backlinksPageUtils"; import {
TAB_DESCRIPTIONS,
formatRelativeTimestamp,
} from "./backlinksPageUtils";
import { exportBacklinksTabCsv } from "./export";
import type { BacklinksFiltersState } from "./useBacklinksFilters";
export function BacklinksOverviewPanels({ export function BacklinksOverviewPanels({
data, data,
@ -58,15 +63,10 @@ export function BacklinksOverviewPanels({
export function BacklinksResultsCard({ export function BacklinksResultsCard({
activeTab, activeTab,
filteredData, filteredData,
filterText, filters,
hideSpam,
spamThreshold,
isTabLoading, isTabLoading,
tabErrorMessage, tabErrorMessage,
onFilterTextChange,
onSetActiveTab, onSetActiveTab,
onSetHideSpam,
onSetSpamThreshold,
exportTarget, exportTarget,
}: { }: {
activeTab: BacklinksSearchState["tab"]; activeTab: BacklinksSearchState["tab"];
@ -75,34 +75,84 @@ export function BacklinksResultsCard({
referringDomains: BacklinksOverviewData["referringDomains"]; referringDomains: BacklinksOverviewData["referringDomains"];
topPages: BacklinksOverviewData["topPages"]; topPages: BacklinksOverviewData["topPages"];
}; };
filterText: string; filters: BacklinksFiltersState;
hideSpam: boolean;
spamThreshold: number;
isTabLoading: boolean; isTabLoading: boolean;
tabErrorMessage: string | null; tabErrorMessage: string | null;
onFilterTextChange: (value: string) => void;
onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void; onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void;
onSetHideSpam: (hideSpam: boolean) => void;
onSetSpamThreshold: (threshold: number) => void;
exportTarget: string; exportTarget: string;
}) { }) {
const currentFilterCount = filters[activeTab].activeFilterCount;
return ( return (
<div className="card bg-base-100 border border-base-300"> <div className="border border-base-300 rounded-xl bg-base-100 overflow-hidden">
<div className="card-body gap-3"> <div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 px-4 py-3 border-b border-base-300">
<ResultsHeader <div className="space-y-2">
activeTab={activeTab} <div role="tablist" className="tabs tabs-box w-fit">
filterText={filterText} <TabButton
hideSpam={hideSpam} activeTab={activeTab}
spamThreshold={spamThreshold} tab="backlinks"
onFilterTextChange={onFilterTextChange} onClick={onSetActiveTab}
onSetActiveTab={onSetActiveTab} >
onSetHideSpam={onSetHideSpam} Backlinks
onSetSpamThreshold={onSetSpamThreshold} </TabButton>
filteredData={filteredData} <TabButton
exportTarget={exportTarget} 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 ? ( {tabErrorMessage ? (
<div className="alert alert-error"> <div className="alert alert-error mb-3">
<span>{tabErrorMessage}</span> <span>{tabErrorMessage}</span>
</div> </div>
) : null} ) : null}
@ -216,6 +266,28 @@ 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 }) { function TabLoadingState({ label }: { label: string }) {
return ( return (
<div className="space-y-3 py-2"> <div className="space-y-3 py-2">

View File

@ -1,162 +0,0 @@
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,67 @@
export type BacklinksTabFilterValues = {
include: string;
exclude: string;
minDomainRank: string;
maxDomainRank: string;
minLinkAuthority: string;
maxLinkAuthority: string;
minSpamScore: string;
maxSpamScore: string;
linkType: "" | "dofollow" | "nofollow";
hideLost: "" | "true";
hideBroken: "" | "true";
};
export type ReferringDomainsFilterValues = {
include: string;
exclude: string;
minBacklinks: string;
maxBacklinks: string;
minRank: string;
maxRank: string;
};
export type TopPagesFilterValues = {
include: string;
exclude: string;
minBacklinks: string;
maxBacklinks: string;
minReferringDomains: string;
maxReferringDomains: string;
minRank: string;
maxRank: string;
};
export const EMPTY_BACKLINKS_FILTERS: BacklinksTabFilterValues = {
include: "",
exclude: "",
minDomainRank: "",
maxDomainRank: "",
minLinkAuthority: "",
maxLinkAuthority: "",
minSpamScore: "",
maxSpamScore: "",
linkType: "",
hideLost: "",
hideBroken: "",
};
export const EMPTY_REFERRING_DOMAINS_FILTERS: ReferringDomainsFilterValues = {
include: "",
exclude: "",
minBacklinks: "",
maxBacklinks: "",
minRank: "",
maxRank: "",
};
export const EMPTY_TOP_PAGES_FILTERS: TopPagesFilterValues = {
include: "",
exclude: "",
minBacklinks: "",
maxBacklinks: "",
minReferringDomains: "",
maxReferringDomains: "",
minRank: "",
maxRank: "",
};

View File

@ -0,0 +1,146 @@
import { parseTerms } from "@/client/features/keywords/utils";
import type { BacklinksOverviewData } from "./backlinksPageTypes";
import type {
BacklinksTabFilterValues,
ReferringDomainsFilterValues,
TopPagesFilterValues,
} from "./backlinksFilterTypes";
function passesNumericFilter(
value: number | null | undefined,
min: string,
max: string,
): boolean {
if (value == null) return true;
if (min && value < Number(min)) return false;
if (max && value > Number(max)) return false;
return true;
}
function passesTextFilter(
haystack: string,
includeTerms: string[],
excludeTerms: string[],
): boolean {
const lower = haystack.toLowerCase();
if (
includeTerms.length > 0 &&
!includeTerms.some((term) => lower.includes(term))
) {
return false;
}
if (excludeTerms.some((term) => lower.includes(term))) {
return false;
}
return true;
}
export function filterBacklinkRows(
rows: BacklinksOverviewData["backlinks"],
filters: BacklinksTabFilterValues,
): BacklinksOverviewData["backlinks"] {
const includeTerms = parseTerms(filters.include);
const excludeTerms = parseTerms(filters.exclude);
return rows.filter((row) => {
const textFields = [row.domainFrom, row.urlFrom, row.urlTo, row.anchor]
.filter((v): v is string => Boolean(v))
.join(" ");
if (!passesTextFilter(textFields, includeTerms, excludeTerms)) return false;
if (
!passesNumericFilter(
row.domainFromRank,
filters.minDomainRank,
filters.maxDomainRank,
)
)
return false;
if (
!passesNumericFilter(
row.rank,
filters.minLinkAuthority,
filters.maxLinkAuthority,
)
)
return false;
if (
!passesNumericFilter(
row.spamScore,
filters.minSpamScore,
filters.maxSpamScore,
)
)
return false;
if (filters.linkType === "dofollow" && row.isDofollow !== true)
return false;
if (filters.linkType === "nofollow" && row.isDofollow !== false)
return false;
if (filters.hideLost === "true" && row.isLost) return false;
if (filters.hideBroken === "true" && row.isBroken) return false;
return true;
});
}
export function filterReferringDomainRows(
rows: BacklinksOverviewData["referringDomains"],
filters: ReferringDomainsFilterValues,
): BacklinksOverviewData["referringDomains"] {
const includeTerms = parseTerms(filters.include);
const excludeTerms = parseTerms(filters.exclude);
return rows.filter((row) => {
if (!passesTextFilter(row.domain ?? "", includeTerms, excludeTerms))
return false;
if (
!passesNumericFilter(
row.backlinks,
filters.minBacklinks,
filters.maxBacklinks,
)
)
return false;
if (!passesNumericFilter(row.rank, filters.minRank, filters.maxRank))
return false;
return true;
});
}
export function filterTopPageRows(
rows: BacklinksOverviewData["topPages"],
filters: TopPagesFilterValues,
): BacklinksOverviewData["topPages"] {
const includeTerms = parseTerms(filters.include);
const excludeTerms = parseTerms(filters.exclude);
return rows.filter((row) => {
if (!passesTextFilter(row.page ?? "", includeTerms, excludeTerms))
return false;
if (
!passesNumericFilter(
row.backlinks,
filters.minBacklinks,
filters.maxBacklinks,
)
)
return false;
if (
!passesNumericFilter(
row.referringDomains,
filters.minReferringDomains,
filters.maxReferringDomains,
)
)
return false;
if (!passesNumericFilter(row.rank, filters.minRank, filters.maxRank))
return false;
return true;
});
}
export function countActiveFilters(values: Record<string, string>): number {
return Object.values(values).filter((v) => v.trim() !== "").length;
}

View File

@ -0,0 +1,94 @@
import { useCallback, useEffect, useState } from "react";
import { useForm, useStore } from "@tanstack/react-form";
import {
EMPTY_BACKLINKS_FILTERS,
EMPTY_REFERRING_DOMAINS_FILTERS,
EMPTY_TOP_PAGES_FILTERS,
type BacklinksTabFilterValues,
type ReferringDomainsFilterValues,
type TopPagesFilterValues,
} from "./backlinksFilterTypes";
import { countActiveFilters } from "./backlinksFiltering";
const STORAGE_KEY_PREFIX = "backlinks-filters:";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function loadFromStorage<T extends Record<string, string>>(
tab: string,
fallback: T,
): T {
try {
const raw = localStorage.getItem(`${STORAGE_KEY_PREFIX}${tab}`);
if (!raw) return fallback;
const parsed: unknown = JSON.parse(raw);
if (!isRecord(parsed)) return fallback;
const result = { ...fallback };
for (const key of Object.keys(fallback)) {
const val = parsed[key];
if (typeof val === "string") {
(result as Record<string, string>)[key] = val;
}
}
return result;
} catch {
return fallback;
}
}
function saveToStorage(tab: string, values: Record<string, string>) {
try {
localStorage.setItem(`${STORAGE_KEY_PREFIX}${tab}`, JSON.stringify(values));
} catch {
// storage full — silently ignore
}
}
function useTabFilterForm<T extends Record<string, string>>(
tab: string,
emptyValues: T,
) {
const form = useForm({ defaultValues: loadFromStorage(tab, emptyValues) });
const values = useStore(form.store, (s) => s.values);
useEffect(() => {
saveToStorage(tab, values);
}, [tab, values]);
const reset = useCallback(() => {
form.reset(emptyValues);
}, [form, emptyValues]);
const activeFilterCount = countActiveFilters(values);
return { form, values, reset, activeFilterCount };
}
export function useBacklinksFilters() {
const [showFilters, setShowFilters] = useState(false);
const backlinks = useTabFilterForm<BacklinksTabFilterValues>(
"backlinks",
EMPTY_BACKLINKS_FILTERS,
);
const domains = useTabFilterForm<ReferringDomainsFilterValues>(
"domains",
EMPTY_REFERRING_DOMAINS_FILTERS,
);
const pages = useTabFilterForm<TopPagesFilterValues>(
"pages",
EMPTY_TOP_PAGES_FILTERS,
);
return {
backlinks,
domains,
pages,
showFilters,
setShowFilters,
};
}
export type BacklinksFiltersState = ReturnType<typeof useBacklinksFilters>;

View File

@ -195,6 +195,11 @@ function buildBacklinksRequestInput(
projectId, projectId,
target: searchState.target, target: searchState.target,
scope: searchState.scope, scope: searchState.scope,
// Server-side spam filtering (hideSpam/spamThreshold) is available but
// intentionally disabled. All filtering — including spam score — is applied
// client-side so users get immediate feedback without re-fetching. This
// trades slightly larger API responses for simpler code and flexibility.
hideSpam: false,
}; };
} }

View File

@ -1,100 +0,0 @@
import { useEffect, useState } from "react";
import {
DEFAULT_BACKLINKS_SPAM_THRESHOLD,
normalizeBacklinksSpamThreshold,
} from "@/types/schemas/backlinks";
const STORAGE_KEY = "backlinks-spam-preferences";
type BacklinksSpamPreferences = {
hideSpam: boolean;
spamThreshold: number;
};
const DEFAULT_PREFERENCES: BacklinksSpamPreferences = {
hideSpam: true,
spamThreshold: DEFAULT_BACKLINKS_SPAM_THRESHOLD,
};
function normalizeBacklinksSpamPreferences(
value: unknown,
): BacklinksSpamPreferences {
if (!value || typeof value !== "object") {
return DEFAULT_PREFERENCES;
}
const preferences = value as {
hideSpam?: unknown;
spamThreshold?: unknown;
};
return {
hideSpam:
typeof preferences.hideSpam === "boolean"
? preferences.hideSpam
: DEFAULT_PREFERENCES.hideSpam,
spamThreshold: normalizeBacklinksSpamThreshold(
typeof preferences.spamThreshold === "number"
? preferences.spamThreshold
: DEFAULT_PREFERENCES.spamThreshold,
),
};
}
function loadBacklinksSpamPreferences() {
if (typeof window === "undefined") {
return DEFAULT_PREFERENCES;
}
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULT_PREFERENCES;
return normalizeBacklinksSpamPreferences(JSON.parse(raw));
} catch {
return DEFAULT_PREFERENCES;
}
}
function saveBacklinksSpamPreferences(preferences: BacklinksSpamPreferences) {
if (typeof window === "undefined") {
return;
}
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences));
} catch {
// storage full or unavailable - silently ignore
}
}
export function useBacklinksSpamPreferences() {
const [preferences, setPreferences] = useState<BacklinksSpamPreferences>(
loadBacklinksSpamPreferences,
);
useEffect(() => {
saveBacklinksSpamPreferences(preferences);
}, [preferences]);
function setHideSpam(nextHideSpam: boolean) {
setPreferences((current) => ({
...current,
hideSpam: nextHideSpam,
}));
}
function setSpamThreshold(nextSpamThreshold: number) {
setPreferences((current) => ({
...current,
spamThreshold: normalizeBacklinksSpamThreshold(nextSpamThreshold),
}));
}
return {
hideSpam: preferences.hideSpam,
setHideSpam,
setSpamThreshold,
spamThreshold: preferences.spamThreshold,
};
}

View File

@ -162,40 +162,44 @@ export function DomainResultsCard({
</div> </div>
</div> </div>
<div className="flex items-center gap-2 px-4 py-2 border-b border-base-300"> {isKeywordsTab ? (
<button <>
className={`btn btn-ghost btn-sm gap-1.5 ${showFilters ? "btn-active" : ""}`} <div className="flex items-center gap-2 px-4 py-2 border-b border-base-300">
onClick={() => setShowFilters((prev) => !prev)} <button
title="Toggle filters" className={`btn btn-ghost btn-sm gap-1.5 ${showFilters ? "btn-active" : ""}`}
> onClick={() => setShowFilters((prev) => !prev)}
<SlidersHorizontal className="size-3.5" /> title="Toggle filters"
Filters >
{activeFilterCount > 0 ? ( <SlidersHorizontal className="size-3.5" />
<span className="badge badge-xs badge-primary border-0 text-primary-content"> Filters
{activeFilterCount} {activeFilterCount > 0 ? (
<span className="badge badge-xs badge-primary border-0 text-primary-content">
{activeFilterCount}
</span>
) : null}
</button>
<span className="text-sm text-base-content/60">
{filteredKeywords.length} keywords
</span> </span>
) : null} <div className="flex-1" />
</button> <label className="input input-bordered input-sm w-full max-w-xs flex items-center gap-2">
<span className="text-sm text-base-content/60"> <Search className="size-4 text-base-content/60" />
{filteredKeywords.length} keywords <input
</span> placeholder="Search in results"
<div className="flex-1" /> value={pendingSearch}
<label className="input input-bordered input-sm w-full max-w-xs flex items-center gap-2"> onChange={(event) => onSearchChange(event.target.value)}
<Search className="size-4 text-base-content/60" /> />
<input </label>
placeholder="Search in results" </div>
value={pendingSearch}
onChange={(event) => onSearchChange(event.target.value)}
/>
</label>
</div>
{showFilters ? ( {showFilters ? (
<DomainFilterPanel <DomainFilterPanel
filtersForm={filtersForm} filtersForm={filtersForm}
activeFilterCount={activeFilterCount} activeFilterCount={activeFilterCount}
resetFilters={resetFilters} resetFilters={resetFilters}
/> />
) : null}
</>
) : null} ) : null}
<div className="p-4"> <div className="p-4">

View File

@ -19,7 +19,15 @@ export const getBacklinksOverview = createServerFn({
target: data.target, target: data.target,
scope: data.scope, scope: data.scope,
}; };
const profile = await BacklinksService.profileOverview(input, context); const spamOptions = {
hideSpam: data.hideSpam,
spamThreshold: data.spamThreshold,
};
const profile = await BacklinksService.profileOverview(
input,
context,
spamOptions,
);
return profile.overview; return profile.overview;
} catch (error) { } catch (error) {
if (error instanceof AppError && error.code === "BACKLINKS_NOT_ENABLED") { if (error instanceof AppError && error.code === "BACKLINKS_NOT_ENABLED") {

View File

@ -2,9 +2,9 @@ import { z } from "zod";
export const backlinksTabSchema = z.enum(["backlinks", "domains", "pages"]); export const backlinksTabSchema = z.enum(["backlinks", "domains", "pages"]);
export const backlinksTargetScopeSchema = z.enum(["domain", "page"]); export const backlinksTargetScopeSchema = z.enum(["domain", "page"]);
export const DEFAULT_BACKLINKS_SPAM_THRESHOLD = 40; const DEFAULT_BACKLINKS_SPAM_THRESHOLD = 40;
export function normalizeBacklinksSpamThreshold(value: number) { function normalizeBacklinksSpamThreshold(value: number) {
if (!Number.isFinite(value)) { if (!Number.isFinite(value)) {
return DEFAULT_BACKLINKS_SPAM_THRESHOLD; return DEFAULT_BACKLINKS_SPAM_THRESHOLD;
} }