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

126 lines
4.0 KiB
TypeScript

import { BacklinksSearchCard } from "./BacklinksSearchCard";
import { BacklinksBody } from "./BacklinksPageContent";
import type { BacklinksPageProps } from "./backlinksPageTypes";
import {
navigateToBacklinksHistory,
navigateToBacklinksSearch,
navigateToBacklinksTab,
useBacklinksPageData,
} from "./useBacklinksPageData";
import { useBacklinksFilters } from "./useBacklinksFilters";
import { useBacklinksSearchHistory } from "@/client/hooks/useBacklinksSearchHistory";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
export function BacklinksPage({
projectId,
searchState,
navigate,
}: BacklinksPageProps) {
const filters = useBacklinksFilters();
const {
accessStatus,
accessStatusErrorMessage,
accessStatusQuery,
activeTabErrorMessage,
backlinksDisabledByError,
backlinksEnabled,
overviewErrorMessage,
overviewQuery,
referringDomainsQuery,
searchCardInitialValues,
testAccessMutation,
topPagesQuery,
} = useBacklinksPageData({
projectId,
searchState,
});
const {
history,
isLoaded: historyLoaded,
addSearch,
removeHistoryItem,
} = useBacklinksSearchHistory(projectId);
const handleHistorySelect = (item: {
target: string;
scope: "domain" | "page";
}) => {
navigateToBacklinksSearch(navigate, {
target: item.target,
scope: item.scope,
});
};
return (
<div className="px-4 py-4 pb-24 overflow-auto md:px-6 md:py-6 md:pb-8">
<div className="mx-auto max-w-7xl space-y-4">
<div>
<h1 className="text-2xl font-semibold">Backlinks</h1>
<p className="text-sm text-base-content/70">
Understand who links to a site, what changed recently, and which
pages attract links.
</p>
</div>
{!accessStatusQuery.isLoading &&
backlinksEnabled &&
!backlinksDisabledByError ? (
<BacklinksSearchCard
errorMessage={overviewErrorMessage}
initialValues={searchCardInitialValues}
isFetching={
overviewQuery.isFetching ||
referringDomainsQuery.isFetching ||
topPagesQuery.isFetching
}
onSubmit={(values) => {
navigateToBacklinksSearch(navigate, values);
addSearch({ target: values.target, scope: values.scope });
}}
/>
) : null}
<BacklinksBody
accessStatus={accessStatus}
accessStatusError={accessStatusErrorMessage}
backlinksDisabledByError={backlinksDisabledByError}
backlinksEnabled={backlinksEnabled}
history={history}
historyLoaded={historyLoaded}
isAccessStatusLoading={accessStatusQuery.isLoading}
overviewData={overviewQuery.data}
overviewError={overviewErrorMessage}
overviewLoading={overviewQuery.isLoading}
referringDomains={referringDomainsQuery.data}
searchState={searchState}
filters={filters}
tabErrorMessage={activeTabErrorMessage}
tabLoading={
(searchState.tab === "domains" &&
referringDomainsQuery.isLoading) ||
(searchState.tab === "pages" && topPagesQuery.isLoading)
}
testError={
testAccessMutation.error
? getStandardErrorMessage(
testAccessMutation.error,
"Could not test Backlinks access.",
)
: null
}
testIsPending={testAccessMutation.isPending}
topPages={topPagesQuery.data}
onRemoveHistoryItem={removeHistoryItem}
onRetryAccess={() => void accessStatusQuery.refetch()}
onSelectHistoryItem={handleHistorySelect}
onShowHistory={() => navigateToBacklinksHistory(navigate)}
onSetActiveTab={(tab) => navigateToBacklinksTab(navigate, tab)}
onRetryOverview={() => void overviewQuery.refetch()}
onTestAccess={() => testAccessMutation.mutate()}
/>
</div>
</div>
);
}