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,
};
}