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

93 lines
2.7 KiB
TypeScript

import { createServerFn } from "@tanstack/react-start";
import {
buildBacklinksDisabledAccessStatus,
setBacklinksAccessStatus,
} from "@/server/features/backlinks/backlinksAccess";
import { BacklinksService } from "@/server/features/backlinks/services/BacklinksService";
import { AppError } from "@/server/lib/errors";
import { requireProjectContext } from "@/serverFunctions/middleware";
import { backlinksOverviewInputSchema } from "@/types/schemas/backlinks";
export const getBacklinksOverview = createServerFn({
method: "POST",
})
.middleware(requireProjectContext)
.inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data))
.handler(async ({ data, context }) => {
try {
const input = {
target: data.target,
scope: data.scope,
};
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") {
const checkedAt = new Date().toISOString();
await setBacklinksAccessStatus(
buildBacklinksDisabledAccessStatus(checkedAt, error.code),
);
}
throw error;
}
});
export const getBacklinksReferringDomains = createServerFn({
method: "POST",
})
.middleware(requireProjectContext)
.inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data))
.handler(async ({ data, context }) => {
try {
const input = {
target: data.target,
scope: data.scope,
};
const profile = await BacklinksService.profileReferringDomains(
input,
context,
);
return profile.rows;
} catch (error) {
await updateBacklinksAccessStatusOnError(error);
throw error;
}
});
export const getBacklinksTopPages = createServerFn({
method: "POST",
})
.middleware(requireProjectContext)
.inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data))
.handler(async ({ data, context }) => {
try {
const input = {
target: data.target,
scope: data.scope,
};
const profile = await BacklinksService.profileTopPages(input, context);
return profile.rows;
} catch (error) {
await updateBacklinksAccessStatusOnError(error);
throw error;
}
});
async function updateBacklinksAccessStatusOnError(error: unknown) {
if (error instanceof AppError && error.code === "BACKLINKS_NOT_ENABLED") {
const checkedAt = new Date().toISOString();
await setBacklinksAccessStatus(
buildBacklinksDisabledAccessStatus(checkedAt, error.code),
);
}
}