feat: add backlink search history (#90)

* feat: add backlink search history

Save recent backlink searches so users can quickly rerun previous lookups instead of starting from an empty state each time. Also widen the keyword research empty state layout to better use the available space.

* update dev tools

* update recent search navigation across SEO pages

* refine search layouts and recent search navigation

* simplify recent search resets and history storage

* save

* fix recent search hydration mismatch

* fix ci lint issues
This commit is contained in:
Ben Senescu 2026-04-07 22:14:20 -04:00 committed by Ben Senescu
parent 868d83fb1c
commit f7ccdfee38
27 changed files with 1466 additions and 872 deletions

View File

@ -50,9 +50,9 @@
"@tanstack/query-core": "^5.90.9", "@tanstack/query-core": "^5.90.9",
"@tanstack/react-form": "^1.25.0", "@tanstack/react-form": "^1.25.0",
"@tanstack/react-query": "^5.90.9", "@tanstack/react-query": "^5.90.9",
"@tanstack/react-router": "^1.136.3", "@tanstack/react-router": "^1.168.10",
"@tanstack/react-router-devtools": "^1.136.3", "@tanstack/react-router-devtools": "^1.166.11",
"@tanstack/react-start": "^1.136.3", "@tanstack/react-start": "^1.167.16",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"autumn-js": "^1.1.7", "autumn-js": "^1.1.7",
"better-auth": "^1.5.5", "better-auth": "^1.5.5",
@ -82,8 +82,8 @@
"@cloudflare/workers-types": "^4.20251014.0", "@cloudflare/workers-types": "^4.20251014.0",
"@libsql/client": "^0.15.15", "@libsql/client": "^0.15.15",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@tanstack/devtools-vite": "^0.5.1", "@tanstack/devtools-vite": "^0.6.0",
"@tanstack/react-devtools": "^0.9.6", "@tanstack/react-devtools": "^0.10.1",
"@types/node": "^22.18.13", "@types/node": "^22.18.13",
"@types/papaparse": "^5.5.2", "@types/papaparse": "^5.5.2",
"@types/react": "^19.0.8", "@types/react": "^19.0.8",

512
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,84 @@
import { Clock, History, Link2, X } from "lucide-react";
import type { BacklinksSearchHistoryItem } from "@/client/hooks/useBacklinksSearchHistory";
type Props = {
history: BacklinksSearchHistoryItem[];
historyLoaded: boolean;
onRemoveHistoryItem: (timestamp: number) => void;
onSelectHistoryItem: (item: BacklinksSearchHistoryItem) => void;
};
export function BacklinksHistorySection({
history,
historyLoaded,
onRemoveHistoryItem,
onSelectHistoryItem,
}: Props) {
if (!historyLoaded) {
return null;
}
if (history.length === 0) {
return (
<section className="rounded-2xl border border-dashed border-base-300 bg-base-100/70 p-6 text-center text-base-content/55 space-y-2">
<Link2 className="size-9 mx-auto opacity-35" />
<p className="text-base font-medium text-base-content/80">
Enter a domain or URL to get started
</p>
</section>
);
}
return (
<section className="rounded-2xl border border-base-300 bg-base-100 p-5 md:p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<History className="size-4 text-base-content/45" />
<span className="text-sm text-base-content/60">
{history.length} recent search{history.length !== 1 ? "es" : ""}
</span>
</div>
</div>
<div className="grid gap-2">
{history.map((item) => (
<div
key={item.timestamp}
className="group flex items-center gap-2 rounded-lg border border-base-300 bg-base-100 p-2"
>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-3 rounded-md px-1 py-1 text-left transition-colors hover:bg-base-200"
onClick={() => onSelectHistoryItem(item)}
>
<Clock className="size-4 text-base-content/40 shrink-0" />
<div className="min-w-0">
<p className="font-medium text-base-content truncate">
{item.target}
</p>
<p className="text-sm text-base-content/60 truncate">
{item.scope === "domain" ? "Site-wide" : "Exact page"}
</p>
</div>
</button>
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-base-content/40">
{new Date(item.timestamp).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})}
</span>
<button
type="button"
className="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100 p-1"
onClick={() => onRemoveHistoryItem(item.timestamp)}
>
<X className="size-3" />
</button>
</div>
</div>
))}
</div>
</section>
);
}

View File

