Share search tab queueing across Backlinks, Domain Overview, and Keyword Research (#207)
This commit is contained in:
parent
01ecf11415
commit
aebc779b93
@ -84,7 +84,6 @@ export function BacklinksPage({
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
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">
|
||||
@ -102,11 +101,6 @@ export function BacklinksPage({
|
||||
<BacklinksSearchCard
|
||||
errorMessage={overviewErrorMessage}
|
||||
initialValues={searchCardInitialValues}
|
||||
isFetching={
|
||||
overviewQuery.isFetching ||
|
||||
referringDomainsQuery.isFetching ||
|
||||
topPagesQuery.isFetching
|
||||
}
|
||||
canOpenSearch={(values) =>
|
||||
searchTabs.canOpenTab(toBacklinksTabInput(values))
|
||||
}
|
||||
@ -147,6 +141,7 @@ export function BacklinksPage({
|
||||
tabs: searchTabs.tabs,
|
||||
onSelect: searchTabs.selectTab,
|
||||
onClose: searchTabs.closeTab,
|
||||
onViewed: searchTabs.markTabViewed,
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
@ -52,6 +52,7 @@ type BacklinksBodyProps = {
|
||||
tabs: SearchTab[];
|
||||
onSelect: (tab: SearchTab) => void;
|
||||
onClose: (tabId: string) => void;
|
||||
onViewed: (tabId: string, when?: number) => void;
|
||||
} | null;
|
||||
};
|
||||
|
||||
@ -105,10 +106,12 @@ export function BacklinksBody({
|
||||
);
|
||||
const tabStrip = searchTabs ? (
|
||||
<SearchTabStrip
|
||||
projectId={projectId}
|
||||
activeTabId={searchTabs.activeTabId}
|
||||
tabs={searchTabs.tabs}
|
||||
onSelect={searchTabs.onSelect}
|
||||
onClose={searchTabs.onClose}
|
||||
onViewed={searchTabs.onViewed}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
|
||||
@ -50,14 +50,12 @@ export function BacklinksSearchCard({
|
||||
canOpenSearch,
|
||||
errorMessage,
|
||||
initialValues,
|
||||
isFetching,
|
||||
onSubmit,
|
||||
tabLimit,
|
||||
}: {
|
||||
canOpenSearch?: (values: SearchDraft) => boolean;
|
||||
errorMessage: string | null;
|
||||
initialValues: SearchDraft;
|
||||
isFetching: boolean;
|
||||
onSubmit: (values: SearchDraft) => void;
|
||||
tabLimit?: number;
|
||||
}) {
|
||||
@ -145,9 +143,9 @@ export function BacklinksSearchCard({
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary lg:col-span-2"
|
||||
disabled={isFetching || isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isFetching || isSubmitting ? "Loading..." : "Search"}
|
||||
{isSubmitting ? "Loading..." : "Search"}
|
||||
</button>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
|
||||
@ -522,10 +522,12 @@ export function DomainOverviewPage({
|
||||
</button>
|
||||
</div>
|
||||
<SearchTabStrip
|
||||
projectId={projectId}
|
||||
activeTabId={searchTabs.activeTabId}
|
||||
tabs={searchTabs.tabs}
|
||||
onSelect={searchTabs.selectTab}
|
||||
onClose={searchTabs.closeTab}
|
||||
onViewed={searchTabs.markTabViewed}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
@ -1,15 +1,8 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { AlertCircle, ArrowLeft } from "lucide-react";
|
||||
import { getErrorCode } from "@/client/lib/error-messages";
|
||||
import { BILLING_ROUTE } from "@/shared/billing";
|
||||
import {
|
||||
KEYWORD_RESEARCH_STALE_TIME_MS,
|
||||
buildKeywordResearchQueryKey,
|
||||
buildKeywordResearchRequest,
|
||||
keywordResearchQueryFn,
|
||||
} from "@/client/features/keywords/hooks/useKeywordResearchData";
|
||||
import { useKeywordResearchController } from "@/client/features/keywords/state/useKeywordResearchController";
|
||||
import type { KeywordResearchControllerInput } from "@/client/features/keywords/state/useKeywordResearchController";
|
||||
import type { KeywordControlsValues } from "@/client/features/keywords/hooks/useKeywordControlsForm";
|
||||
@ -20,12 +13,12 @@ import type {
|
||||
KeywordSearchTabInput,
|
||||
SearchTab,
|
||||
} from "@/client/features/search-tabs/types";
|
||||
import { SearchTabStrip } from "@/client/features/search-tabs/SearchTabStrip";
|
||||
import { useSearchTabNavigation } from "@/client/features/search-tabs/useSearchTabNavigation";
|
||||
import { KeywordResearchEmptyState } from "./KeywordResearchEmptyState";
|
||||
import { KeywordResearchLoadingState } from "./KeywordResearchLoadingState";
|
||||
import { KeywordResearchResults } from "./KeywordResearchResults";
|
||||
import { KeywordResearchSearchBar } from "./KeywordResearchSearchBar";
|
||||
import { KeywordResearchTabStrip } from "./KeywordResearchTabStrip";
|
||||
import type { KeywordResearchControllerState } from "./types";
|
||||
|
||||
type Props = Omit<KeywordResearchControllerInput, "onFormSubmit">;
|
||||
@ -188,50 +181,6 @@ export function KeywordResearchPage(input: Props) {
|
||||
}));
|
||||
}, [controller.controlsForm, searchTabs.tabs]);
|
||||
|
||||
// Mark the active tab as viewed once its data lands. Reads cache state via
|
||||
// the same query key the controller uses, so this catches both fresh fetches
|
||||
// and warm-cache loads on tab switch.
|
||||
const activeRequest = useMemo(
|
||||
() =>
|
||||
activeTab
|
||||
? buildKeywordResearchRequest({
|
||||
projectId,
|
||||
keywordInput: activeTab.input.keyword,
|
||||
locationCode: activeTab.input.locationCode,
|
||||
resultLimit: activeTab.input.resultLimit,
|
||||
mode: activeTab.input.mode,
|
||||
})
|
||||
: null,
|
||||
[activeTab, projectId],
|
||||
);
|
||||
const activeTabQuery = useQuery({
|
||||
queryKey: buildKeywordResearchQueryKey(activeRequest),
|
||||
queryFn: () => {
|
||||
if (!activeRequest) throw new Error("Active tab missing request");
|
||||
return keywordResearchQueryFn(activeRequest);
|
||||
},
|
||||
enabled: false,
|
||||
staleTime: KEYWORD_RESEARCH_STALE_TIME_MS,
|
||||
gcTime: KEYWORD_RESEARCH_STALE_TIME_MS,
|
||||
});
|
||||
|
||||
const markTabViewed = searchTabs.markTabViewed;
|
||||
useEffect(() => {
|
||||
if (!activeTab) return;
|
||||
if (!activeTabQuery.isSuccess) return;
|
||||
const dataUpdatedAt = activeTabQuery.dataUpdatedAt;
|
||||
if (dataUpdatedAt <= 0) return;
|
||||
if (activeTab.viewedAt !== null && activeTab.viewedAt >= dataUpdatedAt) {
|
||||
return;
|
||||
}
|
||||
markTabViewed(activeTab.id, dataUpdatedAt);
|
||||
}, [
|
||||
activeTab,
|
||||
activeTabQuery.dataUpdatedAt,
|
||||
activeTabQuery.isSuccess,
|
||||
markTabViewed,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-5">
|
||||
@ -254,12 +203,13 @@ export function KeywordResearchPage(input: Props) {
|
||||
<ArrowLeft className="size-4" />
|
||||
Recent searches
|
||||
</button>
|
||||
<KeywordResearchTabStrip
|
||||
<SearchTabStrip
|
||||
projectId={projectId}
|
||||
tabs={searchTabs.tabs}
|
||||
activeTabId={searchTabs.activeTabId}
|
||||
onSelect={searchTabs.selectTab}
|
||||
onClose={searchTabs.closeTab}
|
||||
onViewed={searchTabs.markTabViewed}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@ -1,95 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { memo } from "react";
|
||||
import { SearchTabStrip } from "@/client/features/search-tabs/SearchTabStrip";
|
||||
import type { SearchTab } from "@/client/features/search-tabs/types";
|
||||
import {
|
||||
KEYWORD_RESEARCH_STALE_TIME_MS,
|
||||
buildKeywordResearchQueryKey,
|
||||
buildKeywordResearchRequest,
|
||||
keywordResearchQueryFn,
|
||||
} from "@/client/features/keywords/hooks/useKeywordResearchData";
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
tabs: SearchTab[];
|
||||
activeTabId: string | null;
|
||||
onSelect: (tab: SearchTab) => void;
|
||||
onClose: (tabId: string) => void;
|
||||
};
|
||||
|
||||
export function KeywordResearchTabStrip({
|
||||
projectId,
|
||||
tabs,
|
||||
activeTabId,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: Props) {
|
||||
if (tabs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SearchTabStrip
|
||||
activeTabId={activeTabId}
|
||||
tabs={tabs}
|
||||
onSelect={onSelect}
|
||||
onClose={onClose}
|
||||
renderLeading={(tab, active) => (
|
||||
<KeywordTabStatus tab={tab} projectId={projectId} active={active} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const KeywordTabStatus = memo(function KeywordTabStatus({
|
||||
tab,
|
||||
projectId,
|
||||
active,
|
||||
}: {
|
||||
tab: SearchTab;
|
||||
projectId: string;
|
||||
active: boolean;
|
||||
}) {
|
||||
if (tab.input.type !== "keyword") return null;
|
||||
|
||||
const request = buildKeywordResearchRequest({
|
||||
projectId,
|
||||
keywordInput: tab.input.keyword,
|
||||
locationCode: tab.input.locationCode,
|
||||
resultLimit: tab.input.resultLimit,
|
||||
mode: tab.input.mode,
|
||||
});
|
||||
const queryKey = buildKeywordResearchQueryKey(request);
|
||||
|
||||
// enabled: false — observer only. The active tab's controller owns fetching.
|
||||
const query = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => {
|
||||
if (!request) throw new Error("Tab is missing a research request");
|
||||
return keywordResearchQueryFn(request);
|
||||
},
|
||||
enabled: false,
|
||||
select: () => null,
|
||||
notifyOnChangeProps: ["dataUpdatedAt", "errorUpdatedAt"],
|
||||
staleTime: KEYWORD_RESEARCH_STALE_TIME_MS,
|
||||
gcTime: KEYWORD_RESEARCH_STALE_TIME_MS,
|
||||
});
|
||||
|
||||
const hasResult = query.dataUpdatedAt > 0;
|
||||
const unviewed =
|
||||
!active &&
|
||||
hasResult &&
|
||||
(tab.viewedAt === null || tab.viewedAt < query.dataUpdatedAt);
|
||||
const isError = query.isError;
|
||||
|
||||
return (
|
||||
<span
|
||||
className="flex w-3.5 shrink-0 items-center justify-center"
|
||||
aria-hidden
|
||||
>
|
||||
{isError ? (
|
||||
<span className="size-2 rounded-full bg-error" />
|
||||
) : unviewed ? (
|
||||
<span className="size-2 rounded-full bg-primary" />
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
@ -1,22 +1,42 @@
|
||||
import { X } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
import { Loader2, X } from "lucide-react";
|
||||
import type { SearchTab } from "./types";
|
||||
import {
|
||||
KEYWORD_RESEARCH_STALE_TIME_MS,
|
||||
buildKeywordResearchQueryKey,
|
||||
buildKeywordResearchRequest,
|
||||
keywordResearchQueryFn,
|
||||
} from "@/client/features/keywords/hooks/useKeywordResearchData";
|
||||
import { getLanguageCode } from "@/client/features/keywords/locations";
|
||||
import { getBacklinksOverview } from "@/serverFunctions/backlinks";
|
||||
import { getDomainOverview } from "@/serverFunctions/domain";
|
||||
export type { SearchTab } from "./types";
|
||||
|
||||
type Props = {
|
||||
activeTabId: string | null;
|
||||
projectId: string;
|
||||
tabs: SearchTab[];
|
||||
onSelect: (tab: SearchTab) => void;
|
||||
onClose: (tabId: string) => void;
|
||||
renderLeading?: (tab: SearchTab, active: boolean) => ReactNode;
|
||||
onViewed: (tabId: string, when?: number) => void;
|
||||
};
|
||||
|
||||
type SearchTabQueryConfig = {
|
||||
queryKey: QueryKey;
|
||||
queryFn: () => Promise<unknown>;
|
||||
staleTime?: number;
|
||||
gcTime?: number;
|
||||
};
|
||||
|
||||
export function SearchTabStrip({
|
||||
activeTabId,
|
||||
projectId,
|
||||
tabs,
|
||||
onSelect,
|
||||
onClose,
|
||||
renderLeading,
|
||||
onViewed,
|
||||
}: Props) {
|
||||
if (tabs.length === 0) return null;
|
||||
|
||||
@ -46,7 +66,12 @@ export function SearchTabStrip({
|
||||
className="flex min-w-0 items-center gap-1.5 px-2.5 py-1.5 text-left"
|
||||
onClick={() => onSelect(tab)}
|
||||
>
|
||||
{renderLeading ? renderLeading(tab, active) : null}
|
||||
<SearchTabStatus
|
||||
tab={tab}
|
||||
projectId={projectId}
|
||||
active={active}
|
||||
onViewed={onViewed}
|
||||
/>
|
||||
<span
|
||||
className="max-w-[10rem] truncate font-medium"
|
||||
title={tab.label}
|
||||
@ -70,3 +95,140 @@ export function SearchTabStrip({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchTabStatus({
|
||||
tab,
|
||||
projectId,
|
||||
active,
|
||||
onViewed,
|
||||
}: {
|
||||
tab: SearchTab;
|
||||
projectId: string;
|
||||
active: boolean;
|
||||
onViewed: (tabId: string, when?: number) => void;
|
||||
}) {
|
||||
const config = getSearchTabQueryConfig(projectId, tab);
|
||||
const query = useQuery({
|
||||
queryKey: config.queryKey,
|
||||
queryFn: config.queryFn,
|
||||
enabled: false,
|
||||
select: () => null,
|
||||
notifyOnChangeProps: ["dataUpdatedAt", "fetchStatus", "status"],
|
||||
staleTime: config.staleTime,
|
||||
gcTime: config.gcTime,
|
||||
});
|
||||
|
||||
const isLoading = query.fetchStatus === "fetching";
|
||||
const hasResult = query.dataUpdatedAt > 0;
|
||||
const hasError = query.status === "error";
|
||||
const unviewed =
|
||||
!active &&
|
||||
hasResult &&
|
||||
(tab.viewedAt === null || tab.viewedAt < query.dataUpdatedAt);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
if (!hasResult) return;
|
||||
if (tab.viewedAt !== null && tab.viewedAt >= query.dataUpdatedAt) return;
|
||||
onViewed(tab.id, query.dataUpdatedAt);
|
||||
}, [active, hasResult, onViewed, query.dataUpdatedAt, tab.id, tab.viewedAt]);
|
||||
|
||||
const status = isLoading
|
||||
? "loading"
|
||||
: hasError
|
||||
? "error"
|
||||
: unviewed
|
||||
? "unviewed"
|
||||
: "idle";
|
||||
|
||||
return <SearchTabStatusIndicator status={status} />;
|
||||
}
|
||||
|
||||
function SearchTabStatusIndicator({
|
||||
status,
|
||||
}: {
|
||||
status: "idle" | "loading" | "unviewed" | "error";
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className="flex w-3.5 shrink-0 items-center justify-center"
|
||||
aria-hidden
|
||||
>
|
||||
{status === "loading" ? (
|
||||
<Loader2 className="size-3 animate-spin text-base-content/50" />
|
||||
) : status === "error" ? (
|
||||
<span className="size-2 rounded-full bg-error" />
|
||||
) : status === "unviewed" ? (
|
||||
<span className="size-2 rounded-full bg-primary" />
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function getSearchTabQueryConfig(
|
||||
projectId: string,
|
||||
tab: SearchTab,
|
||||
): SearchTabQueryConfig {
|
||||
if (tab.input.type === "backlinks") {
|
||||
const input = tab.input;
|
||||
return {
|
||||
queryKey: ["backlinksOverview", projectId, input.scope, input.target],
|
||||
queryFn: () =>
|
||||
getBacklinksOverview({
|
||||
data: {
|
||||
projectId,
|
||||
target: input.target,
|
||||
scope: input.scope,
|
||||
hideSpam: false,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (tab.input.type === "domain") {
|
||||
const input = tab.input;
|
||||
const trimmedDomain = input.domain.trim();
|
||||
const languageCode = getLanguageCode(input.locationCode);
|
||||
|
||||
return {
|
||||
queryKey: [
|
||||
"domain-overview",
|
||||
projectId,
|
||||
trimmedDomain,
|
||||
input.subdomains,
|
||||
input.locationCode,
|
||||
languageCode,
|
||||
],
|
||||
queryFn: () =>
|
||||
getDomainOverview({
|
||||
data: {
|
||||
projectId,
|
||||
domain: trimmedDomain,
|
||||
includeSubdomains: input.subdomains,
|
||||
locationCode: input.locationCode,
|
||||
languageCode,
|
||||
},
|
||||
}),
|
||||
staleTime: 5 * 60_000,
|
||||
};
|
||||
}
|
||||
|
||||
const input = tab.input;
|
||||
const request = buildKeywordResearchRequest({
|
||||
projectId,
|
||||
keywordInput: input.keyword,
|
||||
locationCode: input.locationCode,
|
||||
resultLimit: input.resultLimit,
|
||||
mode: input.mode,
|
||||
});
|
||||
|
||||
return {
|
||||
queryKey: buildKeywordResearchQueryKey(request),
|
||||
queryFn: () => {
|
||||
if (!request) throw new Error("Tab is missing a research request");
|
||||
return keywordResearchQueryFn(request);
|
||||
},
|
||||
staleTime: KEYWORD_RESEARCH_STALE_TIME_MS,
|
||||
gcTime: KEYWORD_RESEARCH_STALE_TIME_MS,
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user