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:
parent
b3d543d206
commit
93c95dc8f7
347
src/client/features/backlinks/BacklinksFilterPanel.tsx
Normal file
347
src/client/features/backlinks/BacklinksFilterPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -7,8 +7,8 @@ import {
|
||||
navigateToBacklinksTab,
|
||||
useBacklinksPageData,
|
||||
} from "./useBacklinksPageData";
|
||||
import { useBacklinksFilters } from "./useBacklinksFilters";
|
||||
import { useBacklinksSearchHistory } from "@/client/hooks/useBacklinksSearchHistory";
|
||||
import { useBacklinksSpamPreferences } from "./useBacklinksSpamPreferences";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
|
||||
export function BacklinksPage({
|
||||
@ -16,8 +16,7 @@ export function BacklinksPage({
|
||||
searchState,
|
||||
navigate,
|
||||
}: BacklinksPageProps) {
|
||||
const { hideSpam, setHideSpam, setSpamThreshold, spamThreshold } =
|
||||
useBacklinksSpamPreferences();
|
||||
const filters = useBacklinksFilters();
|
||||
const {
|
||||
accessStatus,
|
||||
accessStatusErrorMessage,
|
||||
@ -95,6 +94,7 @@ export function BacklinksPage({
|
||||
overviewLoading={overviewQuery.isLoading}
|
||||
referringDomains={referringDomainsQuery.data}
|
||||
searchState={searchState}
|
||||
filters={filters}
|
||||
tabErrorMessage={activeTabErrorMessage}
|
||||
tabLoading={
|
||||
(searchState.tab === "domains" &&
|
||||
@ -111,8 +111,6 @@ export function BacklinksPage({
|
||||
}
|
||||
testIsPending={testAccessMutation.isPending}
|
||||
topPages={topPagesQuery.data}
|
||||
hideSpam={hideSpam}
|
||||
spamThreshold={spamThreshold}
|
||||
onRemoveHistoryItem={removeHistoryItem}
|
||||
onRetryAccess={() => void accessStatusQuery.refetch()}
|
||||
onSelectHistoryItem={handleHistorySelect}
|
||||
@ -120,8 +118,6 @@ export function BacklinksPage({
|
||||
onSetActiveTab={(tab) => navigateToBacklinksTab(navigate, tab)}
|
||||
onRetryOverview={() => void overviewQuery.refetch()}
|
||||
onTestAccess={() => testAccessMutation.mutate()}
|
||||
onSetHideSpam={setHideSpam}
|
||||
onSetSpamThreshold={setSpamThreshold}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
BacklinksOverviewPanels,
|
||||
BacklinksResultsCard,
|
||||
@ -19,6 +19,12 @@ import type {
|
||||
BacklinksTopPagesData,
|
||||
} from "./backlinksPageTypes";
|
||||
import { buildSummaryStats } from "./backlinksPageUtils";
|
||||
import {
|
||||
filterBacklinkRows,
|
||||
filterReferringDomainRows,
|
||||
filterTopPageRows,
|
||||
} from "./backlinksFiltering";
|
||||
import type { BacklinksFiltersState } from "./useBacklinksFilters";
|
||||
|
||||
type BacklinksBodyProps = {
|
||||
accessStatus: BacklinksAccessStatusData | undefined;
|
||||
@ -28,13 +34,12 @@ type BacklinksBodyProps = {
|
||||
history: BacklinksSearchHistoryItem[];
|
||||
historyLoaded: boolean;
|
||||
isAccessStatusLoading: boolean;
|
||||
hideSpam: boolean;
|
||||
overviewData: BacklinksOverviewData | undefined;
|
||||
overviewError: string | null;
|
||||
overviewLoading: boolean;
|
||||
referringDomains: BacklinksReferringDomainsData | undefined;
|
||||
searchState: BacklinksSearchState;
|
||||
spamThreshold: number;
|
||||
filters: BacklinksFiltersState;
|
||||
tabErrorMessage: string | null;
|
||||
tabLoading: boolean;
|
||||
testError: string | null;
|
||||
@ -47,8 +52,6 @@ type BacklinksBodyProps = {
|
||||
onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void;
|
||||
onRetryOverview: () => void;
|
||||
onTestAccess: () => void;
|
||||
onSetHideSpam: (hideSpam: boolean) => void;
|
||||
onSetSpamThreshold: (threshold: number) => void;
|
||||
};
|
||||
|
||||
export function BacklinksBody({
|
||||
@ -59,13 +62,12 @@ export function BacklinksBody({
|
||||
history,
|
||||
historyLoaded,
|
||||
isAccessStatusLoading,
|
||||
hideSpam,
|
||||
overviewData,
|
||||
overviewError,
|
||||
overviewLoading,
|
||||
referringDomains,
|
||||
searchState,
|
||||
spamThreshold,
|
||||
filters,
|
||||
tabErrorMessage,
|
||||
tabLoading,
|
||||
testError,
|
||||
@ -78,31 +80,32 @@ export function BacklinksBody({
|
||||
onSetActiveTab,
|
||||
onRetryOverview,
|
||||
onTestAccess,
|
||||
onSetHideSpam,
|
||||
onSetSpamThreshold,
|
||||
}: BacklinksBodyProps) {
|
||||
const [filterText, setFilterText] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setFilterText("");
|
||||
}, [searchState.target, searchState.tab]);
|
||||
|
||||
const mergedData = useMemo(
|
||||
() => mergeTabData(overviewData, referringDomains, topPages),
|
||||
[overviewData, referringDomains, topPages],
|
||||
);
|
||||
const normalizedFilter = filterText.trim().toLowerCase();
|
||||
const filteredData = useMemo(
|
||||
() =>
|
||||
filterOverviewData(
|
||||
mergedData,
|
||||
normalizedFilter,
|
||||
searchState.tab,
|
||||
hideSpam,
|
||||
spamThreshold,
|
||||
const filteredData = useMemo(() => {
|
||||
if (!mergedData) {
|
||||
return { backlinks: [], referringDomains: [], topPages: [] };
|
||||
}
|
||||
return {
|
||||
backlinks: filterBacklinkRows(
|
||||
mergedData.backlinks,
|
||||
filters.backlinks.values,
|
||||
),
|
||||
[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(
|
||||
() => buildSummaryStats(mergedData),
|
||||
[mergedData],
|
||||
@ -166,17 +169,12 @@ export function BacklinksBody({
|
||||
<BacklinksResultsCard
|
||||
activeTab={searchState.tab}
|
||||
filteredData={filteredData}
|
||||
filterText={filterText}
|
||||
hideSpam={hideSpam}
|
||||
spamThreshold={spamThreshold}
|
||||
filters={filters}
|
||||
isTabLoading={searchState.tab !== "backlinks" && tabLoading}
|
||||
tabErrorMessage={
|
||||
searchState.tab !== "backlinks" ? tabErrorMessage : null
|
||||
}
|
||||
onFilterTextChange={setFilterText}
|
||||
onSetActiveTab={onSetActiveTab}
|
||||
onSetHideSpam={onSetHideSpam}
|
||||
onSetSpamThreshold={onSetSpamThreshold}
|
||||
exportTarget={mergedData.displayTarget || searchState.target}
|
||||
/>
|
||||
</>
|
||||
@ -198,39 +196,3 @@ function mergeTabData(
|
||||
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;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { HeaderHelpLabel } from "@/client/features/keywords/components";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { ArrowLeft, Download, SlidersHorizontal } from "lucide-react";
|
||||
import {
|
||||
BacklinksNewLostChart,
|
||||
BacklinksTrendChart,
|
||||
} from "./BacklinksPageCharts";
|
||||
import { ResultsHeader } from "./BacklinksResultsHeader";
|
||||
import { BacklinksFilterPanel } from "./BacklinksFilterPanel";
|
||||
import { BacklinksTable } from "./BacklinksTable";
|
||||
import { ReferringDomainsTable } from "./ReferringDomainsTable";
|
||||
import { TopPagesTable } from "./TopPagesTable";
|
||||
@ -12,7 +12,12 @@ import type {
|
||||
BacklinksOverviewData,
|
||||
BacklinksSearchState,
|
||||
} from "./backlinksPageTypes";
|
||||
import { formatRelativeTimestamp } from "./backlinksPageUtils";
|
||||
import {
|
||||
TAB_DESCRIPTIONS,
|
||||
formatRelativeTimestamp,
|
||||
} from "./backlinksPageUtils";
|
||||
import { exportBacklinksTabCsv } from "./export";
|
||||
import type { BacklinksFiltersState } from "./useBacklinksFilters";
|
||||
|
||||
export function BacklinksOverviewPanels({
|
||||
data,
|
||||
@ -58,15 +63,10 @@ export function BacklinksOverviewPanels({
|
||||
export function BacklinksResultsCard({
|
||||
activeTab,
|
||||
filteredData,
|
||||
filterText,
|
||||
hideSpam,
|
||||
spamThreshold,
|
||||
filters,
|
||||
isTabLoading,
|
||||
tabErrorMessage,
|
||||
onFilterTextChange,
|
||||
onSetActiveTab,
|
||||
onSetHideSpam,
|
||||
onSetSpamThreshold,
|
||||
exportTarget,
|
||||
}: {
|
||||
activeTab: BacklinksSearchState["tab"];
|
||||
@ -75,34 +75,84 @@ export function BacklinksResultsCard({
|
||||
referringDomains: BacklinksOverviewData["referringDomains"];
|
||||
topPages: BacklinksOverviewData["topPages"];
|
||||
};
|
||||
filterText: string;
|
||||
hideSpam: boolean;
|
||||
spamThreshold: number;
|
||||
filters: BacklinksFiltersState;
|
||||
isTabLoading: boolean;
|
||||
tabErrorMessage: string | null;
|
||||
onFilterTextChange: (value: string) => void;
|
||||
onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void;
|
||||
onSetHideSpam: (hideSpam: boolean) => void;
|
||||
onSetSpamThreshold: (threshold: number) => void;
|
||||
exportTarget: string;
|
||||
}) {
|
||||
const currentFilterCount = filters[activeTab].activeFilterCount;
|
||||
|
||||
return (
|
||||
<div className="card bg-base-100 border border-base-300">
|
||||
<div className="card-body gap-3">
|
||||
<ResultsHeader
|
||||
<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}
|
||||
filterText={filterText}
|
||||
hideSpam={hideSpam}
|
||||
spamThreshold={spamThreshold}
|
||||
onFilterTextChange={onFilterTextChange}
|
||||
onSetActiveTab={onSetActiveTab}
|
||||
onSetHideSpam={onSetHideSpam}
|
||||
onSetSpamThreshold={onSetSpamThreshold}
|
||||
filteredData={filteredData}
|
||||
exportTarget={exportTarget}
|
||||
/>
|
||||
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">
|
||||
<div className="alert alert-error mb-3">
|
||||
<span>{tabErrorMessage}</span>
|
||||
</div>
|
||||
) : 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 }) {
|
||||
return (
|
||||
<div className="space-y-3 py-2">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
67
src/client/features/backlinks/backlinksFilterTypes.ts
Normal file
67
src/client/features/backlinks/backlinksFilterTypes.ts
Normal 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: "",
|
||||
};
|
||||
146
src/client/features/backlinks/backlinksFiltering.ts
Normal file
146
src/client/features/backlinks/backlinksFiltering.ts
Normal 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;
|
||||
}
|
||||
94
src/client/features/backlinks/useBacklinksFilters.ts
Normal file
94
src/client/features/backlinks/useBacklinksFilters.ts
Normal 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>;
|
||||
@ -195,6 +195,11 @@ function buildBacklinksRequestInput(
|
||||
projectId,
|
||||
target: searchState.target,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -162,6 +162,8 @@ export function DomainResultsCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isKeywordsTab ? (
|
||||
<>
|
||||
<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 ${showFilters ? "btn-active" : ""}`}
|
||||
@ -197,6 +199,8 @@ export function DomainResultsCard({
|
||||
resetFilters={resetFilters}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="p-4">
|
||||
{isKeywordsTab ? (
|
||||
|
||||
@ -19,7 +19,15 @@ export const getBacklinksOverview = createServerFn({
|
||||
target: data.target,
|
||||
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;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError && error.code === "BACKLINKS_NOT_ENABLED") {
|
||||
|
||||
@ -2,9 +2,9 @@ import { z } from "zod";
|
||||
|
||||
export const backlinksTabSchema = z.enum(["backlinks", "domains", "pages"]);
|
||||
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)) {
|
||||
return DEFAULT_BACKLINKS_SPAM_THRESHOLD;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user