Add shared search tabs to domain overview and backlinks (#192)
* Add shared search tabs to domain and backlinks * refactor: share search tab framework with keywords * fix: normalize backlinks tab validation target
This commit is contained in:
parent
7fa4d5cade
commit
efef3894cb
@ -1,12 +1,19 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { BacklinksSearchCard } from "./BacklinksSearchCard";
|
||||
import { BacklinksBody } from "./BacklinksPageContent";
|
||||
import type { BacklinksPageProps } from "./backlinksPageTypes";
|
||||
import type { BacklinksSearchState } from "./backlinksPageTypes";
|
||||
import {
|
||||
navigateToBacklinksSearch,
|
||||
useBacklinksPageData,
|
||||
} from "./useBacklinksPageData";
|
||||
import { useBacklinksFilters } from "./useBacklinksFilters";
|
||||
import { useBacklinksSearchHistory } from "@/client/hooks/useBacklinksSearchHistory";
|
||||
import type {
|
||||
BacklinksSearchTabInput,
|
||||
SearchTabInput,
|
||||
} from "@/client/features/search-tabs/types";
|
||||
import { useSearchTabNavigation } from "@/client/features/search-tabs/useSearchTabNavigation";
|
||||
|
||||
export function BacklinksPage({
|
||||
projectId,
|
||||
@ -34,6 +41,49 @@ export function BacklinksPage({
|
||||
addSearch,
|
||||
removeHistoryItem,
|
||||
} = useBacklinksSearchHistory(projectId);
|
||||
const urlTabInput = useMemo<SearchTabInput | null>(() => {
|
||||
if (searchState.target.trim() === "") return null;
|
||||
return {
|
||||
type: "backlinks",
|
||||
target: searchState.target,
|
||||
scope: searchState.scope,
|
||||
};
|
||||
}, [searchState.scope, searchState.target]);
|
||||
const navigateToTab = useCallback(
|
||||
(input: SearchTabInput | null) => {
|
||||
if (input?.type !== "backlinks") {
|
||||
navigate({
|
||||
search: () => ({}),
|
||||
replace: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
navigateToBacklinksSearch(navigate, {
|
||||
target: input.target,
|
||||
scope: input.scope,
|
||||
});
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
const searchTabs = useSearchTabNavigation({
|
||||
storageKey: `backlinks:${projectId}`,
|
||||
urlInput: urlTabInput,
|
||||
getLabel: useCallback(
|
||||
(input) => (input.type === "backlinks" ? input.target : ""),
|
||||
[],
|
||||
),
|
||||
navigateToInput: navigateToTab,
|
||||
});
|
||||
const toBacklinksTabInput = useCallback(
|
||||
(
|
||||
values: Pick<BacklinksSearchState, "target" | "scope">,
|
||||
): BacklinksSearchTabInput => ({
|
||||
type: "backlinks",
|
||||
target: values.target,
|
||||
scope: values.scope,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="px-4 py-4 pb-24 overflow-auto md:px-6 md:py-6 md:pb-8">
|
||||
@ -57,7 +107,12 @@ export function BacklinksPage({
|
||||
referringDomainsQuery.isFetching ||
|
||||
topPagesQuery.isFetching
|
||||
}
|
||||
canOpenSearch={(values) =>
|
||||
searchTabs.canOpenTab(toBacklinksTabInput(values))
|
||||
}
|
||||
tabLimit={searchTabs.limit}
|
||||
onSubmit={(values) => {
|
||||
searchTabs.openTab(toBacklinksTabInput(values));
|
||||
navigateToBacklinksSearch(navigate, values);
|
||||
addSearch({ target: values.target, scope: values.scope });
|
||||
}}
|
||||
@ -85,6 +140,16 @@ export function BacklinksPage({
|
||||
topPages={topPagesQuery.data}
|
||||
onRemoveHistoryItem={removeHistoryItem}
|
||||
onRetryOverview={() => void overviewQuery.refetch()}
|
||||
searchTabs={
|
||||
searchState.target
|
||||
? {
|
||||
activeTabId: searchTabs.activeTabId,
|
||||
tabs: searchTabs.tabs,
|
||||
onSelect: searchTabs.selectTab,
|
||||
onClose: searchTabs.closeTab,
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -25,6 +25,10 @@ import {
|
||||
filterTopPageRows,
|
||||
} from "./backlinksFiltering";
|
||||
import type { BacklinksFiltersState } from "./useBacklinksFilters";
|
||||
import {
|
||||
SearchTabStrip,
|
||||
type SearchTab,
|
||||
} from "@/client/features/search-tabs/SearchTabStrip";
|
||||
|
||||
type BacklinksBodyProps = {
|
||||
projectId: string;
|
||||
@ -43,6 +47,12 @@ type BacklinksBodyProps = {
|
||||
topPages: BacklinksTopPagesData | undefined;
|
||||
onRemoveHistoryItem: (timestamp: number) => void;
|
||||
onRetryOverview: () => void;
|
||||
searchTabs: {
|
||||
activeTabId: string | null;
|
||||
tabs: SearchTab[];
|
||||
onSelect: (tab: SearchTab) => void;
|
||||
onClose: (tabId: string) => void;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export function BacklinksBody({
|
||||
@ -62,6 +72,7 @@ export function BacklinksBody({
|
||||
topPages,
|
||||
onRemoveHistoryItem,
|
||||
onRetryOverview,
|
||||
searchTabs,
|
||||
}: BacklinksBodyProps) {
|
||||
const mergedData = useMemo(
|
||||
() => mergeTabData(overviewData, referringDomains, topPages),
|
||||
@ -92,6 +103,14 @@ export function BacklinksBody({
|
||||
() => buildSummaryStats(mergedData),
|
||||
[mergedData],
|
||||
);
|
||||
const tabStrip = searchTabs ? (
|
||||
<SearchTabStrip
|
||||
activeTabId={searchTabs.activeTabId}
|
||||
tabs={searchTabs.tabs}
|
||||
onSelect={searchTabs.onSelect}
|
||||
onClose={searchTabs.onClose}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (accessGate.isLoading) {
|
||||
return <BacklinksAccessLoadingState />;
|
||||
@ -128,20 +147,29 @@ export function BacklinksBody({
|
||||
}
|
||||
|
||||
if (overviewLoading) {
|
||||
return <BacklinksLoadingState />;
|
||||
return (
|
||||
<>
|
||||
{tabStrip}
|
||||
<BacklinksLoadingState />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!mergedData) {
|
||||
return (
|
||||
<>
|
||||
{tabStrip}
|
||||
<BacklinksErrorState
|
||||
errorMessage={overviewError}
|
||||
onRetry={onRetryOverview}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{tabStrip}
|
||||
<BacklinksOverviewPanels
|
||||
projectId={projectId}
|
||||
data={mergedData}
|
||||
|
||||
@ -4,6 +4,7 @@ import { Search } from "lucide-react";
|
||||
import {
|
||||
createFormValidationErrors,
|
||||
getFieldError,
|
||||
getFormError,
|
||||
shouldValidateFieldOnChange,
|
||||
} from "@/client/lib/forms";
|
||||
import type { BacklinksSearchState } from "./backlinksPageTypes";
|
||||
@ -14,11 +15,10 @@ type SearchDraft = Pick<BacklinksSearchState, "target" | "scope">;
|
||||
function getBacklinksValidationErrors(
|
||||
value: SearchDraft,
|
||||
shouldValidateUntouchedField: boolean,
|
||||
canOpenSearch?: (value: SearchDraft) => boolean,
|
||||
tabLimit?: number,
|
||||
) {
|
||||
if (value.target.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!value.target.trim()) {
|
||||
if (!shouldValidateUntouchedField) {
|
||||
return null;
|
||||
}
|
||||
@ -30,16 +30,36 @@ function getBacklinksValidationErrors(
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedValue = {
|
||||
...value,
|
||||
target: value.target.trim(),
|
||||
};
|
||||
|
||||
if (canOpenSearch && !canOpenSearch(normalizedValue)) {
|
||||
return createFormValidationErrors({
|
||||
fields: {
|
||||
target: `Close a tab to open more searches (max ${tabLimit ?? 8}).`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
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;
|
||||
}) {
|
||||
const [userSelectedScope, setUserSelectedScope] = useState(false);
|
||||
const form = useForm({
|
||||
@ -49,8 +69,11 @@ export function BacklinksSearchCard({
|
||||
getBacklinksValidationErrors(
|
||||
value,
|
||||
shouldValidateFieldOnChange(formApi, "target"),
|
||||
canOpenSearch,
|
||||
tabLimit,
|
||||
),
|
||||
onSubmit: ({ value }) => getBacklinksValidationErrors(value, true),
|
||||
onSubmit: ({ value }) =>
|
||||
getBacklinksValidationErrors(value, true, canOpenSearch, tabLimit),
|
||||
},
|
||||
onSubmit: ({ value }) => {
|
||||
const target = value.target.trim();
|
||||
@ -140,6 +163,16 @@ export function BacklinksSearchCard({
|
||||
}}
|
||||
</form.Field>
|
||||
|
||||
<form.Subscribe selector={(state) => state.errorMap.onSubmit}>
|
||||
{(submitError) => {
|
||||
const formError = getFormError(submitError);
|
||||
|
||||
return formError ? (
|
||||
<p className="text-sm text-error">{formError}</p>
|
||||
) : null;
|
||||
}}
|
||||
</form.Subscribe>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<form.Field name="scope">
|
||||
{(field) => (
|
||||
|
||||
@ -1,21 +1,29 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { DomainOverviewLoadingState } from "@/client/features/domain/components/DomainOverviewLoadingState";
|
||||
import { DomainHistorySection } from "@/client/features/domain/components/DomainHistorySection";
|
||||
import { DomainResultsCard } from "@/client/features/domain/components/DomainResultsCard";
|
||||
import { DomainSearchCard } from "@/client/features/domain/components/DomainSearchCard";
|
||||
import { StatCard } from "@/client/features/domain/components/StatCard";
|
||||
import { SearchTabStrip } from "@/client/features/search-tabs/SearchTabStrip";
|
||||
import type { SearchTabInput } from "@/client/features/search-tabs/types";
|
||||
import { useSearchTabNavigation } from "@/client/features/search-tabs/useSearchTabNavigation";
|
||||
import { useDomainOverviewController } from "@/client/features/domain/useDomainOverviewController";
|
||||
import {
|
||||
normalizeDomainTarget,
|
||||
formatMetric,
|
||||
getDefaultSortOrder,
|
||||
toSortOrderSearchParam,
|
||||
} from "@/client/features/domain/utils";
|
||||
import { createFormValidationErrors } from "@/client/lib/forms";
|
||||
import type {
|
||||
DomainActiveTab,
|
||||
DomainFilterValues,
|
||||
DomainSortMode,
|
||||
SortOrder,
|
||||
} from "@/client/features/domain/types";
|
||||
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
@ -51,6 +59,118 @@ export function DomainOverviewPage({
|
||||
navigate,
|
||||
searchState,
|
||||
});
|
||||
const urlTabInput = useMemo<SearchTabInput | null>(() => {
|
||||
if (searchState.domain.trim() === "") return null;
|
||||
return {
|
||||
type: "domain",
|
||||
domain: searchState.domain,
|
||||
subdomains: searchState.subdomains,
|
||||
sort: searchState.sort,
|
||||
order: searchState.order ?? getDefaultSortOrder(searchState.sort),
|
||||
locationCode: searchState.locationCode,
|
||||
};
|
||||
}, [
|
||||
searchState.domain,
|
||||
searchState.locationCode,
|
||||
searchState.order,
|
||||
searchState.sort,
|
||||
searchState.subdomains,
|
||||
]);
|
||||
|
||||
const navigateToTab = useCallback(
|
||||
(input: SearchTabInput | null) => {
|
||||
if (input?.type !== "domain") {
|
||||
navigate({
|
||||
search: () => ({}),
|
||||
replace: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
domain: input.domain,
|
||||
subdomains: input.subdomains ? undefined : false,
|
||||
sort: input.sort === "rank" ? undefined : input.sort,
|
||||
order: toSortOrderSearchParam(input.sort, input.order),
|
||||
loc:
|
||||
input.locationCode === DEFAULT_LOCATION_CODE
|
||||
? undefined
|
||||
: input.locationCode,
|
||||
page: undefined,
|
||||
size: undefined,
|
||||
}),
|
||||
replace: true,
|
||||
});
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
const searchTabs = useSearchTabNavigation({
|
||||
storageKey: `domain:${projectId}`,
|
||||
urlInput: urlTabInput,
|
||||
getLabel: useCallback(
|
||||
(input) => (input.type === "domain" ? input.domain : ""),
|
||||
[],
|
||||
),
|
||||
navigateToInput: navigateToTab,
|
||||
});
|
||||
const handleSearchSubmit = useCallback(
|
||||
(event: React.FormEvent) => {
|
||||
const values = state.controlsForm.state.values;
|
||||
const target = normalizeDomainTarget(values.domain);
|
||||
if (!target) {
|
||||
state.handleSearchSubmit(event);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTabInput: SearchTabInput = {
|
||||
type: "domain",
|
||||
domain: target,
|
||||
subdomains: values.subdomains,
|
||||
sort: values.sort,
|
||||
order: searchState.order ?? getDefaultSortOrder(values.sort),
|
||||
locationCode: values.locationCode,
|
||||
};
|
||||
|
||||
if (!searchTabs.canOpenTab(nextTabInput)) {
|
||||
event.preventDefault();
|
||||
state.controlsForm.setErrorMap({
|
||||
onSubmit: createFormValidationErrors({
|
||||
fields: {
|
||||
domain: `Close a tab to open more searches (max ${searchTabs.limit}).`,
|
||||
},
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
state.handleSearchSubmit(event);
|
||||
},
|
||||
[searchState.order, searchTabs, state],
|
||||
);
|
||||
const tabControls = searchState.domain ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent"
|
||||
onClick={() => {
|
||||
searchTabs.setActiveTab(null);
|
||||
onShowRecentSearches();
|
||||
}}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Recent searches
|
||||
</button>
|
||||
</div>
|
||||
<SearchTabStrip
|
||||
activeTabId={searchTabs.activeTabId}
|
||||
tabs={searchTabs.tabs}
|
||||
onSelect={searchTabs.selectTab}
|
||||
onClose={searchTabs.closeTab}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto">
|
||||
@ -66,7 +186,7 @@ export function DomainOverviewPage({
|
||||
<DomainSearchCard
|
||||
controlsForm={state.controlsForm}
|
||||
isLoading={state.isLoading}
|
||||
onSubmit={state.handleSearchSubmit}
|
||||
onSubmit={handleSearchSubmit}
|
||||
onSortChange={(sort) =>
|
||||
state.applySort(sort, getDefaultSortOrder(sort))
|
||||
}
|
||||
@ -76,7 +196,10 @@ export function DomainOverviewPage({
|
||||
/>
|
||||
|
||||
{state.isLoading ? (
|
||||
<>
|
||||
{tabControls}
|
||||
<DomainOverviewLoadingState />
|
||||
</>
|
||||
) : state.overview === null ? (
|
||||
<div className="space-y-4 pt-1">
|
||||
<DomainHistorySection
|
||||
@ -88,16 +211,7 @@ export function DomainOverviewPage({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
{tabControls}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<StatCard
|
||||
label="Estimated Organic Traffic"
|
||||
|
||||
@ -18,11 +18,11 @@ import {
|
||||
buildKeywordSearchKey,
|
||||
} from "@/client/features/keywords/state/keywordControllerActions";
|
||||
import { useKeywordSearchParams } from "@/client/features/keywords/state/keywordControllerInternals";
|
||||
import { useKeywordTabs } from "@/client/features/keywords/state/useKeywordTabs";
|
||||
import {
|
||||
getKeywordTabsSnapshot,
|
||||
useKeywordTabs,
|
||||
type OpenTabInput,
|
||||
} from "@/client/features/keywords/state/keywordTabsStore";
|
||||
} from "@/client/features/keywords/state/useKeywordTabs";
|
||||
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
|
||||
import { KeywordResearchEmptyState } from "./KeywordResearchEmptyState";
|
||||
import { KeywordResearchLoadingState } from "./KeywordResearchLoadingState";
|
||||
|
||||
@ -1,16 +1,14 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { X } from "lucide-react";
|
||||
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";
|
||||
import type {
|
||||
KeywordTab,
|
||||
UseKeywordTabsReturn,
|
||||
} from "@/client/features/keywords/state/useKeywordTabs";
|
||||
import type { UseKeywordTabsReturn } from "@/client/features/keywords/state/useKeywordTabs";
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
@ -22,45 +20,35 @@ export function KeywordResearchTabStrip({ projectId, tabs, closeTab }: Props) {
|
||||
if (tabs.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.tabs.map((tab) => (
|
||||
<TabPill
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
projectId={projectId}
|
||||
active={tab.id === tabs.activeTabId}
|
||||
setActiveTab={tabs.setActiveTab}
|
||||
closeTab={closeTab}
|
||||
<SearchTabStrip
|
||||
activeTabId={tabs.activeTabId}
|
||||
tabs={tabs.tabs}
|
||||
onSelect={(tab) => tabs.setActiveTab(tab.id)}
|
||||
onClose={closeTab}
|
||||
renderLeading={(tab, active) => (
|
||||
<KeywordTabStatus tab={tab} projectId={projectId} active={active} />
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TabPill = memo(function TabPill({
|
||||
const KeywordTabStatus = memo(function KeywordTabStatus({
|
||||
tab,
|
||||
projectId,
|
||||
active,
|
||||
setActiveTab,
|
||||
closeTab,
|
||||
}: {
|
||||
tab: KeywordTab;
|
||||
tab: SearchTab;
|
||||
projectId: string;
|
||||
active: boolean;
|
||||
setActiveTab: (tabId: string | null) => void;
|
||||
closeTab: (tabId: string) => void;
|
||||
}) {
|
||||
if (tab.input.type !== "keyword") return null;
|
||||
|
||||
const request = buildKeywordResearchRequest({
|
||||
projectId,
|
||||
keywordInput: tab.keyword,
|
||||
locationCode: tab.locationCode,
|
||||
resultLimit: tab.resultLimit,
|
||||
mode: tab.mode,
|
||||
keywordInput: tab.input.keyword,
|
||||
locationCode: tab.input.locationCode,
|
||||
resultLimit: tab.input.resultLimit,
|
||||
mode: tab.input.mode,
|
||||
});
|
||||
const queryKey = buildKeywordResearchQueryKey(request);
|
||||
|
||||
@ -86,23 +74,6 @@ const TabPill = memo(function TabPill({
|
||||
const isError = query.isError;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
tabIndex={0}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setActiveTab(tab.id);
|
||||
}
|
||||
}}
|
||||
className={`group flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm transition ${
|
||||
active
|
||||
? "bg-base-300 text-base-content shadow-sm"
|
||||
: "text-base-content/80 hover:bg-base-200"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="flex w-3.5 shrink-0 items-center justify-center"
|
||||
aria-hidden
|
||||
@ -113,20 +84,5 @@ const TabPill = memo(function TabPill({
|
||||
<span className="size-2 rounded-full bg-primary" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="max-w-[10rem] truncate font-medium" title={tab.keyword}>
|
||||
{tab.keyword}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-0.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={(event) => {
|
||||
event.stopPropagation();
|
||||
closeTab(tab.id);
|
||||
}}
|
||||
aria-label={`Close ${tab.keyword} tab`}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@ -1,291 +0,0 @@
|
||||
import type {
|
||||
KeywordMode,
|
||||
ResultLimit,
|
||||
} from "@/client/features/keywords/keywordResearchTypes";
|
||||
|
||||
export type KeywordTab = {
|
||||
id: string;
|
||||
keyword: string;
|
||||
locationCode: number;
|
||||
resultLimit: ResultLimit;
|
||||
mode: KeywordMode;
|
||||
createdAt: number;
|
||||
viewedAt: number | null;
|
||||
};
|
||||
|
||||
export type ProjectTabsState = {
|
||||
tabs: KeywordTab[];
|
||||
activeTabId: string | null;
|
||||
};
|
||||
|
||||
export type OpenTabInput = {
|
||||
keyword: string;
|
||||
locationCode: number;
|
||||
resultLimit: ResultLimit;
|
||||
mode: KeywordMode;
|
||||
};
|
||||
|
||||
type OpenTabsResult = {
|
||||
opened: KeywordTab[];
|
||||
focused: KeywordTab[];
|
||||
activeTab: KeywordTab | null;
|
||||
dropped: OpenTabInput[];
|
||||
};
|
||||
|
||||
export const KEYWORD_TABS_LIMIT = 8;
|
||||
export const EMPTY_TABS_STATE: ProjectTabsState = {
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
};
|
||||
|
||||
const STORAGE_KEY_PREFIX = "keyword-tabs:";
|
||||
const CHANGE_EVENT = "keyword-tabs-change";
|
||||
|
||||
// Module-level cache. Returning stable references keeps useSyncExternalStore
|
||||
// from infinite-looping on Object.is equality checks.
|
||||
const projectStates = new Map<string, ProjectTabsState>();
|
||||
|
||||
function storageKey(projectId: string): string {
|
||||
return `${STORAGE_KEY_PREFIX}${projectId}`;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isResultLimit(value: unknown): value is ResultLimit {
|
||||
return value === 150 || value === 300 || value === 500;
|
||||
}
|
||||
|
||||
function isKeywordMode(value: unknown): value is KeywordMode {
|
||||
return (
|
||||
value === "auto" ||
|
||||
value === "related" ||
|
||||
value === "suggestions" ||
|
||||
value === "ideas"
|
||||
);
|
||||
}
|
||||
|
||||
function parseTab(value: unknown): KeywordTab | null {
|
||||
if (!isRecord(value)) return null;
|
||||
if (typeof value.id !== "string" || value.id === "") return null;
|
||||
if (typeof value.keyword !== "string" || value.keyword === "") return null;
|
||||
if (typeof value.locationCode !== "number") return null;
|
||||
if (!isResultLimit(value.resultLimit)) return null;
|
||||
if (!isKeywordMode(value.mode)) return null;
|
||||
if (typeof value.createdAt !== "number") return null;
|
||||
const viewedAt =
|
||||
value.viewedAt === null
|
||||
? null
|
||||
: typeof value.viewedAt === "number"
|
||||
? value.viewedAt
|
||||
: null;
|
||||
return {
|
||||
id: value.id,
|
||||
keyword: value.keyword,
|
||||
locationCode: value.locationCode,
|
||||
resultLimit: value.resultLimit,
|
||||
mode: value.mode,
|
||||
createdAt: value.createdAt,
|
||||
viewedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function parseStoredState(value: unknown): ProjectTabsState {
|
||||
if (!isRecord(value)) return EMPTY_TABS_STATE;
|
||||
if (!Array.isArray(value.tabs)) return EMPTY_TABS_STATE;
|
||||
const tabs: KeywordTab[] = [];
|
||||
for (const raw of value.tabs) {
|
||||
const parsed = parseTab(raw);
|
||||
if (parsed) tabs.push(parsed);
|
||||
}
|
||||
const activeTabId =
|
||||
typeof value.activeTabId === "string" &&
|
||||
tabs.some((tab) => tab.id === value.activeTabId)
|
||||
? value.activeTabId
|
||||
: null;
|
||||
return { tabs, activeTabId };
|
||||
}
|
||||
|
||||
function loadFromStorage(projectId: string): ProjectTabsState {
|
||||
if (typeof window === "undefined") return EMPTY_TABS_STATE;
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(storageKey(projectId));
|
||||
if (!raw) return EMPTY_TABS_STATE;
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return parseStoredState(parsed);
|
||||
} catch {
|
||||
return EMPTY_TABS_STATE;
|
||||
}
|
||||
}
|
||||
|
||||
function persist(projectId: string, state: ProjectTabsState) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.sessionStorage.setItem(storageKey(projectId), JSON.stringify(state));
|
||||
} catch {
|
||||
// sessionStorage unavailable or full; in-memory cache still works.
|
||||
}
|
||||
}
|
||||
|
||||
function notify() {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(new Event(CHANGE_EVENT));
|
||||
}
|
||||
|
||||
export function getKeywordTabsSnapshot(projectId: string): ProjectTabsState {
|
||||
let state = projectStates.get(projectId);
|
||||
if (!state) {
|
||||
state = loadFromStorage(projectId);
|
||||
projectStates.set(projectId, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export function subscribeKeywordTabsStore(onChange: () => void): () => void {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
window.addEventListener(CHANGE_EVENT, onChange);
|
||||
return () => window.removeEventListener(CHANGE_EVENT, onChange);
|
||||
}
|
||||
|
||||
function update(
|
||||
projectId: string,
|
||||
updater: (current: ProjectTabsState) => ProjectTabsState,
|
||||
) {
|
||||
const current = getKeywordTabsSnapshot(projectId);
|
||||
const next = updater(current);
|
||||
if (next === current) return;
|
||||
projectStates.set(projectId, next);
|
||||
persist(projectId, next);
|
||||
notify();
|
||||
}
|
||||
|
||||
function generateTabId(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `tab_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function tabMatches(tab: KeywordTab, input: OpenTabInput): boolean {
|
||||
return (
|
||||
tab.keyword === input.keyword &&
|
||||
tab.locationCode === input.locationCode &&
|
||||
tab.resultLimit === input.resultLimit &&
|
||||
tab.mode === input.mode
|
||||
);
|
||||
}
|
||||
|
||||
export function findMatchingTab(
|
||||
state: ProjectTabsState,
|
||||
input: OpenTabInput,
|
||||
): KeywordTab | null {
|
||||
return state.tabs.find((tab) => tabMatches(tab, input)) ?? null;
|
||||
}
|
||||
|
||||
export function openTabs(
|
||||
projectId: string,
|
||||
inputs: OpenTabInput[],
|
||||
): OpenTabsResult {
|
||||
const opened: KeywordTab[] = [];
|
||||
const focused: KeywordTab[] = [];
|
||||
const dropped: OpenTabInput[] = [];
|
||||
let resultActiveTab: KeywordTab | null = null;
|
||||
|
||||
update(projectId, (current) => {
|
||||
let tabs = current.tabs;
|
||||
let activeTabId = current.activeTabId;
|
||||
|
||||
for (const input of inputs) {
|
||||
const existing = tabs.find((tab) => tabMatches(tab, input));
|
||||
if (existing) {
|
||||
focused.push(existing);
|
||||
activeTabId = existing.id;
|
||||
resultActiveTab = existing;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tabs.length >= KEYWORD_TABS_LIMIT) {
|
||||
dropped.push(input);
|
||||
continue;
|
||||
}
|
||||
|
||||
const next: KeywordTab = {
|
||||
id: generateTabId(),
|
||||
keyword: input.keyword,
|
||||
locationCode: input.locationCode,
|
||||
resultLimit: input.resultLimit,
|
||||
mode: input.mode,
|
||||
createdAt: Date.now(),
|
||||
viewedAt: null,
|
||||
};
|
||||
tabs = [...tabs, next];
|
||||
opened.push(next);
|
||||
activeTabId = next.id;
|
||||
resultActiveTab = next;
|
||||
}
|
||||
|
||||
if (tabs === current.tabs && activeTabId === current.activeTabId) {
|
||||
return current;
|
||||
}
|
||||
return { tabs, activeTabId };
|
||||
});
|
||||
|
||||
return { opened, focused, activeTab: resultActiveTab, dropped };
|
||||
}
|
||||
|
||||
export function setActiveTab(projectId: string, tabId: string | null) {
|
||||
update(projectId, (current) => {
|
||||
if (current.activeTabId === tabId) return current;
|
||||
if (tabId !== null && !current.tabs.some((tab) => tab.id === tabId)) {
|
||||
return current;
|
||||
}
|
||||
return { ...current, activeTabId: tabId };
|
||||
});
|
||||
}
|
||||
|
||||
export function closeTab(
|
||||
projectId: string,
|
||||
tabId: string,
|
||||
): { nextActiveTab: KeywordTab | null; closedActive: boolean } {
|
||||
let nextActiveTab: KeywordTab | null = null;
|
||||
let closedActive = false;
|
||||
|
||||
update(projectId, (current) => {
|
||||
const index = current.tabs.findIndex((tab) => tab.id === tabId);
|
||||
if (index === -1) return current;
|
||||
|
||||
const tabs = current.tabs.filter((tab) => tab.id !== tabId);
|
||||
let activeTabId = current.activeTabId;
|
||||
|
||||
if (current.activeTabId === tabId) {
|
||||
closedActive = true;
|
||||
// Activate the right neighbor, fall back to the left, fall back to null.
|
||||
const neighbor = tabs[index] ?? tabs[index - 1] ?? null;
|
||||
activeTabId = neighbor?.id ?? null;
|
||||
nextActiveTab = neighbor;
|
||||
}
|
||||
|
||||
return { tabs, activeTabId };
|
||||
});
|
||||
|
||||
return { nextActiveTab, closedActive };
|
||||
}
|
||||
|
||||
export function markTabViewed(
|
||||
projectId: string,
|
||||
tabId: string,
|
||||
when = Date.now(),
|
||||
) {
|
||||
update(projectId, (current) => {
|
||||
let changed = false;
|
||||
const tabs = current.tabs.map((tab) => {
|
||||
if (tab.id !== tabId) return tab;
|
||||
if (tab.viewedAt !== null && tab.viewedAt >= when) return tab;
|
||||
changed = true;
|
||||
return { ...tab, viewedAt: when };
|
||||
});
|
||||
if (!changed) return current;
|
||||
return { ...current, tabs };
|
||||
});
|
||||
}
|
||||
@ -14,7 +14,7 @@ import {
|
||||
type KeywordMode,
|
||||
type ResultLimit,
|
||||
} from "@/client/features/keywords/keywordResearchTypes";
|
||||
import type { OpenTabInput } from "@/client/features/keywords/state/keywordTabsStore";
|
||||
import type { OpenTabInput } from "@/client/features/keywords/state/useKeywordTabs";
|
||||
import type { KeywordResearchRow } from "@/types/keywords";
|
||||
import type { SortDir, SortField } from "@/client/features/keywords/components";
|
||||
import {
|
||||
|
||||
@ -1,81 +1,158 @@
|
||||
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type {
|
||||
KeywordSearchTabInput,
|
||||
SearchTab,
|
||||
} from "@/client/features/search-tabs/types";
|
||||
import {
|
||||
EMPTY_TABS_STATE,
|
||||
KEYWORD_TABS_LIMIT,
|
||||
closeTab as closeTabAction,
|
||||
findMatchingTab,
|
||||
getKeywordTabsSnapshot,
|
||||
markTabViewed as markTabViewedAction,
|
||||
openTabs as openTabsAction,
|
||||
setActiveTab as setActiveTabAction,
|
||||
subscribeKeywordTabsStore,
|
||||
type KeywordTab,
|
||||
type OpenTabInput,
|
||||
type ProjectTabsState,
|
||||
} from "./keywordTabsStore";
|
||||
getSearchTabsSnapshot,
|
||||
useSearchTabs,
|
||||
} from "@/client/features/search-tabs/useSearchTabs";
|
||||
|
||||
function useKeywordTabsSnapshot(projectId: string): ProjectTabsState {
|
||||
const getSnapshot = useCallback(
|
||||
() => getKeywordTabsSnapshot(projectId),
|
||||
[projectId],
|
||||
);
|
||||
return useSyncExternalStore(
|
||||
subscribeKeywordTabsStore,
|
||||
getSnapshot,
|
||||
() => EMPTY_TABS_STATE,
|
||||
);
|
||||
export type OpenTabInput = Omit<KeywordSearchTabInput, "type">;
|
||||
|
||||
type KeywordTab = SearchTab & {
|
||||
input: KeywordSearchTabInput;
|
||||
keyword: string;
|
||||
locationCode: KeywordSearchTabInput["locationCode"];
|
||||
resultLimit: KeywordSearchTabInput["resultLimit"];
|
||||
mode: KeywordSearchTabInput["mode"];
|
||||
};
|
||||
|
||||
type ProjectTabsState = {
|
||||
tabs: KeywordTab[];
|
||||
activeTabId: string | null;
|
||||
};
|
||||
|
||||
type OpenTabsResult = {
|
||||
opened: KeywordTab[];
|
||||
focused: KeywordTab[];
|
||||
activeTab: KeywordTab | null;
|
||||
dropped: OpenTabInput[];
|
||||
};
|
||||
|
||||
const KEYWORD_TABS_KEY_PREFIX = "keyword";
|
||||
|
||||
function keywordTabsKey(projectId: string) {
|
||||
return `${KEYWORD_TABS_KEY_PREFIX}:${projectId}`;
|
||||
}
|
||||
|
||||
function toSearchTabInput(input: OpenTabInput): KeywordSearchTabInput {
|
||||
return {
|
||||
type: "keyword",
|
||||
keyword: input.keyword,
|
||||
locationCode: input.locationCode,
|
||||
resultLimit: input.resultLimit,
|
||||
mode: input.mode,
|
||||
};
|
||||
}
|
||||
|
||||
function toKeywordTab(tab: SearchTab): KeywordTab | null {
|
||||
if (tab.input.type !== "keyword") return null;
|
||||
return {
|
||||
...tab,
|
||||
input: tab.input,
|
||||
keyword: tab.input.keyword,
|
||||
locationCode: tab.input.locationCode,
|
||||
resultLimit: tab.input.resultLimit,
|
||||
mode: tab.input.mode,
|
||||
};
|
||||
}
|
||||
|
||||
function toKeywordTabs(tabs: readonly SearchTab[]): KeywordTab[] {
|
||||
return tabs.flatMap((tab) => {
|
||||
const keywordTab = toKeywordTab(tab);
|
||||
return keywordTab ? [keywordTab] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function getKeywordTabsSnapshot(projectId: string): ProjectTabsState {
|
||||
const snapshot = getSearchTabsSnapshot(keywordTabsKey(projectId));
|
||||
return {
|
||||
tabs: toKeywordTabs(snapshot.tabs),
|
||||
activeTabId: snapshot.activeTabId,
|
||||
};
|
||||
}
|
||||
|
||||
export function useKeywordTabs(projectId: string) {
|
||||
const state = useKeywordTabsSnapshot(projectId);
|
||||
const tabs = useSearchTabs(keywordTabsKey(projectId));
|
||||
const keywordTabs = useMemo(() => toKeywordTabs(tabs.tabs), [tabs.tabs]);
|
||||
const activeTab = useMemo(
|
||||
() => keywordTabs.find((tab) => tab.id === tabs.activeTabId) ?? null,
|
||||
[keywordTabs, tabs.activeTabId],
|
||||
);
|
||||
|
||||
const openTabs = useCallback(
|
||||
(inputs: OpenTabInput[]) => openTabsAction(projectId, inputs),
|
||||
[projectId],
|
||||
(inputs: OpenTabInput[]): OpenTabsResult => {
|
||||
const opened: KeywordTab[] = [];
|
||||
const focused: KeywordTab[] = [];
|
||||
const dropped: OpenTabInput[] = [];
|
||||
let resultActiveTab: KeywordTab | null = null;
|
||||
let simulatedTabs = keywordTabs;
|
||||
|
||||
for (const input of inputs) {
|
||||
const result = tabs.openTab({
|
||||
label: input.keyword,
|
||||
input: toSearchTabInput(input),
|
||||
});
|
||||
|
||||
if (result.dropped) {
|
||||
dropped.push(input);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!result.tab) continue;
|
||||
const keywordTab = toKeywordTab(result.tab);
|
||||
if (!keywordTab) continue;
|
||||
resultActiveTab = keywordTab;
|
||||
|
||||
const wasAlreadyOpen = simulatedTabs.some(
|
||||
(tab) => tab.id === keywordTab.id,
|
||||
);
|
||||
if (wasAlreadyOpen) focused.push(keywordTab);
|
||||
else {
|
||||
opened.push(keywordTab);
|
||||
simulatedTabs = [...simulatedTabs, keywordTab];
|
||||
}
|
||||
}
|
||||
|
||||
return { opened, focused, activeTab: resultActiveTab, dropped };
|
||||
},
|
||||
[keywordTabs, tabs],
|
||||
);
|
||||
|
||||
const findMatchingTab = useCallback(
|
||||
(input: OpenTabInput) => {
|
||||
const match = tabs.findMatchingTab(toSearchTabInput(input));
|
||||
return match ? toKeywordTab(match) : null;
|
||||
},
|
||||
[tabs],
|
||||
);
|
||||
|
||||
const closeTab = useCallback(
|
||||
(tabId: string) => closeTabAction(projectId, tabId),
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const setActiveTab = useCallback(
|
||||
(tabId: string | null) => setActiveTabAction(projectId, tabId),
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const markTabViewed = useCallback(
|
||||
(tabId: string, when?: number) =>
|
||||
markTabViewedAction(projectId, tabId, when),
|
||||
[projectId],
|
||||
);
|
||||
|
||||
// Reads the latest module snapshot directly so this callback is safe to use
|
||||
// in effect dependency arrays — its reference is stable across renders.
|
||||
const findMatching = useCallback(
|
||||
(input: OpenTabInput) =>
|
||||
findMatchingTab(getKeywordTabsSnapshot(projectId), input),
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const activeTab = useMemo(
|
||||
() => state.tabs.find((tab) => tab.id === state.activeTabId) ?? null,
|
||||
[state],
|
||||
(tabId: string) => {
|
||||
const result = tabs.closeTab(tabId);
|
||||
return {
|
||||
closedActive: result.closedActive,
|
||||
nextActiveTab: result.nextActiveTab
|
||||
? toKeywordTab(result.nextActiveTab)
|
||||
: null,
|
||||
};
|
||||
},
|
||||
[tabs],
|
||||
);
|
||||
|
||||
return {
|
||||
tabs: state.tabs,
|
||||
activeTabId: state.activeTabId,
|
||||
tabs: keywordTabs,
|
||||
activeTabId: tabs.activeTabId,
|
||||
activeTab,
|
||||
isAtCap: state.tabs.length >= KEYWORD_TABS_LIMIT,
|
||||
limit: KEYWORD_TABS_LIMIT,
|
||||
isAtCap: keywordTabs.length >= tabs.limit,
|
||||
limit: tabs.limit,
|
||||
openTabs,
|
||||
closeTab,
|
||||
setActiveTab,
|
||||
markTabViewed,
|
||||
findMatchingTab: findMatching,
|
||||
setActiveTab: tabs.setActiveTab,
|
||||
markTabViewed: tabs.markTabViewed,
|
||||
findMatchingTab,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseKeywordTabsReturn = ReturnType<typeof useKeywordTabs>;
|
||||
export type { KeywordTab };
|
||||
|
||||
74
src/client/features/search-tabs/SearchTabStrip.tsx
Normal file
74
src/client/features/search-tabs/SearchTabStrip.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import { X } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { SearchTab } from "./types";
|
||||
export type { SearchTab } from "./types";
|
||||
|
||||
type Props = {
|
||||
activeTabId: string | null;
|
||||
tabs: SearchTab[];
|
||||
onSelect: (tab: SearchTab) => void;
|
||||
onClose: (tabId: string) => void;
|
||||
renderLeading?: (tab: SearchTab, active: boolean) => ReactNode;
|
||||
};
|
||||
|
||||
export function SearchTabStrip({
|
||||
activeTabId,
|
||||
tabs,
|
||||
onSelect,
|
||||
onClose,
|
||||
renderLeading,
|
||||
}: 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}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(tab)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onSelect(tab);
|
||||
}
|
||||
}}
|
||||
className={`group flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm transition ${
|
||||
active
|
||||
? "bg-base-300 text-base-content shadow-sm"
|
||||
: "text-base-content/80 hover:bg-base-200"
|
||||
}`}
|
||||
>
|
||||
{renderLeading ? renderLeading(tab, active) : null}
|
||||
<span
|
||||
className="max-w-[10rem] truncate font-medium"
|
||||
title={tab.label}
|
||||
>
|
||||
{tab.label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-0.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={(event) => {
|
||||
event.stopPropagation();
|
||||
onClose(tab.id);
|
||||
}}
|
||||
aria-label={`Close ${tab.label} tab`}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
src/client/features/search-tabs/types.ts
Normal file
42
src/client/features/search-tabs/types.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import type { DomainSortMode, SortOrder } from "@/client/features/domain/types";
|
||||
import type {
|
||||
KeywordMode,
|
||||
ResultLimit,
|
||||
} from "@/client/features/keywords/keywordResearchTypes";
|
||||
import type { BacklinksTargetScope } from "@/types/schemas/backlinks";
|
||||
|
||||
export type BacklinksSearchTabInput = {
|
||||
type: "backlinks";
|
||||
target: string;
|
||||
scope: BacklinksTargetScope;
|
||||
};
|
||||
|
||||
export type DomainSearchTabInput = {
|
||||
type: "domain";
|
||||
domain: string;
|
||||
subdomains: boolean;
|
||||
sort: DomainSortMode;
|
||||
order: SortOrder;
|
||||
locationCode: number;
|
||||
};
|
||||
|
||||
export type KeywordSearchTabInput = {
|
||||
type: "keyword";
|
||||
keyword: string;
|
||||
locationCode: number;
|
||||
resultLimit: ResultLimit;
|
||||
mode: KeywordMode;
|
||||
};
|
||||
|
||||
export type SearchTabInput =
|
||||
| BacklinksSearchTabInput
|
||||
| DomainSearchTabInput
|
||||
| KeywordSearchTabInput;
|
||||
|
||||
export type SearchTab = {
|
||||
id: string;
|
||||
label: string;
|
||||
input: SearchTabInput;
|
||||
createdAt: number;
|
||||
viewedAt: number | null;
|
||||
};
|
||||
82
src/client/features/search-tabs/useSearchTabNavigation.ts
Normal file
82
src/client/features/search-tabs/useSearchTabNavigation.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import type { SearchTab, SearchTabInput } from "./types";
|
||||
import { useSearchTabs } from "./useSearchTabs";
|
||||
|
||||
type UseSearchTabNavigationArgs = {
|
||||
storageKey: string;
|
||||
urlInput: SearchTabInput | null;
|
||||
getLabel: (input: SearchTabInput) => string;
|
||||
navigateToInput: (input: SearchTabInput | null) => void;
|
||||
};
|
||||
|
||||
export function useSearchTabNavigation({
|
||||
storageKey,
|
||||
urlInput,
|
||||
getLabel,
|
||||
navigateToInput,
|
||||
}: UseSearchTabNavigationArgs) {
|
||||
const tabs = useSearchTabs(storageKey);
|
||||
const { activeTabId, closeTab, findMatchingTab, openTab, setActiveTab } =
|
||||
tabs;
|
||||
|
||||
useEffect(() => {
|
||||
if (!urlInput) {
|
||||
setActiveTab(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = findMatchingTab(urlInput);
|
||||
if (existing) {
|
||||
if (activeTabId !== existing.id) {
|
||||
setActiveTab(existing.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result = openTab({
|
||||
label: getLabel(urlInput),
|
||||
input: urlInput,
|
||||
});
|
||||
if (result.dropped) {
|
||||
setActiveTab(null);
|
||||
}
|
||||
}, [activeTabId, findMatchingTab, getLabel, openTab, setActiveTab, urlInput]);
|
||||
|
||||
const selectTab = useCallback(
|
||||
(tab: SearchTab) => {
|
||||
setActiveTab(tab.id);
|
||||
navigateToInput(tab.input);
|
||||
},
|
||||
[navigateToInput, setActiveTab],
|
||||
);
|
||||
|
||||
const closeSearchTab = useCallback(
|
||||
(tabId: string) => {
|
||||
const result = closeTab(tabId);
|
||||
if (result.closedActive) {
|
||||
navigateToInput(result.nextActiveTab?.input ?? null);
|
||||
}
|
||||
},
|
||||
[closeTab, navigateToInput],
|
||||
);
|
||||
|
||||
const openSearchTab = useCallback(
|
||||
(input: SearchTabInput) =>
|
||||
openTab({
|
||||
label: getLabel(input),
|
||||
input,
|
||||
}),
|
||||
[getLabel, openTab],
|
||||
);
|
||||
|
||||
return {
|
||||
activeTabId: tabs.activeTabId,
|
||||
tabs: tabs.tabs,
|
||||
canOpenTab: tabs.canOpenTab,
|
||||
closeTab: closeSearchTab,
|
||||
limit: tabs.limit,
|
||||
openTab: openSearchTab,
|
||||
selectTab,
|
||||
setActiveTab,
|
||||
};
|
||||
}
|
||||
329
src/client/features/search-tabs/useSearchTabs.ts
Normal file
329
src/client/features/search-tabs/useSearchTabs.ts
Normal file
@ -0,0 +1,329 @@
|
||||
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import type { SearchTab, SearchTabInput } from "./types";
|
||||
|
||||
type TabsState = {
|
||||
tabs: SearchTab[];
|
||||
activeTabId: string | null;
|
||||
};
|
||||
|
||||
type OpenTabInput = {
|
||||
label: string;
|
||||
input: SearchTabInput;
|
||||
};
|
||||
|
||||
type OpenTabResult = {
|
||||
tab: SearchTab | null;
|
||||
dropped: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_STATE: TabsState = {
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
};
|
||||
|
||||
const CHANGE_EVENT = "search-tabs-change";
|
||||
const stateCache = new Map<string, TabsState>();
|
||||
const SEARCH_TABS_LIMIT = 8;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function parseTabInput(value: unknown): SearchTabInput | null {
|
||||
if (!isRecord(value)) return null;
|
||||
if (value.type === "backlinks") {
|
||||
if (typeof value.target !== "string" || value.target === "") return null;
|
||||
if (value.scope !== "domain" && value.scope !== "page") return null;
|
||||
return {
|
||||
type: "backlinks",
|
||||
target: value.target,
|
||||
scope: value.scope,
|
||||
};
|
||||
}
|
||||
|
||||
if (value.type === "domain") {
|
||||
if (typeof value.domain !== "string" || value.domain === "") return null;
|
||||
if (typeof value.subdomains !== "boolean") return null;
|
||||
if (
|
||||
value.sort !== "rank" &&
|
||||
value.sort !== "traffic" &&
|
||||
value.sort !== "volume" &&
|
||||
value.sort !== "score" &&
|
||||
value.sort !== "cpc"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (value.order !== "asc" && value.order !== "desc") return null;
|
||||
if (typeof value.locationCode !== "number") return null;
|
||||
return {
|
||||
type: "domain",
|
||||
domain: value.domain,
|
||||
subdomains: value.subdomains,
|
||||
sort: value.sort,
|
||||
order: value.order,
|
||||
locationCode: value.locationCode,
|
||||
};
|
||||
}
|
||||
|
||||
if (value.type === "keyword") {
|
||||
if (typeof value.keyword !== "string" || value.keyword === "") return null;
|
||||
if (typeof value.locationCode !== "number") return null;
|
||||
if (
|
||||
value.resultLimit !== 150 &&
|
||||
value.resultLimit !== 300 &&
|
||||
value.resultLimit !== 500
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
value.mode !== "auto" &&
|
||||
value.mode !== "related" &&
|
||||
value.mode !== "suggestions" &&
|
||||
value.mode !== "ideas"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "keyword",
|
||||
keyword: value.keyword,
|
||||
locationCode: value.locationCode,
|
||||
resultLimit: value.resultLimit,
|
||||
mode: value.mode,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function storageKey(key: string) {
|
||||
return `search-tabs:${key}`;
|
||||
}
|
||||
|
||||
function tabInputKey(value: unknown): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function parseStoredState(value: unknown): TabsState {
|
||||
if (!isRecord(value)) return EMPTY_STATE;
|
||||
if (!Array.isArray(value.tabs)) return EMPTY_STATE;
|
||||
const tabs = value.tabs
|
||||
.flatMap((tab): SearchTab[] => {
|
||||
if (!isRecord(tab)) return [];
|
||||
if (typeof tab.id !== "string" || tab.id === "") return [];
|
||||
if (typeof tab.label !== "string" || tab.label === "") return [];
|
||||
if (typeof tab.createdAt !== "number") return [];
|
||||
const input = parseTabInput(tab.input);
|
||||
if (!input) return [];
|
||||
return [
|
||||
{
|
||||
id: tab.id,
|
||||
label: tab.label,
|
||||
input,
|
||||
createdAt: tab.createdAt,
|
||||
viewedAt:
|
||||
tab.viewedAt === null
|
||||
? null
|
||||
: typeof tab.viewedAt === "number"
|
||||
? tab.viewedAt
|
||||
: null,
|
||||
},
|
||||
];
|
||||
})
|
||||
.slice(0, SEARCH_TABS_LIMIT);
|
||||
const activeTabId =
|
||||
typeof value.activeTabId === "string" &&
|
||||
tabs.some((tab) => tab.id === value.activeTabId)
|
||||
? value.activeTabId
|
||||
: null;
|
||||
return { tabs, activeTabId };
|
||||
}
|
||||
|
||||
function loadState(key: string): TabsState {
|
||||
if (typeof window === "undefined") return EMPTY_STATE;
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(storageKey(key));
|
||||
if (!raw) return EMPTY_STATE;
|
||||
return parseStoredState(JSON.parse(raw));
|
||||
} catch {
|
||||
return EMPTY_STATE;
|
||||
}
|
||||
}
|
||||
|
||||
export function getSearchTabsSnapshot(key: string): TabsState {
|
||||
let state = stateCache.get(key);
|
||||
if (!state) {
|
||||
state = loadState(key);
|
||||
stateCache.set(key, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function persist(key: string, state: TabsState) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.sessionStorage.setItem(storageKey(key), JSON.stringify(state));
|
||||
} catch {
|
||||
// In-memory tabs still work if sessionStorage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
function notify() {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(new Event(CHANGE_EVENT));
|
||||
}
|
||||
|
||||
function update(key: string, updater: (current: TabsState) => TabsState) {
|
||||
const current = getSearchTabsSnapshot(key);
|
||||
const next = updater(current);
|
||||
if (next === current) return;
|
||||
stateCache.set(key, next);
|
||||
persist(key, next);
|
||||
notify();
|
||||
}
|
||||
|
||||
function subscribe(onChange: () => void) {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
window.addEventListener(CHANGE_EVENT, onChange);
|
||||
return () => window.removeEventListener(CHANGE_EVENT, onChange);
|
||||
}
|
||||
|
||||
function generateTabId(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `tab_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
export function useSearchTabs(key: string) {
|
||||
const state = useSyncExternalStore(
|
||||
subscribe,
|
||||
() => getSearchTabsSnapshot(key),
|
||||
() => EMPTY_STATE,
|
||||
);
|
||||
|
||||
const openTab = useCallback(
|
||||
({ label, input }: OpenTabInput): OpenTabResult => {
|
||||
let result: SearchTab | null = null;
|
||||
let dropped = false;
|
||||
update(key, (current) => {
|
||||
const inputKey = tabInputKey(input);
|
||||
const existing = current.tabs.find(
|
||||
(tab) => tabInputKey(tab.input) === inputKey,
|
||||
);
|
||||
if (existing) {
|
||||
result = existing;
|
||||
return { ...current, activeTabId: existing.id };
|
||||
}
|
||||
if (current.tabs.length >= SEARCH_TABS_LIMIT) {
|
||||
dropped = true;
|
||||
return current;
|
||||
}
|
||||
const next: SearchTab = {
|
||||
id: generateTabId(),
|
||||
label,
|
||||
input,
|
||||
createdAt: Date.now(),
|
||||
viewedAt: null,
|
||||
};
|
||||
result = next;
|
||||
return {
|
||||
tabs: [...current.tabs, next],
|
||||
activeTabId: next.id,
|
||||
};
|
||||
});
|
||||
return { tab: result, dropped };
|
||||
},
|
||||
[key],
|
||||
);
|
||||
|
||||
const setActiveTab = useCallback(
|
||||
(tabId: string | null) => {
|
||||
update(key, (current) => {
|
||||
if (current.activeTabId === tabId) return current;
|
||||
if (tabId !== null && !current.tabs.some((tab) => tab.id === tabId)) {
|
||||
return current;
|
||||
}
|
||||
return { ...current, activeTabId: tabId };
|
||||
});
|
||||
},
|
||||
[key],
|
||||
);
|
||||
|
||||
const closeTab = useCallback(
|
||||
(
|
||||
tabId: string,
|
||||
): {
|
||||
closedActive: boolean;
|
||||
nextActiveTab: SearchTab | null;
|
||||
} => {
|
||||
let nextActiveTab: SearchTab | null = null;
|
||||
let closedActive = false;
|
||||
update(key, (current) => {
|
||||
const index = current.tabs.findIndex((tab) => tab.id === tabId);
|
||||
if (index === -1) return current;
|
||||
const tabs = current.tabs.filter((tab) => tab.id !== tabId);
|
||||
let activeTabId = current.activeTabId;
|
||||
if (current.activeTabId === tabId) {
|
||||
closedActive = true;
|
||||
const neighbor = tabs[index] ?? tabs[index - 1] ?? null;
|
||||
activeTabId = neighbor?.id ?? null;
|
||||
nextActiveTab = neighbor;
|
||||
}
|
||||
return { tabs, activeTabId };
|
||||
});
|
||||
return { closedActive, nextActiveTab };
|
||||
},
|
||||
[key],
|
||||
);
|
||||
|
||||
const markTabViewed = useCallback(
|
||||
(tabId: string, when = Date.now()) => {
|
||||
update(key, (current) => {
|
||||
let changed = false;
|
||||
const tabs = current.tabs.map((tab) => {
|
||||
if (tab.id !== tabId) return tab;
|
||||
if (tab.viewedAt !== null && tab.viewedAt >= when) return tab;
|
||||
changed = true;
|
||||
return { ...tab, viewedAt: when };
|
||||
});
|
||||
if (!changed) return current;
|
||||
return { ...current, tabs };
|
||||
});
|
||||
},
|
||||
[key],
|
||||
);
|
||||
|
||||
const findMatchingTab = useCallback(
|
||||
(input: SearchTabInput) => {
|
||||
const inputKey = tabInputKey(input);
|
||||
return (
|
||||
state.tabs.find((tab) => tabInputKey(tab.input) === inputKey) ?? null
|
||||
);
|
||||
},
|
||||
[state.tabs],
|
||||
);
|
||||
|
||||
const canOpenTab = useCallback(
|
||||
(input: SearchTabInput) =>
|
||||
Boolean(findMatchingTab(input)) || state.tabs.length < SEARCH_TABS_LIMIT,
|
||||
[findMatchingTab, state.tabs.length],
|
||||
);
|
||||
|
||||
const activeTab = useMemo(
|
||||
() => state.tabs.find((tab) => tab.id === state.activeTabId) ?? null,
|
||||
[state.activeTabId, state.tabs],
|
||||
);
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
activeTabId: state.activeTabId,
|
||||
tabs: state.tabs,
|
||||
canOpenTab,
|
||||
closeTab,
|
||||
findMatchingTab,
|
||||
limit: SEARCH_TABS_LIMIT,
|
||||
markTabViewed,
|
||||
openTab,
|
||||
setActiveTab,
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user