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:
Ben Senescu 2026-05-18 14:51:43 -04:00 committed by GitHub
parent 7fa4d5cade
commit efef3894cb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 964 additions and 455 deletions

View File

@ -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>

View File

@ -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}

View File

@ -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;
} }
@ -28,18 +28,38 @@ function getBacklinksValidationErrors(
target: "Enter a domain or URL to analyze.", target: "Enter a domain or URL to analyze.",
}, },
}); });
}
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) => (

View File

@ -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"

View File

@ -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";

View File

@ -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>
); );
}); });

View File

@ -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 };
});
}

View File

@ -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 {

View File

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

View 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>
);
}

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

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

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