@ -2,10 +2,12 @@ import { BacklinksSearchCard } from "./BacklinksSearchCard";
import { BacklinksBody } from "./BacklinksPageContent"; import { BacklinksBody } from "./BacklinksPageContent";
import type { BacklinksPageProps } from "./backlinksPageTypes"; import type { BacklinksPageProps } from "./backlinksPageTypes";
import { import {
navigateToBacklinksHistory,
navigateToBacklinksSearch, navigateToBacklinksSearch,
navigateToBacklinksTab, navigateToBacklinksTab,
useBacklinksPageData, useBacklinksPageData,
} from "./useBacklinksPageData"; } from "./useBacklinksPageData";
import { useBacklinksSearchHistory } from "@/client/hooks/useBacklinksSearchHistory";
import { useBacklinksSpamPreferences } from "./useBacklinksSpamPreferences"; import { useBacklinksSpamPreferences } from "./useBacklinksSpamPreferences";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
@ -34,6 +36,23 @@ export function BacklinksPage({
searchState, 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 ( return (
<div className="px-4 py-4 pb-24 overflow-auto md:px-6 md:py-6 md:pb-8"> <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 className="mx-auto max-w-7xl space-y-4">
@ -56,7 +75,10 @@ export function BacklinksPage({
referringDomainsQuery.isFetching || referringDomainsQuery.isFetching ||
topPagesQuery.isFetching topPagesQuery.isFetching
} }
onSubmit={(values) => navigateToBacklinksSearch(navigate, values)} onSubmit={(values) => {
navigateToBacklinksSearch(navigate, values);
addSearch({ target: values.target, scope: values.scope });
}}
/> />
) : null} ) : null}
@ -65,6 +87,8 @@ export function BacklinksPage({
accessStatusError={accessStatusErrorMessage} accessStatusError={accessStatusErrorMessage}
backlinksDisabledByError={backlinksDisabledByError} backlinksDisabledByError={backlinksDisabledByError}
backlinksEnabled={backlinksEnabled} backlinksEnabled={backlinksEnabled}
history={history}
historyLoaded={historyLoaded}
isAccessStatusLoading={accessStatusQuery.isLoading} isAccessStatusLoading={accessStatusQuery.isLoading}
overviewData={overviewQuery.data} overviewData={overviewQuery.data}
overviewError={overviewErrorMessage} overviewError={overviewErrorMessage}
@ -89,7 +113,10 @@ export function BacklinksPage({
topPages={topPagesQuery.data} topPages={topPagesQuery.data}
hideSpam={hideSpam} hideSpam={hideSpam}
spamThreshold={spamThreshold} spamThreshold={spamThreshold}
onRemoveHistoryItem={removeHistoryItem}
onRetryAccess={() => void accessStatusQuery.refetch()} onRetryAccess={() => void accessStatusQuery.refetch()}
onSelectHistoryItem={handleHistorySelect}
onShowHistory={() => navigateToBacklinksHistory(navigate)}
onSetActiveTab={(tab) => navigateToBacklinksTab(navigate, tab)} onSetActiveTab={(tab) => navigateToBacklinksTab(navigate, tab)}
onRetryOverview={() => void overviewQuery.refetch()} onRetryOverview={() => void overviewQuery.refetch()}
onTestAccess={() => testAccessMutation.mutate()} onTestAccess={() => testAccessMutation.mutate()}

View File

@ -5,11 +5,12 @@ import {
} from "./BacklinksPageSections"; } from "./BacklinksPageSections";
import { import {
BacklinksAccessLoadingState, BacklinksAccessLoadingState,
BacklinksEmptyState,
BacklinksErrorState, BacklinksErrorState,
BacklinksLoadingState, BacklinksLoadingState,
BacklinksSetupGate, BacklinksSetupGate,
} from "./BacklinksPageStates"; } from "./BacklinksPageStates";
import { BacklinksHistorySection } from "./BacklinksHistorySection";
import type { BacklinksSearchHistoryItem } from "@/client/hooks/useBacklinksSearchHistory";
import type { import type {
BacklinksAccessStatusData, BacklinksAccessStatusData,
BacklinksOverviewData, BacklinksOverviewData,
@ -24,6 +25,8 @@ type BacklinksBodyProps = {
accessStatusError: string | null; accessStatusError: string | null;
backlinksDisabledByError: boolean; backlinksDisabledByError: boolean;
backlinksEnabled: boolean; backlinksEnabled: boolean;
history: BacklinksSearchHistoryItem[];
historyLoaded: boolean;
isAccessStatusLoading: boolean; isAccessStatusLoading: boolean;
hideSpam: boolean; hideSpam: boolean;
overviewData: BacklinksOverviewData | undefined; overviewData: BacklinksOverviewData | undefined;
@ -37,7 +40,10 @@ type BacklinksBodyProps = {
testError: string | null; testError: string | null;
testIsPending: boolean; testIsPending: boolean;
topPages: BacklinksTopPagesData | undefined; topPages: BacklinksTopPagesData | undefined;
onRemoveHistoryItem: (timestamp: number) => void;
onRetryAccess: () => void; onRetryAccess: () => void;
onSelectHistoryItem: (item: BacklinksSearchHistoryItem) => void;
onShowHistory: () => void;
onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void; onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void;
onRetryOverview: () => void; onRetryOverview: () => void;
onTestAccess: () => void; onTestAccess: () => void;
@ -50,6 +56,8 @@ export function BacklinksBody({
accessStatusError, accessStatusError,
backlinksDisabledByError, backlinksDisabledByError,
backlinksEnabled, backlinksEnabled,
history,
historyLoaded,
isAccessStatusLoading, isAccessStatusLoading,
hideSpam, hideSpam,
overviewData, overviewData,
@ -63,7 +71,10 @@ export function BacklinksBody({
testError, testError,
testIsPending, testIsPending,
topPages, topPages,
onRemoveHistoryItem,
onRetryAccess, onRetryAccess,
onSelectHistoryItem,
onShowHistory,
onSetActiveTab, onSetActiveTab,
onRetryOverview, onRetryOverview,
onTestAccess, onTestAccess,
@ -122,7 +133,14 @@ export function BacklinksBody({
} }
if (!searchState.target) { if (!searchState.target) {
return <BacklinksEmptyState />; return (
<BacklinksHistorySection
history={history}
historyLoaded={historyLoaded}
onRemoveHistoryItem={onRemoveHistoryItem}
onSelectHistoryItem={onSelectHistoryItem}
/>
);
} }
if (overviewLoading) { if (overviewLoading) {
@ -140,7 +158,11 @@ export function BacklinksBody({
return ( return (
<> <>
<BacklinksOverviewPanels data={mergedData} summaryStats={summaryStats} /> <BacklinksOverviewPanels
data={mergedData}
onShowHistory={onShowHistory}
summaryStats={summaryStats}
/>
<BacklinksResultsCard <BacklinksResultsCard
activeTab={searchState.tab} activeTab={searchState.tab}
filteredData={filteredData} filteredData={filteredData}

View File

@ -1,4 +1,5 @@
import { HeaderHelpLabel } from "@/client/features/keywords/components"; import { HeaderHelpLabel } from "@/client/features/keywords/components";
import { ArrowLeft } from "lucide-react";
import { import {
BacklinksNewLostChart, BacklinksNewLostChart,
BacklinksTrendChart, BacklinksTrendChart,
@ -15,13 +16,25 @@ import { formatRelativeTimestamp } from "./backlinksPageUtils";
export function BacklinksOverviewPanels({ export function BacklinksOverviewPanels({
data, data,
onShowHistory,
summaryStats, summaryStats,
}: { }: {
data: BacklinksOverviewData; data: BacklinksOverviewData;
onShowHistory: () => void;
summaryStats: Array<{ label: string; value: string; description: string }>; summaryStats: Array<{ label: string; value: string; description: string }>;
}) { }) {
return ( return (
<> <>
<div>
<button
type="button"
className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent"
onClick={onShowHistory}
>
<ArrowLeft className="size-4" />
Recent searches
</button>
</div>
<div className="flex flex-wrap items-center gap-2 text-sm text-base-content/65"> <div className="flex flex-wrap items-center gap-2 text-sm text-base-content/65">
<span className="badge badge-outline">{data.scope}</span> <span className="badge badge-outline">{data.scope}</span>
<span>Target: {data.displayTarget}</span> <span>Target: {data.displayTarget}</span>

View File

@ -1,11 +1,4 @@
import { import { ShieldAlert, Wrench } from "lucide-react";
Link2,
ShieldAlert,
Sparkles,
TrendingUp,
Wrench,
type LucideIcon,
} from "lucide-react";
import type { BacklinksAccessStatusData } from "./backlinksPageTypes"; import type { BacklinksAccessStatusData } from "./backlinksPageTypes";
import { formatRelativeTimestamp } from "./backlinksPageUtils"; import { formatRelativeTimestamp } from "./backlinksPageUtils";
@ -78,41 +71,6 @@ export function BacklinksSetupGate({
); );
} }
export function BacklinksEmptyState() {
return (
<section className="rounded-2xl border border-dashed border-base-300 bg-base-100/70 p-8 text-center space-y-3">
<div className="mx-auto flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Link2 className="size-6" />
</div>
<div className="space-y-2">
<p className="text-lg font-medium">Start with a domain or page URL</p>
<p className="mx-auto max-w-2xl text-sm text-base-content/65">
Keep backlink research simple: see who links to you, check what pages
attract links, and spot recent wins or losses without getting buried
in enterprise SEO dashboards.
</p>
</div>
<div className="grid gap-3 pt-2 text-left md:grid-cols-3">
<BeginnerCard
icon={TrendingUp}
title="Check link health"
body="Use the overview to see backlink totals, referring domains, and broken links worth fixing."
/>
<BeginnerCard
icon={Sparkles}
title="Review real links"
body="Start with the backlinks tab to see the pages linking to you and the strongest sources first."
/>
<BeginnerCard
icon={ShieldAlert}
title="Find easy actions"
body="Look for broken targets, recently lost links, and your most-linked pages to decide what to do next."
/>
</div>
</section>
);
}
export function BacklinksLoadingState() { export function BacklinksLoadingState() {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
@ -203,24 +161,6 @@ function BacklinksSetupFeedback({
); );
} }
function BeginnerCard({
icon: Icon,
title,
body,
}: {
icon: LucideIcon;
title: string;
body: string;
}) {
return (
<div className="rounded-xl border border-base-300 bg-base-100 p-4 space-y-2">
<Icon className="size-4 text-primary" />
<p className="font-medium">{title}</p>
<p className="text-sm text-base-content/65">{body}</p>
</div>
);
}
function InlineMailingListLink() { function InlineMailingListLink() {
return ( return (
<a <a

View File

@ -160,6 +160,20 @@ export function navigateToBacklinksSearch(
}); });
} }
export function navigateToBacklinksHistory(
navigate: BacklinksPageProps["navigate"],
) {
navigate({
search: (prev) => ({
...prev,
target: undefined,
scope: undefined,
tab: undefined,
}),
replace: true,
});
}
export function navigateToBacklinksTab( export function navigateToBacklinksTab(
navigate: BacklinksPageProps["navigate"], navigate: BacklinksPageProps["navigate"],
tab: BacklinksSearchState["tab"], tab: BacklinksSearchState["tab"],

View File

@ -1,4 +1,5 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { ArrowLeft } from "lucide-react";
import { DomainOverviewLoadingState } from "@/client/features/domain/components/DomainOverviewLoadingState"; import { DomainOverviewLoadingState } from "@/client/features/domain/components/DomainOverviewLoadingState";
import { DomainHistorySection } from "@/client/features/domain/components/DomainHistorySection"; import { DomainHistorySection } from "@/client/features/domain/components/DomainHistorySection";
import { DomainResultsCard } from "@/client/features/domain/components/DomainResultsCard"; import { DomainResultsCard } from "@/client/features/domain/components/DomainResultsCard";
@ -29,12 +30,14 @@ type Props = {
search: (prev: Record<string, unknown>) => Record<string, unknown>; search: (prev: Record<string, unknown>) => Record<string, unknown>;
replace: boolean; replace: boolean;
}) => void; }) => void;
onShowRecentSearches: () => void;
}; };
export function DomainOverviewPage({ export function DomainOverviewPage({
projectId, projectId,
searchState, searchState,
navigate, navigate,
onShowRecentSearches,
}: Props) { }: Props) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const state = useDomainOverviewController({ const state = useDomainOverviewController({
@ -43,6 +46,10 @@ export function DomainOverviewPage({
navigate, navigate,
searchState, searchState,
}); });
const handleShowRecentSearches = () => {
state.resetView();
onShowRecentSearches();
};
return ( return (
<div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto"> <div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto">
@ -69,15 +76,24 @@ export function DomainOverviewPage({
) : state.overview === null ? ( ) : state.overview === null ? (
<div className="space-y-4 pt-1"> <div className="space-y-4 pt-1">
<DomainHistorySection <DomainHistorySection
historyLoaded={state.historyLoaded}
history={state.history} history={state.history}
onClearHistory={state.clearHistory} historyLoaded={state.historyLoaded}
onRemoveHistoryItem={state.removeHistoryItem} onRemoveHistoryItem={state.removeHistoryItem}
onSelectHistoryItem={state.handleHistorySelect} onSelectHistoryItem={state.handleHistorySelect}
/> />
</div> </div>
) : ( ) : (
<> <>
<div>
<button
type="button"
className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent"
onClick={handleShowRecentSearches}
>
<ArrowLeft className="size-4" />
Recent searches
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<StatCard <StatCard
label="Estimated Organic Traffic" label="Estimated Organic Traffic"

View File

@ -3,21 +3,23 @@ import { Globe } from "lucide-react";
import type { DomainHistoryItem } from "@/client/features/domain/types"; import type { DomainHistoryItem } from "@/client/features/domain/types";
type Props = { type Props = {
historyLoaded: boolean;
history: DomainHistoryItem[]; history: DomainHistoryItem[];
onClearHistory: () => void; historyLoaded: boolean;
onRemoveHistoryItem: (timestamp: number) => void; onRemoveHistoryItem: (timestamp: number) => void;
onSelectHistoryItem: (item: DomainHistoryItem) => void; onSelectHistoryItem: (item: DomainHistoryItem) => void;
}; };
export function DomainHistorySection({ export function DomainHistorySection({
historyLoaded,
history, history,
onClearHistory, historyLoaded,
onRemoveHistoryItem, onRemoveHistoryItem,
onSelectHistoryItem, onSelectHistoryItem,
}: Props) { }: Props) {
if (!historyLoaded || history.length === 0) { if (!historyLoaded) {
return null;
}
if (history.length === 0) {
return ( return (
<section className="rounded-2xl border border-dashed border-base-300 bg-base-100/70 p-6 text-center text-base-content/55 space-y-2"> <section className="rounded-2xl border border-dashed border-base-300 bg-base-100/70 p-6 text-center text-base-content/55 space-y-2">
<Globe className="size-9 mx-auto opacity-35" /> <Globe className="size-9 mx-auto opacity-35" />
@ -37,22 +39,19 @@ export function DomainHistorySection({
{history.length} recent search{history.length !== 1 ? "es" : ""} {history.length} recent search{history.length !== 1 ? "es" : ""}
</span> </span>
</div> </div>
<button
className="btn btn-ghost btn-xs text-error"
onClick={onClearHistory}
>
Clear all
</button>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
{history.map((item) => ( {history.map((item) => (
<div <div
key={item.timestamp} key={item.timestamp}
className="flex items-center justify-between p-3 rounded-lg border border-base-300 bg-base-100 hover:bg-base-200 transition-colors text-left group cursor-pointer" className="group flex items-center gap-2 rounded-lg border border-base-300 bg-base-100 p-2"
>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-3 rounded-md px-1 py-1 text-left transition-colors hover:bg-base-200"
onClick={() => onSelectHistoryItem(item)} onClick={() => onSelectHistoryItem(item)}
> >
<div className="flex items-center gap-3 min-w-0">
<Clock className="size-4 text-base-content/40 shrink-0" /> <Clock className="size-4 text-base-content/40 shrink-0" />
<div className="min-w-0"> <div className="min-w-0">
<p className="font-medium text-base-content truncate"> <p className="font-medium text-base-content truncate">
@ -63,7 +62,7 @@ export function DomainHistorySection({
{item.search?.trim() ? ` - ${item.search}` : ""} {item.search?.trim() ? ` - ${item.search}` : ""}
</p> </p>
</div> </div>
</div> </button>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-base-content/40"> <span className="text-xs text-base-content/40">
{new Date(item.timestamp).toLocaleDateString(undefined, { {new Date(item.timestamp).toLocaleDateString(undefined, {
@ -74,10 +73,7 @@ export function DomainHistorySection({
<button <button
type="button" type="button"
className="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100 p-1" className="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100 p-1"
onClick={(event) => { onClick={() => onRemoveHistoryItem(item.timestamp)}
event.stopPropagation();
onRemoveHistoryItem(item.timestamp);
}}
> >
<X className="size-3" /> <X className="size-3" />
</button> </button>

View File

@ -112,8 +112,12 @@ export function useDomainOverviewController({
); );
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false);
const domainFilters = useDomainFilters(); const domainFilters = useDomainFilters();
const { history, isLoaded, addSearch, clearHistory, removeHistoryItem } = const {
useDomainSearchHistory(projectId); history,
isLoaded: historyLoaded,
addSearch,
removeHistoryItem,
} = useDomainSearchHistory(projectId);
const currentSortOrder = resolveSortOrder( const currentSortOrder = resolveSortOrder(
searchState.sort, searchState.sort,
@ -202,13 +206,20 @@ export function useDomainOverviewController({
setSearchParams, setSearchParams,
}); });
const resetView = useCallback(() => {
setOverview(null);
setPendingSearch("");
setSelectedKeywords(new Set());
setShowFilters(false);
domainFilters.resetFilters();
}, [domainFilters]);
return { return {
controlsForm, controlsForm,
isLoading: domainMutation.isPending, isLoading: domainMutation.isPending,
overview, overview,
history, history,
historyLoaded: isLoaded, historyLoaded,
clearHistory,
removeHistoryItem, removeHistoryItem,
pendingSearch, pendingSearch,
setPendingSearch, setPendingSearch,
@ -218,6 +229,7 @@ export function useDomainOverviewController({
showFilters, showFilters,
setShowFilters, setShowFilters,
filtersForm: domainFilters.filtersForm, filtersForm: domainFilters.filtersForm,
resetView,
resetFilters: domainFilters.resetFilters, resetFilters: domainFilters.resetFilters,
...handlers, ...handlers,
...dataState, ...dataState,

View File

@ -49,6 +49,18 @@ export function useKeywordResearchData(addSearch: AddSearchFn) {
setLastSearchLocationCode(locationCode); setLastSearchLocationCode(locationCode);
}; };
const resetResearch = () => {
setRows([]);
setHasSearched(false);
setLastSearchError(false);
setLastResultSource("related");
setLastUsedFallback(false);
setLastSearchKeyword("");
setLastSearchLocationCode(2840);
setResearchError(null);
setSearchedKeyword("");
};
const runSearch = ( const runSearch = (
input: { input: {
projectId: string; projectId: string;
@ -120,8 +132,8 @@ export function useKeywordResearchData(addSearch: AddSearchFn) {
researchError, researchError,
searchedKeyword, searchedKeyword,
isLoading: researchMutation.isPending, isLoading: researchMutation.isPending,
setRows,
beginSearch, beginSearch,
resetResearch,
runSearch, runSearch,
}; };
} }

View File

@ -21,6 +21,26 @@ type KeywordSearchParams = {
exclude?: string; exclude?: string;
}; };
export function clearKeywordSearchParams(search: KeywordSearchParams) {
return {
...search,
q: undefined,
loc: undefined,
kLimit: undefined,
mode: undefined,
sort: undefined,
order: undefined,
minVol: undefined,
maxVol: undefined,
minCpc: undefined,
maxCpc: undefined,
minKd: undefined,
maxKd: undefined,
include: undefined,
exclude: undefined,
} satisfies KeywordSearchParams;
}
export function normalizeLegacyKeywordSearch(search: KeywordSearchParams): { export function normalizeLegacyKeywordSearch(search: KeywordSearchParams): {
normalized: KeywordSearchParams; normalized: KeywordSearchParams;
changed: boolean; changed: boolean;

View File

@ -20,8 +20,8 @@ function NoResultsState({ controller }: Props) {
const { lastSearchKeyword, lastSearchLocationCode } = controller; const { lastSearchKeyword, lastSearchLocationCode } = controller;
return ( return (
<div className="flex-1 flex items-start justify-center px-4 md:px-6 py-6"> <div className="pt-1">
<div className="w-full max-w-2xl rounded-2xl border border-base-300 bg-base-100 p-6 md:p-8 text-center space-y-4"> <div className="w-full max-w-2xl rounded-2xl border border-base-300 bg-base-100 p-6 md:p-8 text-center space-y-4 mx-auto">
<Globe className="size-10 mx-auto text-base-content/40" /> <Globe className="size-10 mx-auto text-base-content/40" />
<div className="space-y-2"> <div className="space-y-2">
<p className="text-lg font-semibold text-base-content"> <p className="text-lg font-semibold text-base-content">
@ -45,19 +45,15 @@ function NoResultsState({ controller }: Props) {
} }
function SearchHistoryState({ controller }: Props) { function SearchHistoryState({ controller }: Props) {
const { const { history, historyLoaded, onSearch, removeHistoryItem } = controller;
clearHistory,
controlsForm, if (!historyLoaded) {
history, return null;
historyLoaded, }
onSearch,
removeHistoryItem,
} = controller;
return ( return (
<div className="flex-1 overflow-y-auto px-4 md:px-6 pb-6"> <div className="space-y-4 pt-1">
<div className="mx-auto w-full max-w-5xl space-y-6 pt-3 md:pt-5"> {history.length > 0 ? (
{historyLoaded && history.length > 0 ? (
<section className="rounded-2xl border border-base-300 bg-base-100 p-5 md:p-6"> <section className="rounded-2xl border border-base-300 bg-base-100 p-5 md:p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -67,42 +63,34 @@ function SearchHistoryState({ controller }: Props) {
{history.length !== 1 ? "es" : ""} {history.length !== 1 ? "es" : ""}
</span> </span>
</div> </div>
<button
className="btn btn-ghost btn-xs text-error"
onClick={clearHistory}
>
Clear all
</button>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
{history.map((item) => ( {history.map((item) => (
<div <div
key={item.timestamp} key={item.timestamp}
className="flex items-center justify-between p-3 rounded-lg border border-base-300 bg-base-100 hover:bg-base-200 transition-colors text-left group cursor-pointer" className="group flex items-center gap-2 rounded-lg border border-base-300 bg-base-100 p-2"
onClick={() => { >
controlsForm.setFieldValue("keyword", item.keyword); <button
controlsForm.setFieldValue( type="button"
"locationCode", className="flex min-w-0 flex-1 items-center gap-3 rounded-md px-1 py-1 text-left transition-colors hover:bg-base-200"
item.locationCode, onClick={() =>
);
onSearch({ onSearch({
keyword: item.keyword, keyword: item.keyword,
locationCode: item.locationCode, locationCode: item.locationCode,
}); })
}} }
> >
<div className="flex items-center gap-3"> <Clock className="size-4 shrink-0 text-base-content/40" />
<Clock className="size-4 text-base-content/40" /> <div className="min-w-0">
<div> <p className="truncate font-medium text-base-content">
<p className="font-medium text-base-content">
{item.keyword} {item.keyword}
</p> </p>
<p className="text-sm text-base-content/60"> <p className="truncate text-sm text-base-content/60">
{item.locationName} {item.locationName}
</p> </p>
</div> </div>
</div> </button>
<div className="flex items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<span className="text-xs text-base-content/40"> <span className="text-xs text-base-content/40">
{new Date(item.timestamp).toLocaleDateString(undefined, { {new Date(item.timestamp).toLocaleDateString(undefined, {
month: "short", month: "short",
@ -110,11 +98,9 @@ function SearchHistoryState({ controller }: Props) {
})} })}
</span> </span>
<button <button
type="button"
className="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100 p-1" className="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100 p-1"
onClick={(event) => { onClick={() => removeHistoryItem(item.timestamp)}
event.stopPropagation();
removeHistoryItem(item.timestamp);
}}
> >
<X className="size-3" /> <X className="size-3" />
</button> </button>
@ -136,6 +122,5 @@ function SearchHistoryState({ controller }: Props) {
</section> </section>
)} )}
</div> </div>
</div>
); );
} }

View File

@ -1,6 +1,6 @@
export function KeywordResearchLoadingState() { export function KeywordResearchLoadingState() {
return ( return (
<div className="flex-1 w-full px-4 md:px-6 pb-4 max-w-8xl mx-auto"> <div className="flex-1 w-full pt-1">
<div className="hidden md:flex h-full gap-4 mt-2"> <div className="hidden md:flex h-full gap-4 mt-2">
<div className="flex-1 flex flex-col min-w-0 gap-2"> <div className="flex-1 flex flex-col min-w-0 gap-2">
<div className="rounded-xl border border-base-300 bg-base-100 p-4"> <div className="rounded-xl border border-base-300 bg-base-100 p-4">

View File

@ -1,4 +1,4 @@
import { AlertCircle } from "lucide-react"; import { AlertCircle, ArrowLeft } from "lucide-react";
import { useKeywordResearchController } from "@/client/features/keywords/state/useKeywordResearchController"; import { useKeywordResearchController } from "@/client/features/keywords/state/useKeywordResearchController";
import type { KeywordResearchControllerInput } from "@/client/features/keywords/state/useKeywordResearchController"; import type { KeywordResearchControllerInput } from "@/client/features/keywords/state/useKeywordResearchController";
import { KeywordResearchEmptyState } from "./KeywordResearchEmptyState"; import { KeywordResearchEmptyState } from "./KeywordResearchEmptyState";
@ -7,50 +7,99 @@ import { KeywordResearchResults } from "./KeywordResearchResults";
import { KeywordResearchSearchBar } from "./KeywordResearchSearchBar"; import { KeywordResearchSearchBar } from "./KeywordResearchSearchBar";
import type { KeywordResearchControllerState } from "./types"; import type { KeywordResearchControllerState } from "./types";
type Props = KeywordResearchControllerInput; type Props = KeywordResearchControllerInput & {
onShowRecentSearches: () => void;
};
export function KeywordResearchPage(props: Props) { export function KeywordResearchPage({ onShowRecentSearches, ...input }: Props) {
const controller = useKeywordResearchController(props); const controller = useKeywordResearchController(input);
const handleShowRecentSearches = () => {
controller.resetView();
onShowRecentSearches();
};
return ( return (
<div className="flex flex-col h-full overflow-hidden"> <div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto">
<div className="mx-auto max-w-7xl space-y-4">
<div>
<h1 className="text-2xl font-semibold">Keyword Research</h1>
<p className="text-sm text-base-content/70">
Discover keyword ideas, search demand, and ranking opportunities.
</p>
</div>
<KeywordResearchSearchBar controller={controller} /> <KeywordResearchSearchBar controller={controller} />
<KeywordResearchContent controller={controller} /> <KeywordResearchContent
controller={controller}
onShowRecentSearches={handleShowRecentSearches}
/>
<KeywordSaveDialog controller={controller} /> <KeywordSaveDialog controller={controller} />
</div> </div>
</div>
); );
} }
function KeywordResearchContent({ function KeywordResearchContent({
controller, controller,
onShowRecentSearches,
}: { }: {
controller: KeywordResearchControllerState; controller: KeywordResearchControllerState;
onShowRecentSearches: () => void;
}) { }) {
const recentSearchesButton = controller.hasSearched ? (
<div>
<button
type="button"
className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent"
onClick={onShowRecentSearches}
>
<ArrowLeft className="size-4" />
Recent searches
</button>
</div>
) : null;
if (controller.isLoading) { if (controller.isLoading) {
return <KeywordResearchLoadingState />; return <KeywordResearchLoadingState />;
} }
if (controller.researchError) { if (controller.researchError) {
return ( return (
<div className="flex-1 flex items-center justify-center px-4 md:px-6"> <div className="space-y-4 pt-1">
{recentSearchesButton}
<div className="flex-1 flex items-center justify-center">
<div className="w-full max-w-xl rounded-xl border border-error/30 bg-error/10 p-5 text-error space-y-3"> <div className="w-full max-w-xl rounded-xl border border-error/30 bg-error/10 p-5 text-error space-y-3">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 size-4 shrink-0" /> <AlertCircle className="mt-0.5 size-4 shrink-0" />
<p className="text-sm">{controller.researchError}</p> <p className="text-sm">{controller.researchError}</p>
</div> </div>
<button className="btn btn-sm" onClick={() => controller.onSearch()}> <button
className="btn btn-sm"
onClick={() => controller.onSearch()}
>
Try again Try again
</button> </button>
</div> </div>
</div> </div>
</div>
); );
} }
if (controller.rows.length === 0) { if (controller.rows.length === 0) {
return <KeywordResearchEmptyState controller={controller} />; return (
<div className="space-y-4 pt-1">
{recentSearchesButton}
<KeywordResearchEmptyState controller={controller} />
</div>
);
} }
return <KeywordResearchResults controller={controller} />; return (
<div className="space-y-4 pt-1">
{recentSearchesButton}
<KeywordResearchResults controller={controller} />
</div>
);
} }
function KeywordSaveDialog({ function KeywordSaveDialog({

View File

@ -8,7 +8,7 @@ type Props = {
export function KeywordResearchResults({ controller }: Props) { export function KeywordResearchResults({ controller }: Props) {
return ( return (
<div className="flex-1 flex flex-col overflow-hidden w-full px-4 md:px-6 pb-4 max-w-8xl mx-auto"> <div className="flex-1 flex flex-col overflow-hidden w-full pt-1">
<KeywordResearchDesktopResults controller={controller} /> <KeywordResearchDesktopResults controller={controller} />
<KeywordResearchMobileResults controller={controller} /> <KeywordResearchMobileResults controller={controller} />
</div> </div>

View File

@ -16,9 +16,10 @@ export function KeywordResearchSearchBar({ controller }: Props) {
const { controlsForm, handleSearchSubmit, isLoading } = controller; const { controlsForm, handleSearchSubmit, isLoading } = controller;
return ( return (
<div className="shrink-0 px-4 md:px-6 pt-4 pb-2 max-w-8xl mx-auto w-full"> <div className="card border border-base-300 bg-base-100">
<div className="card-body gap-2">
<form <form
className="bg-base-100 border border-base-300 rounded-xl px-4 py-3 flex flex-wrap items-center gap-2" className="w-full flex flex-wrap items-center gap-2"
onSubmit={handleSearchSubmit} onSubmit={handleSearchSubmit}
> >
<controlsForm.Field name="keyword"> <controlsForm.Field name="keyword">
@ -27,9 +28,9 @@ export function KeywordResearchSearchBar({ controller }: Props) {
return ( return (
<label <label
className={`input input-bordered input-sm flex items-center gap-2 flex-1 min-w-0 max-w-md ${keywordError ? "input-error" : ""}`} className={`input input-bordered flex items-center gap-2 flex-1 min-w-0 max-w-md ${keywordError ? "input-error" : ""}`}
> >
<Search className="size-3.5 shrink-0 text-base-content/50" /> <Search className="size-4 shrink-0 text-base-content/60" />
<input <input
className="grow min-w-0" className="grow min-w-0"
placeholder="Enter Keyword" placeholder="Enter Keyword"
@ -108,10 +109,11 @@ export function KeywordResearchSearchBar({ controller }: Props) {
const keywordError = getFieldError(field.state.meta.errors); const keywordError = getFieldError(field.state.meta.errors);
return keywordError ? ( return keywordError ? (
<p className="mt-2 text-sm text-error">{keywordError}</p> <p className="text-sm text-error">{keywordError}</p>
) : null; ) : null;
}} }}
</controlsForm.Field> </controlsForm.Field>
</div> </div>
</div>
); );
} }

View File

@ -96,10 +96,21 @@ export function useKeywordResearchController(
state.setSerpPage(0); state.setSerpPage(0);
}; };
const resetView = useCallback(() => {
state.resetResearch();
state.clearSelection();
state.resetFilters();
state.setSelectedKeyword(null);
state.setSerpKeyword(null);
state.setSerpPage(0);
state.setMobileTab("keywords");
state.setShowFilters(false);
state.setShowSaveDialog(false);
}, [state]);
return { return {
activeFilterCount: state.activeFilterCount, activeFilterCount: state.activeFilterCount,
activeSerpKeyword: state.activeSerpKeyword, activeSerpKeyword: state.activeSerpKeyword,
clearHistory: state.clearHistory,
confirmSave, confirmSave,
controlsForm: state.controlsForm, controlsForm: state.controlsForm,
exportCsv, exportCsv,
@ -122,6 +133,7 @@ export function useKeywordResearchController(
overviewKeyword: state.overviewKeyword, overviewKeyword: state.overviewKeyword,
removeHistoryItem: state.removeHistoryItem, removeHistoryItem: state.removeHistoryItem,
researchError: state.researchError, researchError: state.researchError,
resetView,
resetFilters: state.resetFilters, resetFilters: state.resetFilters,
rows: state.rows, rows: state.rows,
searchedKeyword: state.searchedKeyword, searchedKeyword: state.searchedKeyword,
@ -174,7 +186,6 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
history, history,
isLoaded: historyLoaded, isLoaded: historyLoaded,
addSearch, addSearch,
clearHistory,
removeHistoryItem, removeHistoryItem,
} = useSearchHistory(input.projectId); } = useSearchHistory(input.projectId);
@ -190,6 +201,7 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
searchedKeyword, searchedKeyword,
isLoading, isLoading,
beginSearch, beginSearch,
resetResearch,
runSearch, runSearch,
} = useKeywordResearchData(addSearch); } = useKeywordResearchData(addSearch);
const setSearchParams = useKeywordSearchParams(); const setSearchParams = useKeywordSearchParams();
@ -268,7 +280,6 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
activeSerpKeyword, activeSerpKeyword,
beginSearch, beginSearch,
clearSelection, clearSelection,
clearHistory,
controlsForm, controlsForm,
filteredRows, filteredRows,
filtersForm, filtersForm,
@ -284,6 +295,7 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
mobileTab: uiState.mobileTab, mobileTab: uiState.mobileTab,
overviewKeyword, overviewKeyword,
removeHistoryItem, removeHistoryItem,
resetResearch,
researchError, researchError,
runSearch, runSearch,
resetFilters, resetFilters,

View File

@ -0,0 +1,56 @@
import { z } from "zod";
import { useLocalHistoryStore } from "@/client/hooks/useLocalHistoryStore";
import { jsonCodec } from "@/shared/json";
export interface BacklinksSearchHistoryItem {
target: string;
scope: "domain" | "page";
timestamp: number;
}
type AddBacklinksSearchInput = Omit<BacklinksSearchHistoryItem, "timestamp">;
const MAX_HISTORY = 20;
const backlinksSearchHistoryItemSchema = z.object({
target: z.string(),
scope: z.enum(["domain", "page"]),
timestamp: z.number(),
});
const backlinksSearchHistorySchema = z.array(backlinksSearchHistoryItemSchema);
const backlinksSearchHistoryCodec = jsonCodec(backlinksSearchHistorySchema);
function isSameSearch(
a: BacklinksSearchHistoryItem,
b: AddBacklinksSearchInput,
): boolean {
return a.target === b.target && a.scope === b.scope;
}
export function useBacklinksSearchHistory(projectId: string) {
const { history, isLoaded, addItem, removeItem } = useLocalHistoryStore<
BacklinksSearchHistoryItem,
AddBacklinksSearchInput
>({
storageKey: `backlinks-search-history:${projectId}`,
maxItems: MAX_HISTORY,
parse: (raw) => {
const parsed = backlinksSearchHistoryCodec.safeParse(raw);
return parsed.success ? parsed.data : null;
},
isSameItem: isSameSearch,
createItem: (item) => ({
...item,
timestamp: Date.now(),
}),
getItemKey: (item) => item.timestamp,
});
return {
history,
isLoaded,
addSearch: addItem,
removeHistoryItem: removeItem,
};
}

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from "react";
import { z } from "zod"; import { z } from "zod";
import { useLocalHistoryStore } from "@/client/hooks/useLocalHistoryStore";
import { jsonCodec } from "@/shared/json"; import { jsonCodec } from "@/shared/json";
type DomainSortMode = "rank" | "traffic" | "volume" | "score" | "cpc"; type DomainSortMode = "rank" | "traffic" | "volume" | "score" | "cpc";
@ -21,7 +21,7 @@ const MAX_HISTORY = 20;
const domainSearchHistoryItemSchema = z.object({ const domainSearchHistoryItemSchema = z.object({
domain: z.string(), domain: z.string(),
subdomains: z.boolean(), subdomains: z.boolean(),
sort: z.enum(["rank", "traffic", "volume"]), sort: z.enum(["rank", "traffic", "volume", "score", "cpc"]),
tab: z.enum(["keywords", "pages"]), tab: z.enum(["keywords", "pages"]),
search: z.string().optional(), search: z.string().optional(),
timestamp: z.number(), timestamp: z.number(),
@ -30,26 +30,6 @@ const domainSearchHistoryItemSchema = z.object({
const domainSearchHistorySchema = z.array(domainSearchHistoryItemSchema); const domainSearchHistorySchema = z.array(domainSearchHistoryItemSchema);
const domainSearchHistoryCodec = jsonCodec(domainSearchHistorySchema); const domainSearchHistoryCodec = jsonCodec(domainSearchHistorySchema);
function storageKey(projectId: string) {
return `domain-search-history:${projectId}`;
}
function loadHistory(projectId: string): DomainSearchHistoryItem[] {
const raw = localStorage.getItem(storageKey(projectId));
if (!raw) return [];
const parsed = domainSearchHistoryCodec.safeParse(raw);
return parsed.success ? parsed.data.slice(0, MAX_HISTORY) : [];
}
function saveHistory(projectId: string, items: DomainSearchHistoryItem[]) {
try {
localStorage.setItem(storageKey(projectId), JSON.stringify(items));
} catch {
// storage full or unavailable - silently ignore
}
}
function normalizeSearchText(value: string | undefined): string { function normalizeSearchText(value: string | undefined): string {
return value?.trim() ?? ""; return value?.trim() ?? "";
} }
@ -68,50 +48,28 @@ function isSameSearch(
} }
export function useDomainSearchHistory(projectId: string) { export function useDomainSearchHistory(projectId: string) {
const [history, setHistory] = useState<DomainSearchHistoryItem[]>([]); const { history, isLoaded, addItem, removeItem, clearItems } =
const [isLoaded, setIsLoaded] = useState(false); useLocalHistoryStore<DomainSearchHistoryItem, AddDomainSearchInput>({
storageKey: `domain-search-history:${projectId}`,
useEffect(() => { maxItems: MAX_HISTORY,
setHistory(loadHistory(projectId)); parse: (raw) => {
setIsLoaded(true); const parsed = domainSearchHistoryCodec.safeParse(raw);
}, [projectId]); return parsed.success ? parsed.data : null;
},
const addSearch = useCallback( isSameItem: isSameSearch,
(item: AddDomainSearchInput) => { createItem: (item) => ({
setHistory((prev) => {
const filtered = prev.filter(
(existing) => !isSameSearch(existing, item),
);
const next = [
{
...item, ...item,
search: normalizeSearchText(item.search) || undefined, search: normalizeSearchText(item.search) || undefined,
timestamp: Date.now(), timestamp: Date.now(),
}, }),
...filtered, getItemKey: (item) => item.timestamp,
].slice(0, MAX_HISTORY);
saveHistory(projectId, next);
return next;
}); });
},
[projectId],
);
const removeHistoryItem = useCallback( return {
(timestamp: number) => { history,
setHistory((prev) => { isLoaded,
const next = prev.filter((item) => item.timestamp !== timestamp); addSearch: addItem,
saveHistory(projectId, next); clearHistory: clearItems,
return next; removeHistoryItem: removeItem,
}); };
},
[projectId],
);
const clearHistory = useCallback(() => {
setHistory([]);
saveHistory(projectId, []);
}, [projectId]);
return { history, isLoaded, addSearch, clearHistory, removeHistoryItem };
} }

View File

@ -0,0 +1,88 @@
import { useCallback, useEffect, useRef, useState } from "react";
type UseLocalHistoryStoreOptions<TItem, TAddInput> = {
storageKey: string;
maxItems?: number;
parse: (raw: string) => TItem[] | null;
isSameItem: (existing: TItem, next: TAddInput) => boolean;
createItem: (input: TAddInput) => TItem;
getItemKey: (item: TItem) => number;
};
function loadHistory<TItem>(
storageKey: string,
parse: (raw: string) => TItem[] | null,
maxItems: number,
): TItem[] {
try {
const raw = localStorage.getItem(storageKey);
if (!raw) return [];
const parsed = parse(raw);
return parsed ? parsed.slice(0, maxItems) : [];
} catch {
return [];
}
}
function saveHistory<TItem>(storageKey: string, items: TItem[]) {
try {
localStorage.setItem(storageKey, JSON.stringify(items));
} catch {
// storage full or unavailable - silently ignore
}
}
export function useLocalHistoryStore<TItem, TAddInput>({
storageKey,
maxItems = 20,
parse,
isSameItem,
createItem,
getItemKey,
}: UseLocalHistoryStoreOptions<TItem, TAddInput>) {
const parseRef = useRef(parse);
const [history, setHistory] = useState<TItem[]>([]);
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
parseRef.current = parse;
}, [parse]);
useEffect(() => {
setHistory(loadHistory(storageKey, parseRef.current, maxItems));
setIsLoaded(true);
}, [maxItems, storageKey]);
const addItem = useCallback(
(input: TAddInput) => {
setHistory((prev) => {
const filtered = prev.filter(
(existing) => !isSameItem(existing, input),
);
const next = [createItem(input), ...filtered].slice(0, maxItems);
saveHistory(storageKey, next);
return next;
});
},
[createItem, isSameItem, maxItems, storageKey],
);
const removeItem = useCallback(
(itemKey: number) => {
setHistory((prev) => {
const next = prev.filter((item) => getItemKey(item) !== itemKey);
saveHistory(storageKey, next);
return next;
});
},
[getItemKey, storageKey],
);
const clearItems = useCallback(() => {
setHistory([]);
saveHistory(storageKey, []);
}, [storageKey]);
return { history, isLoaded, addItem, removeItem, clearItems };
}

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from "react";
import { z } from "zod"; import { z } from "zod";
import { useLocalHistoryStore } from "@/client/hooks/useLocalHistoryStore";
import { jsonCodec } from "@/shared/json"; import { jsonCodec } from "@/shared/json";
interface SearchHistoryItem { interface SearchHistoryItem {
@ -21,70 +21,34 @@ const searchHistoryItemSchema = z.object({
const searchHistorySchema = z.array(searchHistoryItemSchema); const searchHistorySchema = z.array(searchHistoryItemSchema);
const searchHistoryCodec = jsonCodec(searchHistorySchema); const searchHistoryCodec = jsonCodec(searchHistorySchema);
function storageKey(projectId: string) {
return `search-history:${projectId}`;
}
function loadHistory(projectId: string): SearchHistoryItem[] {
const raw = localStorage.getItem(storageKey(projectId));
if (!raw) return [];
const parsed = searchHistoryCodec.safeParse(raw);
return parsed.success ? parsed.data.slice(0, MAX_HISTORY) : [];
}
function saveHistory(projectId: string, items: SearchHistoryItem[]) {
try {
localStorage.setItem(storageKey(projectId), JSON.stringify(items));
} catch {
// storage full or unavailable — silently ignore
}
}
export function useSearchHistory(projectId: string) { export function useSearchHistory(projectId: string) {
const [history, setHistory] = useState<SearchHistoryItem[]>([]); const { history, isLoaded, addItem, removeItem, clearItems } =
const [isLoaded, setIsLoaded] = useState(false); useLocalHistoryStore<
SearchHistoryItem,
// Load from localStorage on mount / when projectId changes Omit<SearchHistoryItem, "timestamp">
useEffect(() => { >({
setHistory(loadHistory(projectId)); storageKey: `search-history:${projectId}`,
setIsLoaded(true); maxItems: MAX_HISTORY,
}, [projectId]); parse: (raw) => {
const parsed = searchHistoryCodec.safeParse(raw);
const addSearch = useCallback( return parsed.success ? parsed.data : null;
(keyword: string, locationCode: number, locationName: string) => {
setHistory((prev) => {
// Remove any existing entry for the same keyword+location
const filtered = prev.filter(
(item) =>
!(item.keyword === keyword && item.locationCode === locationCode),
);
const next = [
{ keyword, locationCode, locationName, timestamp: Date.now() },
...filtered,
].slice(0, MAX_HISTORY);
saveHistory(projectId, next);
return next;
});
}, },
[projectId], isSameItem: (existing, next) =>
); existing.keyword === next.keyword &&
existing.locationCode === next.locationCode,
const removeHistoryItem = useCallback( createItem: (item) => ({
(timestamp: number) => { ...item,
setHistory((prev) => { timestamp: Date.now(),
const next = prev.filter((item) => item.timestamp !== timestamp); }),
saveHistory(projectId, next); getItemKey: (item) => item.timestamp,
return next;
}); });
},
[projectId],
);
const clearHistory = useCallback(() => { return {
setHistory([]); history,
saveHistory(projectId, []); isLoaded,
}, [projectId]); addSearch: (keyword: string, locationCode: number, locationName: string) =>
addItem({ keyword, locationCode, locationName }),
return { history, isLoaded, addSearch, clearHistory, removeHistoryItem }; clearHistory: clearItems,
removeHistoryItem: removeItem,
};
} }

View File

@ -33,6 +33,20 @@ function DomainOverviewRoute() {
return ( return (
<DomainOverviewPage <DomainOverviewPage
projectId={projectId} projectId={projectId}
onShowRecentSearches={() => {
void navigate({
search: (prev) => ({
...prev,
domain: undefined,
subdomains: undefined,
sort: undefined,
order: undefined,
tab: undefined,
search: undefined,
}),
replace: true,
});
}}
navigate={navigate} navigate={navigate}
searchState={{ searchState={{
domain, domain,

View File

@ -1,7 +1,8 @@
import { createFileRoute, redirect } from "@tanstack/react-router"; import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations"; import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
import { KeywordResearchPage } from "@/client/features/keywords/page/KeywordResearchPage"; import { KeywordResearchPage } from "@/client/features/keywords/page/KeywordResearchPage";
import { import {
clearKeywordSearchParams,
isResultLimit, isResultLimit,
normalizeKeywordMode, normalizeKeywordMode,
normalizeLegacyKeywordSearch, normalizeLegacyKeywordSearch,
@ -28,6 +29,7 @@ export const Route = createFileRoute("/_project/p/$projectId/keywords")({
function KeywordResearchPageRoute() { function KeywordResearchPageRoute() {
const { projectId } = Route.useParams(); const { projectId } = Route.useParams();
const navigate = useNavigate({ from: Route.fullPath });
const search = Route.useSearch(); const search = Route.useSearch();
const { const {
q: keywordInput = "", q: keywordInput = "",
@ -41,6 +43,12 @@ function KeywordResearchPageRoute() {
return ( return (
<KeywordResearchPage <KeywordResearchPage
onShowRecentSearches={() => {
void navigate({
search: clearKeywordSearchParams,
replace: true,
});
}}
projectId={projectId} projectId={projectId}
keywordInput={keywordInput} keywordInput={keywordInput}
locationCode={locationCode} locationCode={locationCode}

View File

@ -18,9 +18,9 @@
"deploy:preview": "npm run build && wrangler deploy --env preview" "deploy:preview": "npm run build && wrangler deploy --env preview"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-router": "^1.161.3", "@tanstack/react-router": "^1.168.10",
"@tanstack/react-router-devtools": "^1.161.3", "@tanstack/react-router-devtools": "^1.166.11",
"@tanstack/react-start": "^1.161.3", "@tanstack/react-start": "^1.167.16",
"fumadocs-core": "^15.5.1", "fumadocs-core": "^15.5.1",
"fumadocs-mdx": "^11.6.5", "fumadocs-mdx": "^11.6.5",
"fumadocs-ui": "^15.5.1", "fumadocs-ui": "^15.5.1",

676
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff