diff --git a/src/client/features/backlinks/BacklinksFilterPanel.tsx b/src/client/features/backlinks/BacklinksFilterPanel.tsx new file mode 100644 index 0000000..3ba5193 --- /dev/null +++ b/src/client/features/backlinks/BacklinksFilterPanel.tsx @@ -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 }; + +function FilterTextInput({ + form, + name, + label, + placeholder, +}: { + form: AnyForm; + name: string; + label: string; + placeholder: string; +}) { + return ( + + ); +} + +function FilterRangeInputs({ + form, + title, + minName, + maxName, + step, +}: { + form: AnyForm; + title: string; + minName: string; + maxName: string; + step?: string; +}) { + return ( +
+

+ {title} +

+
+ + +
+
+ ); +} + +function CompactRangeInput({ + form, + name, + placeholder, + step, +}: { + form: AnyForm; + name: string; + placeholder: string; + step?: string; +}) { + return ( + + {(field: { + state: { value: string }; + handleChange: (v: string) => void; + }) => ( + field.handleChange(event.target.value)} + /> + )} + + ); +} + +function BacklinksTabFilters({ + form, +}: { + form: BacklinksFiltersState["backlinks"]["form"]; +}) { + return ( + <> +
+ + +
+ +
+ + + +
+ +
+
+

+ Link Type +

+ + {(field) => ( +
+ {(["", "dofollow", "nofollow"] as const).map((value) => ( + + ))} +
+ )} +
+
+ +
+

+ Visibility +

+
+ + {(field) => ( + + )} + + + {(field) => ( + + )} + +
+
+
+ + ); +} + +function ReferringDomainsFilters({ + form, +}: { + form: BacklinksFiltersState["domains"]["form"]; +}) { + return ( + <> +
+ + +
+ +
+ + +
+ + ); +} + +function TopPagesFilters({ + form, +}: { + form: BacklinksFiltersState["pages"]["form"]; +}) { + return ( + <> +
+ + +
+ +
+ + + +
+ + ); +} + +export function BacklinksFilterPanel({ + activeTab, + filters, +}: { + activeTab: BacklinksTab; + filters: BacklinksFiltersState; +}) { + const current = filters[activeTab]; + + return ( +
+
+
+

Refine results

+ {current.activeFilterCount > 0 ? ( + + {current.activeFilterCount} active + + ) : null} +
+ +
+ + {activeTab === "backlinks" ? ( + + ) : null} + {activeTab === "domains" ? ( + + ) : null} + {activeTab === "pages" ? ( + + ) : null} +
+ ); +} diff --git a/src/client/features/backlinks/BacklinksPage.tsx b/src/client/features/backlinks/BacklinksPage.tsx index e9917d0..daf2678 100644 --- a/src/client/features/backlinks/BacklinksPage.tsx +++ b/src/client/features/backlinks/BacklinksPage.tsx @@ -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} /> diff --git a/src/client/features/backlinks/BacklinksPageContent.tsx b/src/client/features/backlinks/BacklinksPageContent.tsx index 47d9f83..f91cd03 100644 --- a/src/client/features/backlinks/BacklinksPageContent.tsx +++ b/src/client/features/backlinks/BacklinksPageContent.tsx @@ -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({ @@ -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; - }), - }; -} diff --git a/src/client/features/backlinks/BacklinksPageSections.tsx b/src/client/features/backlinks/BacklinksPageSections.tsx index f51d763..53ecafb 100644 --- a/src/client/features/backlinks/BacklinksPageSections.tsx +++ b/src/client/features/backlinks/BacklinksPageSections.tsx @@ -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 ( -
-
- +
+
+
+
+ + Backlinks + + + Referring Domains + + + Top Pages + +
+

+ {TAB_DESCRIPTIONS[activeTab]} +

+
+ + +
+ +
+ +
+ + {filters.showFilters ? ( + + ) : null} + +
{tabErrorMessage ? ( -
+
{tabErrorMessage}
) : 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 ( + + ); +} + function TabLoadingState({ label }: { label: string }) { return (
diff --git a/src/client/features/backlinks/BacklinksResultsHeader.tsx b/src/client/features/backlinks/BacklinksResultsHeader.tsx deleted file mode 100644 index ce0b7e5..0000000 --- a/src/client/features/backlinks/BacklinksResultsHeader.tsx +++ /dev/null @@ -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 ( -
-
-
- - Backlinks - - - Referring Domains - - - Top Pages - -
-

- {TAB_DESCRIPTIONS[activeTab]} -

-
- -
- - - {activeTab === "backlinks" ? ( -
- - -
- ) : null} -
-
- ); -} - -function TabButton({ - activeTab, - children, - onClick, - tab, -}: { - activeTab: BacklinksSearchState["tab"]; - children: string; - onClick: (tab: BacklinksSearchState["tab"]) => void; - tab: BacklinksSearchState["tab"]; -}) { - return ( - - ); -} diff --git a/src/client/features/backlinks/backlinksFilterTypes.ts b/src/client/features/backlinks/backlinksFilterTypes.ts new file mode 100644 index 0000000..a391951 --- /dev/null +++ b/src/client/features/backlinks/backlinksFilterTypes.ts @@ -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: "", +}; diff --git a/src/client/features/backlinks/backlinksFiltering.ts b/src/client/features/backlinks/backlinksFiltering.ts new file mode 100644 index 0000000..e637b3e --- /dev/null +++ b/src/client/features/backlinks/backlinksFiltering.ts @@ -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): number { + return Object.values(values).filter((v) => v.trim() !== "").length; +} diff --git a/src/client/features/backlinks/useBacklinksFilters.ts b/src/client/features/backlinks/useBacklinksFilters.ts new file mode 100644 index 0000000..4245d55 --- /dev/null +++ b/src/client/features/backlinks/useBacklinksFilters.ts @@ -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 { + return typeof value === "object" && value !== null; +} + +function loadFromStorage>( + 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)[key] = val; + } + } + return result; + } catch { + return fallback; + } +} + +function saveToStorage(tab: string, values: Record) { + try { + localStorage.setItem(`${STORAGE_KEY_PREFIX}${tab}`, JSON.stringify(values)); + } catch { + // storage full — silently ignore + } +} + +function useTabFilterForm>( + 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( + "backlinks", + EMPTY_BACKLINKS_FILTERS, + ); + const domains = useTabFilterForm( + "domains", + EMPTY_REFERRING_DOMAINS_FILTERS, + ); + const pages = useTabFilterForm( + "pages", + EMPTY_TOP_PAGES_FILTERS, + ); + + return { + backlinks, + domains, + pages, + showFilters, + setShowFilters, + }; +} + +export type BacklinksFiltersState = ReturnType; diff --git a/src/client/features/backlinks/useBacklinksPageData.ts b/src/client/features/backlinks/useBacklinksPageData.ts index 467b71c..229ce5d 100644 --- a/src/client/features/backlinks/useBacklinksPageData.ts +++ b/src/client/features/backlinks/useBacklinksPageData.ts @@ -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, }; } diff --git a/src/client/features/backlinks/useBacklinksSpamPreferences.ts b/src/client/features/backlinks/useBacklinksSpamPreferences.ts deleted file mode 100644 index f5ddfae..0000000 --- a/src/client/features/backlinks/useBacklinksSpamPreferences.ts +++ /dev/null @@ -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( - 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, - }; -} diff --git a/src/client/features/domain/components/DomainResultsCard.tsx b/src/client/features/domain/components/DomainResultsCard.tsx index 4dc2520..0fb7436 100644 --- a/src/client/features/domain/components/DomainResultsCard.tsx +++ b/src/client/features/domain/components/DomainResultsCard.tsx @@ -162,40 +162,44 @@ export function DomainResultsCard({
-
- + + {filteredKeywords.length} keywords - ) : null} - - - {filteredKeywords.length} keywords - -
- -
+
+ +
- {showFilters ? ( - + {showFilters ? ( + + ) : null} + ) : null}
diff --git a/src/serverFunctions/backlinks.ts b/src/serverFunctions/backlinks.ts index fa1fe11..7993ac9 100644 --- a/src/serverFunctions/backlinks.ts +++ b/src/serverFunctions/backlinks.ts @@ -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") { diff --git a/src/types/schemas/backlinks.ts b/src/types/schemas/backlinks.ts index 48a8ffe..daaa059 100644 --- a/src/types/schemas/backlinks.ts +++ b/src/types/schemas/backlinks.ts @@ -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; }