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 { BacklinksSearchCard } from "./BacklinksSearchCard";
|
||||||
import { BacklinksBody } from "./BacklinksPageContent";
|
import { BacklinksBody } from "./BacklinksPageContent";
|
||||||
import type { BacklinksPageProps } from "./backlinksPageTypes";
|
import type { BacklinksPageProps } from "./backlinksPageTypes";
|
||||||
|
import type { BacklinksSearchState } from "./backlinksPageTypes";
|
||||||
import {
|
import {
|
||||||
navigateToBacklinksSearch,
|
navigateToBacklinksSearch,
|
||||||
useBacklinksPageData,
|
useBacklinksPageData,
|
||||||
} from "./useBacklinksPageData";
|
} from "./useBacklinksPageData";
|
||||||
import { useBacklinksFilters } from "./useBacklinksFilters";
|
import { useBacklinksFilters } from "./useBacklinksFilters";
|
||||||
import { useBacklinksSearchHistory } from "@/client/hooks/useBacklinksSearchHistory";
|
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({
|
export function BacklinksPage({
|
||||||
projectId,
|
projectId,
|
||||||
@ -34,6 +41,49 @@ export function BacklinksPage({
|
|||||||
addSearch,
|
addSearch,
|
||||||
removeHistoryItem,
|
removeHistoryItem,
|
||||||
} = useBacklinksSearchHistory(projectId);
|
} = 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 (
|
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">
|
||||||
@ -57,7 +107,12 @@ export function BacklinksPage({
|
|||||||
referringDomainsQuery.isFetching ||
|
referringDomainsQuery.isFetching ||
|
||||||
topPagesQuery.isFetching
|
topPagesQuery.isFetching
|
||||||
}
|
}
|
||||||
|
canOpenSearch={(values) =>
|
||||||
|
searchTabs.canOpenTab(toBacklinksTabInput(values))
|
||||||
|
}
|
||||||
|
tabLimit={searchTabs.limit}
|
||||||
onSubmit={(values) => {
|
onSubmit={(values) => {
|
||||||
|
searchTabs.openTab(toBacklinksTabInput(values));
|
||||||
navigateToBacklinksSearch(navigate, values);
|
navigateToBacklinksSearch(navigate, values);
|
||||||
addSearch({ target: values.target, scope: values.scope });
|
addSearch({ target: values.target, scope: values.scope });
|
||||||
}}
|
}}
|
||||||
@ -85,6 +140,16 @@ export function BacklinksPage({
|
|||||||
topPages={topPagesQuery.data}
|
topPages={topPagesQuery.data}
|
||||||
onRemoveHistoryItem={removeHistoryItem}
|
onRemoveHistoryItem={removeHistoryItem}
|
||||||
onRetryOverview={() => void overviewQuery.refetch()}
|
onRetryOverview={() => void overviewQuery.refetch()}
|
||||||
|
searchTabs={
|
||||||
|
searchState.target
|
||||||
|
? {
|
||||||
|
activeTabId: searchTabs.activeTabId,
|
||||||
|
tabs: searchTabs.tabs,
|
||||||
|
onSelect: searchTabs.selectTab,
|
||||||
|
onClose: searchTabs.closeTab,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -25,6 +25,10 @@ import {
|
|||||||
filterTopPageRows,
|
filterTopPageRows,
|
||||||
} from "./backlinksFiltering";
|
} from "./backlinksFiltering";
|
||||||
import type { BacklinksFiltersState } from "./useBacklinksFilters";
|
import type { BacklinksFiltersState } from "./useBacklinksFilters";
|
||||||
|
import {
|
||||||
|
SearchTabStrip,
|
||||||
|
type SearchTab,
|
||||||
|
} from "@/client/features/search-tabs/SearchTabStrip";
|
||||||
|
|
||||||
type BacklinksBodyProps = {
|
type BacklinksBodyProps = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@ -43,6 +47,12 @@ type BacklinksBodyProps = {
|
|||||||
topPages: BacklinksTopPagesData | undefined;
|
topPages: BacklinksTopPagesData | undefined;
|
||||||
onRemoveHistoryItem: (timestamp: number) => void;
|
onRemoveHistoryItem: (timestamp: number) => void;
|
||||||
onRetryOverview: () => void;
|
onRetryOverview: () => void;
|
||||||
|
searchTabs: {
|
||||||
|
activeTabId: string | null;
|
||||||
|
tabs: SearchTab[];
|
||||||
|
onSelect: (tab: SearchTab) => void;
|
||||||
|
onClose: (tabId: string) => void;
|
||||||
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BacklinksBody({
|
export function BacklinksBody({
|
||||||
@ -62,6 +72,7 @@ export function BacklinksBody({
|
|||||||
topPages,
|
topPages,
|
||||||
onRemoveHistoryItem,
|
onRemoveHistoryItem,
|
||||||
onRetryOverview,
|
onRetryOverview,
|
||||||
|
searchTabs,
|
||||||
}: BacklinksBodyProps) {
|
}: BacklinksBodyProps) {
|
||||||
const mergedData = useMemo(
|
const mergedData = useMemo(
|
||||||
() => mergeTabData(overviewData, referringDomains, topPages),
|
() => mergeTabData(overviewData, referringDomains, topPages),
|
||||||
@ -92,6 +103,14 @@ export function BacklinksBody({
|
|||||||
() => buildSummaryStats(mergedData),
|
() => buildSummaryStats(mergedData),
|
||||||
[mergedData],
|
[mergedData],
|
||||||
);
|
);
|
||||||
|
const tabStrip = searchTabs ? (
|
||||||
|
<SearchTabStrip
|
||||||
|
activeTabId={searchTabs.activeTabId}
|
||||||
|
tabs={searchTabs.tabs}
|
||||||
|
onSelect={searchTabs.onSelect}
|
||||||
|
onClose={searchTabs.onClose}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
if (accessGate.isLoading) {
|
if (accessGate.isLoading) {
|
||||||
return <BacklinksAccessLoadingState />;
|
return <BacklinksAccessLoadingState />;
|
||||||
@ -128,20 +147,29 @@ export function BacklinksBody({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (overviewLoading) {
|
if (overviewLoading) {
|
||||||
return <BacklinksLoadingState />;
|
return (
|
||||||
|
<>
|
||||||
|
{tabStrip}
|
||||||
|
<BacklinksLoadingState />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mergedData) {
|
if (!mergedData) {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{tabStrip}
|
||||||
<BacklinksErrorState
|
<BacklinksErrorState
|
||||||
errorMessage={overviewError}
|
errorMessage={overviewError}
|
||||||
onRetry={onRetryOverview}
|
onRetry={onRetryOverview}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{tabStrip}
|
||||||
<BacklinksOverviewPanels
|
<BacklinksOverviewPanels
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
data={mergedData}
|
data={mergedData}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { Search } from "lucide-react";
|
|||||||
import {
|
import {
|
||||||
createFormValidationErrors,
|
createFormValidationErrors,
|
||||||
getFieldError,
|
getFieldError,
|
||||||
|
getFormError,
|
||||||
shouldValidateFieldOnChange,
|
shouldValidateFieldOnChange,
|
||||||
} from "@/client/lib/forms";
|
} from "@/client/lib/forms";
|
||||||
import type { BacklinksSearchState } from "./backlinksPageTypes";
|
import type { BacklinksSearchState } from "./backlinksPageTypes";
|
||||||
@ -14,11 +15,10 @@ type SearchDraft = Pick<BacklinksSearchState, "target" | "scope">;
|
|||||||
function getBacklinksValidationErrors(
|
function getBacklinksValidationErrors(
|
||||||
value: SearchDraft,
|
value: SearchDraft,
|
||||||
shouldValidateUntouchedField: boolean,
|
shouldValidateUntouchedField: boolean,
|
||||||
|
canOpenSearch?: (value: SearchDraft) => boolean,
|
||||||
|
tabLimit?: number,
|
||||||
) {
|
) {
|
||||||
if (value.target.trim()) {
|
if (!value.target.trim()) {
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!shouldValidateUntouchedField) {
|
if (!shouldValidateUntouchedField) {
|
||||||
return null;
|
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({
|
export function BacklinksSearchCard({
|
||||||
|
canOpenSearch,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
initialValues,
|
initialValues,
|
||||||
isFetching,
|
isFetching,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
tabLimit,
|
||||||
}: {
|
}: {
|
||||||
|
canOpenSearch?: (values: SearchDraft) => boolean;
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
initialValues: SearchDraft;
|
initialValues: SearchDraft;
|
||||||
isFetching: boolean;
|
isFetching: boolean;
|
||||||
onSubmit: (values: SearchDraft) => void;
|
onSubmit: (values: SearchDraft) => void;
|
||||||
|
tabLimit?: number;
|
||||||
}) {
|
}) {
|
||||||
const [userSelectedScope, setUserSelectedScope] = useState(false);
|
const [userSelectedScope, setUserSelectedScope] = useState(false);
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
@ -49,8 +69,11 @@ export function BacklinksSearchCard({
|
|||||||
getBacklinksValidationErrors(
|
getBacklinksValidationErrors(
|
||||||
value,
|
value,
|
||||||
shouldValidateFieldOnChange(formApi, "target"),
|
shouldValidateFieldOnChange(formApi, "target"),
|
||||||
|
canOpenSearch,
|
||||||
|
tabLimit,
|
||||||
),
|
),
|
||||||
onSubmit: ({ value }) => getBacklinksValidationErrors(value, true),
|
onSubmit: ({ value }) =>
|
||||||
|
getBacklinksValidationErrors(value, true, canOpenSearch, tabLimit),
|
||||||
},
|
},
|
||||||
onSubmit: ({ value }) => {
|
onSubmit: ({ value }) => {
|
||||||
const target = value.target.trim();
|
const target = value.target.trim();
|
||||||
@ -140,6 +163,16 @@ export function BacklinksSearchCard({
|
|||||||
}}
|
}}
|
||||||
</form.Field>
|
</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">
|
<div className="flex items-center gap-1">
|
||||||
<form.Field name="scope">
|
<form.Field name="scope">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
|
|||||||
@ -1,21 +1,29 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
import { ArrowLeft } from "lucide-react";
|
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";
|
||||||
import { DomainSearchCard } from "@/client/features/domain/components/DomainSearchCard";
|
import { DomainSearchCard } from "@/client/features/domain/components/DomainSearchCard";
|
||||||
import { StatCard } from "@/client/features/domain/components/StatCard";
|
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 { useDomainOverviewController } from "@/client/features/domain/useDomainOverviewController";
|
||||||
import {
|
import {
|
||||||
|
normalizeDomainTarget,
|
||||||
formatMetric,
|
formatMetric,
|
||||||
getDefaultSortOrder,
|
getDefaultSortOrder,
|
||||||
|
toSortOrderSearchParam,
|
||||||
} from "@/client/features/domain/utils";
|
} from "@/client/features/domain/utils";
|
||||||
|
import { createFormValidationErrors } from "@/client/lib/forms";
|
||||||
import type {
|
import type {
|
||||||
DomainActiveTab,
|
DomainActiveTab,
|
||||||
DomainFilterValues,
|
DomainFilterValues,
|
||||||
DomainSortMode,
|
DomainSortMode,
|
||||||
SortOrder,
|
SortOrder,
|
||||||
} from "@/client/features/domain/types";
|
} from "@/client/features/domain/types";
|
||||||
|
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@ -51,6 +59,118 @@ export function DomainOverviewPage({
|
|||||||
navigate,
|
navigate,
|
||||||
searchState,
|
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 (
|
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">
|
||||||
@ -66,7 +186,7 @@ export function DomainOverviewPage({
|
|||||||
<DomainSearchCard
|
<DomainSearchCard
|
||||||
controlsForm={state.controlsForm}
|
controlsForm={state.controlsForm}
|
||||||
isLoading={state.isLoading}
|
isLoading={state.isLoading}
|
||||||
onSubmit={state.handleSearchSubmit}
|
onSubmit={handleSearchSubmit}
|
||||||
onSortChange={(sort) =>
|
onSortChange={(sort) =>
|
||||||
state.applySort(sort, getDefaultSortOrder(sort))
|
state.applySort(sort, getDefaultSortOrder(sort))
|
||||||
}
|
}
|
||||||
@ -76,7 +196,10 @@ export function DomainOverviewPage({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{state.isLoading ? (
|
{state.isLoading ? (
|
||||||
|
<>
|
||||||
|
{tabControls}
|
||||||
<DomainOverviewLoadingState />
|
<DomainOverviewLoadingState />
|
||||||
|
</>
|
||||||
) : state.overview === null ? (
|
) : state.overview === null ? (
|
||||||
<div className="space-y-4 pt-1">
|
<div className="space-y-4 pt-1">
|
||||||
<DomainHistorySection
|
<DomainHistorySection
|
||||||
@ -88,16 +211,7 @@ export function DomainOverviewPage({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div>
|
{tabControls}
|
||||||
<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>
|
|
||||||
<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"
|
||||||
|
|||||||
@ -18,11 +18,11 @@ import {
|
|||||||
buildKeywordSearchKey,
|
buildKeywordSearchKey,
|
||||||
} from "@/client/features/keywords/state/keywordControllerActions";
|
} from "@/client/features/keywords/state/keywordControllerActions";
|
||||||
import { useKeywordSearchParams } from "@/client/features/keywords/state/keywordControllerInternals";
|
import { useKeywordSearchParams } from "@/client/features/keywords/state/keywordControllerInternals";
|
||||||
import { useKeywordTabs } from "@/client/features/keywords/state/useKeywordTabs";
|
|
||||||
import {
|
import {
|
||||||
getKeywordTabsSnapshot,
|
getKeywordTabsSnapshot,
|
||||||
|
useKeywordTabs,
|
||||||
type OpenTabInput,
|
type OpenTabInput,
|
||||||
} from "@/client/features/keywords/state/keywordTabsStore";
|
} from "@/client/features/keywords/state/useKeywordTabs";
|
||||||
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
|
import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations";
|
||||||
import { KeywordResearchEmptyState } from "./KeywordResearchEmptyState";
|
import { KeywordResearchEmptyState } from "./KeywordResearchEmptyState";
|
||||||
import { KeywordResearchLoadingState } from "./KeywordResearchLoadingState";
|
import { KeywordResearchLoadingState } from "./KeywordResearchLoadingState";
|
||||||
|
|||||||
@ -1,16 +1,14 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { X } from "lucide-react";
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
|
import { SearchTabStrip } from "@/client/features/search-tabs/SearchTabStrip";
|
||||||
|
import type { SearchTab } from "@/client/features/search-tabs/types";
|
||||||
import {
|
import {
|
||||||
KEYWORD_RESEARCH_STALE_TIME_MS,
|
KEYWORD_RESEARCH_STALE_TIME_MS,
|
||||||
buildKeywordResearchQueryKey,
|
buildKeywordResearchQueryKey,
|
||||||
buildKeywordResearchRequest,
|
buildKeywordResearchRequest,
|
||||||
keywordResearchQueryFn,
|
keywordResearchQueryFn,
|
||||||
} from "@/client/features/keywords/hooks/useKeywordResearchData";
|
} from "@/client/features/keywords/hooks/useKeywordResearchData";
|
||||||
import type {
|
import type { UseKeywordTabsReturn } from "@/client/features/keywords/state/useKeywordTabs";
|
||||||
KeywordTab,
|
|
||||||
UseKeywordTabsReturn,
|
|
||||||
} from "@/client/features/keywords/state/useKeywordTabs";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@ -22,45 +20,35 @@ export function KeywordResearchTabStrip({ projectId, tabs, closeTab }: Props) {
|
|||||||
if (tabs.tabs.length === 0) return null;
|
if (tabs.tabs.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-base-300 bg-base-100 p-1">
|
<SearchTabStrip
|
||||||
<div
|
activeTabId={tabs.activeTabId}
|
||||||
role="tablist"
|
tabs={tabs.tabs}
|
||||||
className="flex min-w-0 items-stretch gap-1 overflow-x-auto"
|
onSelect={(tab) => tabs.setActiveTab(tab.id)}
|
||||||
>
|
onClose={closeTab}
|
||||||
{tabs.tabs.map((tab) => (
|
renderLeading={(tab, active) => (
|
||||||
<TabPill
|
<KeywordTabStatus tab={tab} projectId={projectId} active={active} />
|
||||||
key={tab.id}
|
)}
|
||||||
tab={tab}
|
|
||||||
projectId={projectId}
|
|
||||||
active={tab.id === tabs.activeTabId}
|
|
||||||
setActiveTab={tabs.setActiveTab}
|
|
||||||
closeTab={closeTab}
|
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const TabPill = memo(function TabPill({
|
const KeywordTabStatus = memo(function KeywordTabStatus({
|
||||||
tab,
|
tab,
|
||||||
projectId,
|
projectId,
|
||||||
active,
|
active,
|
||||||
setActiveTab,
|
|
||||||
closeTab,
|
|
||||||
}: {
|
}: {
|
||||||
tab: KeywordTab;
|
tab: SearchTab;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
setActiveTab: (tabId: string | null) => void;
|
|
||||||
closeTab: (tabId: string) => void;
|
|
||||||
}) {
|
}) {
|
||||||
|
if (tab.input.type !== "keyword") return null;
|
||||||
|
|
||||||
const request = buildKeywordResearchRequest({
|
const request = buildKeywordResearchRequest({
|
||||||
projectId,
|
projectId,
|
||||||
keywordInput: tab.keyword,
|
keywordInput: tab.input.keyword,
|
||||||
locationCode: tab.locationCode,
|
locationCode: tab.input.locationCode,
|
||||||
resultLimit: tab.resultLimit,
|
resultLimit: tab.input.resultLimit,
|
||||||
mode: tab.mode,
|
mode: tab.input.mode,
|
||||||
});
|
});
|
||||||
const queryKey = buildKeywordResearchQueryKey(request);
|
const queryKey = buildKeywordResearchQueryKey(request);
|
||||||
|
|
||||||
@ -86,23 +74,6 @@ const TabPill = memo(function TabPill({
|
|||||||
const isError = query.isError;
|
const isError = query.isError;
|
||||||
|
|
||||||
return (
|
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
|
<span
|
||||||
className="flex w-3.5 shrink-0 items-center justify-center"
|
className="flex w-3.5 shrink-0 items-center justify-center"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
@ -113,20 +84,5 @@ const TabPill = memo(function TabPill({
|
|||||||
<span className="size-2 rounded-full bg-primary" />
|
<span className="size-2 rounded-full bg-primary" />
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</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 KeywordMode,
|
||||||
type ResultLimit,
|
type ResultLimit,
|
||||||
} from "@/client/features/keywords/keywordResearchTypes";
|
} 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 { KeywordResearchRow } from "@/types/keywords";
|
||||||
import type { SortDir, SortField } from "@/client/features/keywords/components";
|
import type { SortDir, SortField } from "@/client/features/keywords/components";
|
||||||
import {
|
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 {
|
import {
|
||||||
EMPTY_TABS_STATE,
|
getSearchTabsSnapshot,
|
||||||
KEYWORD_TABS_LIMIT,
|
useSearchTabs,
|
||||||
closeTab as closeTabAction,
|
} from "@/client/features/search-tabs/useSearchTabs";
|
||||||
findMatchingTab,
|
|
||||||
getKeywordTabsSnapshot,
|
|
||||||
markTabViewed as markTabViewedAction,
|
|
||||||
openTabs as openTabsAction,
|
|
||||||
setActiveTab as setActiveTabAction,
|
|
||||||
subscribeKeywordTabsStore,
|
|
||||||
type KeywordTab,
|
|
||||||
type OpenTabInput,
|
|
||||||
type ProjectTabsState,
|
|
||||||
} from "./keywordTabsStore";
|
|
||||||
|
|
||||||
function useKeywordTabsSnapshot(projectId: string): ProjectTabsState {
|
export type OpenTabInput = Omit<KeywordSearchTabInput, "type">;
|
||||||
const getSnapshot = useCallback(
|
|
||||||
() => getKeywordTabsSnapshot(projectId),
|
type KeywordTab = SearchTab & {
|
||||||
[projectId],
|
input: KeywordSearchTabInput;
|
||||||
);
|
keyword: string;
|
||||||
return useSyncExternalStore(
|
locationCode: KeywordSearchTabInput["locationCode"];
|
||||||
subscribeKeywordTabsStore,
|
resultLimit: KeywordSearchTabInput["resultLimit"];
|
||||||
getSnapshot,
|
mode: KeywordSearchTabInput["mode"];
|
||||||
() => EMPTY_TABS_STATE,
|
};
|
||||||
);
|
|
||||||
|
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) {
|
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(
|
const openTabs = useCallback(
|
||||||
(inputs: OpenTabInput[]) => openTabsAction(projectId, inputs),
|
(inputs: OpenTabInput[]): OpenTabsResult => {
|
||||||
[projectId],
|
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(
|
const closeTab = useCallback(
|
||||||
(tabId: string) => closeTabAction(projectId, tabId),
|
(tabId: string) => {
|
||||||
[projectId],
|
const result = tabs.closeTab(tabId);
|
||||||
);
|
return {
|
||||||
|
closedActive: result.closedActive,
|
||||||
const setActiveTab = useCallback(
|
nextActiveTab: result.nextActiveTab
|
||||||
(tabId: string | null) => setActiveTabAction(projectId, tabId),
|
? toKeywordTab(result.nextActiveTab)
|
||||||
[projectId],
|
: null,
|
||||||
);
|
};
|
||||||
|
},
|
||||||
const markTabViewed = useCallback(
|
[tabs],
|
||||||
(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],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tabs: state.tabs,
|
tabs: keywordTabs,
|
||||||
activeTabId: state.activeTabId,
|
activeTabId: tabs.activeTabId,
|
||||||
activeTab,
|
activeTab,
|
||||||
isAtCap: state.tabs.length >= KEYWORD_TABS_LIMIT,
|
isAtCap: keywordTabs.length >= tabs.limit,
|
||||||
limit: KEYWORD_TABS_LIMIT,
|
limit: tabs.limit,
|
||||||
openTabs,
|
openTabs,
|
||||||
closeTab,
|
closeTab,
|
||||||
setActiveTab,
|
setActiveTab: tabs.setActiveTab,
|
||||||
markTabViewed,
|
markTabViewed: tabs.markTabViewed,
|
||||||
findMatchingTab: findMatching,
|
findMatchingTab,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UseKeywordTabsReturn = ReturnType<typeof useKeywordTabs>;
|
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