236 lines
6.5 KiB
TypeScript
236 lines
6.5 KiB
TypeScript
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;
|
|
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,
|
|
onViewed,
|
|
}: Props) {
|
|
if (tabs.length === 0) return null;
|
|
|
|
return (
|
|
<div className="rounded-xl border border-base-300 bg-base-100 p-1">
|
|
<div
|
|
role="tablist"
|
|
className="flex min-w-0 items-stretch gap-1 overflow-x-auto"
|
|
>
|
|
{tabs.map((tab) => {
|
|
const active = tab.id === activeTabId;
|
|
return (
|
|
<div
|
|
key={tab.id}
|
|
data-search-tab-id={tab.id}
|
|
className={`group flex shrink-0 items-stretch overflow-hidden rounded-md text-sm transition ${
|
|
active
|
|
? "bg-base-300 text-base-content shadow-sm"
|
|
: "text-base-content/80 hover:bg-base-200"
|
|
}`}
|
|
>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
data-search-tab-id={tab.id}
|
|
aria-selected={active}
|
|
className="flex min-w-0 items-center gap-1.5 px-2.5 py-1.5 text-left"
|
|
onClick={() => onSelect(tab)}
|
|
>
|
|
<SearchTabStatus
|
|
tab={tab}
|
|
projectId={projectId}
|
|
active={active}
|
|
onViewed={onViewed}
|
|
/>
|
|
<span
|
|
className="max-w-[10rem] truncate font-medium"
|
|
title={tab.label}
|
|
>
|
|
{tab.label}
|
|
</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
data-search-tab-id={tab.id}
|
|
className="flex items-center px-1.5 text-base-content/50 opacity-60 transition hover:bg-base-content/10 hover:text-base-content hover:opacity-100 group-hover:opacity-100"
|
|
onClick={() => onClose(tab.id)}
|
|
aria-label={`Close ${tab.label} tab`}
|
|
>
|
|
<X className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</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,
|
|
clickstream: input.clickstream,
|
|
});
|
|
|
|
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,
|
|
};
|
|
}
|