From efef3894cb5421a1f01fd33de790738bec40b35d Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Mon, 18 May 2026 14:51:43 -0400 Subject: [PATCH] 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 --- .../features/backlinks/BacklinksPage.tsx | 65 ++++ .../backlinks/BacklinksPageContent.tsx | 38 +- .../backlinks/BacklinksSearchCard.tsx | 53 ++- .../features/domain/DomainOverviewPage.tsx | 138 +++++++- .../keywords/page/KeywordResearchPage.tsx | 4 +- .../keywords/page/KeywordResearchTabStrip.tsx | 102 ++---- .../keywords/state/keywordTabsStore.ts | 291 ---------------- .../state/useKeywordResearchController.ts | 2 +- .../features/keywords/state/useKeywordTabs.ts | 199 +++++++---- .../features/search-tabs/SearchTabStrip.tsx | 74 ++++ src/client/features/search-tabs/types.ts | 42 +++ .../search-tabs/useSearchTabNavigation.ts | 82 +++++ .../features/search-tabs/useSearchTabs.ts | 329 ++++++++++++++++++ 13 files changed, 964 insertions(+), 455 deletions(-) delete mode 100644 src/client/features/keywords/state/keywordTabsStore.ts create mode 100644 src/client/features/search-tabs/SearchTabStrip.tsx create mode 100644 src/client/features/search-tabs/types.ts create mode 100644 src/client/features/search-tabs/useSearchTabNavigation.ts create mode 100644 src/client/features/search-tabs/useSearchTabs.ts diff --git a/src/client/features/backlinks/BacklinksPage.tsx b/src/client/features/backlinks/BacklinksPage.tsx index a2b1bb5..bad595c 100644 --- a/src/client/features/backlinks/BacklinksPage.tsx +++ b/src/client/features/backlinks/BacklinksPage.tsx @@ -1,12 +1,19 @@ +import { useCallback, useMemo } from "react"; import { BacklinksSearchCard } from "./BacklinksSearchCard"; import { BacklinksBody } from "./BacklinksPageContent"; import type { BacklinksPageProps } from "./backlinksPageTypes"; +import type { BacklinksSearchState } from "./backlinksPageTypes"; import { navigateToBacklinksSearch, useBacklinksPageData, } from "./useBacklinksPageData"; import { useBacklinksFilters } from "./useBacklinksFilters"; import { useBacklinksSearchHistory } from "@/client/hooks/useBacklinksSearchHistory"; +import type { + BacklinksSearchTabInput, + SearchTabInput, +} from "@/client/features/search-tabs/types"; +import { useSearchTabNavigation } from "@/client/features/search-tabs/useSearchTabNavigation"; export function BacklinksPage({ projectId, @@ -34,6 +41,49 @@ export function BacklinksPage({ addSearch, removeHistoryItem, } = useBacklinksSearchHistory(projectId); + const urlTabInput = useMemo(() => { + 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, + ): BacklinksSearchTabInput => ({ + type: "backlinks", + target: values.target, + scope: values.scope, + }), + [], + ); return (
@@ -57,7 +107,12 @@ export function BacklinksPage({ referringDomainsQuery.isFetching || topPagesQuery.isFetching } + canOpenSearch={(values) => + searchTabs.canOpenTab(toBacklinksTabInput(values)) + } + tabLimit={searchTabs.limit} onSubmit={(values) => { + searchTabs.openTab(toBacklinksTabInput(values)); navigateToBacklinksSearch(navigate, values); addSearch({ target: values.target, scope: values.scope }); }} @@ -85,6 +140,16 @@ export function BacklinksPage({ topPages={topPagesQuery.data} onRemoveHistoryItem={removeHistoryItem} onRetryOverview={() => void overviewQuery.refetch()} + searchTabs={ + searchState.target + ? { + activeTabId: searchTabs.activeTabId, + tabs: searchTabs.tabs, + onSelect: searchTabs.selectTab, + onClose: searchTabs.closeTab, + } + : null + } />
diff --git a/src/client/features/backlinks/BacklinksPageContent.tsx b/src/client/features/backlinks/BacklinksPageContent.tsx index 541410b..f37eb64 100644 --- a/src/client/features/backlinks/BacklinksPageContent.tsx +++ b/src/client/features/backlinks/BacklinksPageContent.tsx @@ -25,6 +25,10 @@ import { filterTopPageRows, } from "./backlinksFiltering"; import type { BacklinksFiltersState } from "./useBacklinksFilters"; +import { + SearchTabStrip, + type SearchTab, +} from "@/client/features/search-tabs/SearchTabStrip"; type BacklinksBodyProps = { projectId: string; @@ -43,6 +47,12 @@ type BacklinksBodyProps = { topPages: BacklinksTopPagesData | undefined; onRemoveHistoryItem: (timestamp: number) => void; onRetryOverview: () => void; + searchTabs: { + activeTabId: string | null; + tabs: SearchTab[]; + onSelect: (tab: SearchTab) => void; + onClose: (tabId: string) => void; + } | null; }; export function BacklinksBody({ @@ -62,6 +72,7 @@ export function BacklinksBody({ topPages, onRemoveHistoryItem, onRetryOverview, + searchTabs, }: BacklinksBodyProps) { const mergedData = useMemo( () => mergeTabData(overviewData, referringDomains, topPages), @@ -92,6 +103,14 @@ export function BacklinksBody({ () => buildSummaryStats(mergedData), [mergedData], ); + const tabStrip = searchTabs ? ( + + ) : null; if (accessGate.isLoading) { return ; @@ -128,20 +147,29 @@ export function BacklinksBody({ } if (overviewLoading) { - return ; + return ( + <> + {tabStrip} + + + ); } if (!mergedData) { return ( - + <> + {tabStrip} + + ); } return ( <> + {tabStrip} ; function getBacklinksValidationErrors( value: SearchDraft, shouldValidateUntouchedField: boolean, + canOpenSearch?: (value: SearchDraft) => boolean, + tabLimit?: number, ) { - if (value.target.trim()) { - return null; + if (!value.target.trim()) { + if (!shouldValidateUntouchedField) { + return null; + } + + return createFormValidationErrors({ + fields: { + target: "Enter a domain or URL to analyze.", + }, + }); } - if (!shouldValidateUntouchedField) { - return null; + 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 createFormValidationErrors({ - fields: { - target: "Enter a domain or URL to analyze.", - }, - }); + return null; } export function BacklinksSearchCard({ + canOpenSearch, errorMessage, initialValues, isFetching, onSubmit, + tabLimit, }: { + canOpenSearch?: (values: SearchDraft) => boolean; errorMessage: string | null; initialValues: SearchDraft; isFetching: boolean; onSubmit: (values: SearchDraft) => void; + tabLimit?: number; }) { const [userSelectedScope, setUserSelectedScope] = useState(false); const form = useForm({ @@ -49,8 +69,11 @@ export function BacklinksSearchCard({ getBacklinksValidationErrors( value, shouldValidateFieldOnChange(formApi, "target"), + canOpenSearch, + tabLimit, ), - onSubmit: ({ value }) => getBacklinksValidationErrors(value, true), + onSubmit: ({ value }) => + getBacklinksValidationErrors(value, true, canOpenSearch, tabLimit), }, onSubmit: ({ value }) => { const target = value.target.trim(); @@ -140,6 +163,16 @@ export function BacklinksSearchCard({ }} + state.errorMap.onSubmit}> + {(submitError) => { + const formError = getFormError(submitError); + + return formError ? ( +

{formError}

+ ) : null; + }} +
+
{(field) => ( diff --git a/src/client/features/domain/DomainOverviewPage.tsx b/src/client/features/domain/DomainOverviewPage.tsx index b7dcbb4..41e2e1d 100644 --- a/src/client/features/domain/DomainOverviewPage.tsx +++ b/src/client/features/domain/DomainOverviewPage.tsx @@ -1,21 +1,29 @@ import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useMemo } from "react"; import { ArrowLeft } from "lucide-react"; import { DomainOverviewLoadingState } from "@/client/features/domain/components/DomainOverviewLoadingState"; import { DomainHistorySection } from "@/client/features/domain/components/DomainHistorySection"; import { DomainResultsCard } from "@/client/features/domain/components/DomainResultsCard"; import { DomainSearchCard } from "@/client/features/domain/components/DomainSearchCard"; import { StatCard } from "@/client/features/domain/components/StatCard"; +import { SearchTabStrip } from "@/client/features/search-tabs/SearchTabStrip"; +import type { SearchTabInput } from "@/client/features/search-tabs/types"; +import { useSearchTabNavigation } from "@/client/features/search-tabs/useSearchTabNavigation"; import { useDomainOverviewController } from "@/client/features/domain/useDomainOverviewController"; import { + normalizeDomainTarget, formatMetric, getDefaultSortOrder, + toSortOrderSearchParam, } from "@/client/features/domain/utils"; +import { createFormValidationErrors } from "@/client/lib/forms"; import type { DomainActiveTab, DomainFilterValues, DomainSortMode, SortOrder, } from "@/client/features/domain/types"; +import { DEFAULT_LOCATION_CODE } from "@/client/features/keywords/locations"; type Props = { projectId: string; @@ -51,6 +59,118 @@ export function DomainOverviewPage({ navigate, searchState, }); + const urlTabInput = useMemo(() => { + 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 ? ( +
+
+ +
+ +
+ ) : null; return (
@@ -66,7 +186,7 @@ export function DomainOverviewPage({ state.applySort(sort, getDefaultSortOrder(sort)) } @@ -76,7 +196,10 @@ export function DomainOverviewPage({ /> {state.isLoading ? ( - + <> + {tabControls} + + ) : state.overview === null ? (
) : ( <> -
- -
+ {tabControls}
-
- {tabs.tabs.map((tab) => ( - - ))} -
-
+ tabs.setActiveTab(tab.id)} + onClose={closeTab} + renderLeading={(tab, active) => ( + + )} + /> ); } -const TabPill = memo(function TabPill({ +const KeywordTabStatus = memo(function KeywordTabStatus({ tab, projectId, active, - setActiveTab, - closeTab, }: { - tab: KeywordTab; + tab: SearchTab; projectId: string; active: boolean; - setActiveTab: (tabId: string | null) => void; - closeTab: (tabId: string) => void; }) { + if (tab.input.type !== "keyword") return null; + const request = buildKeywordResearchRequest({ projectId, - keywordInput: tab.keyword, - locationCode: tab.locationCode, - resultLimit: tab.resultLimit, - mode: tab.mode, + keywordInput: tab.input.keyword, + locationCode: tab.input.locationCode, + resultLimit: tab.input.resultLimit, + mode: tab.input.mode, }); const queryKey = buildKeywordResearchQueryKey(request); @@ -86,47 +74,15 @@ const TabPill = memo(function TabPill({ const isError = query.isError; return ( -
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" - }`} + - - {isError ? ( - - ) : unviewed ? ( - - ) : null} - - - {tab.keyword} - - -
+ {isError ? ( + + ) : unviewed ? ( + + ) : null} + ); }); diff --git a/src/client/features/keywords/state/keywordTabsStore.ts b/src/client/features/keywords/state/keywordTabsStore.ts deleted file mode 100644 index 72a50ec..0000000 --- a/src/client/features/keywords/state/keywordTabsStore.ts +++ /dev/null @@ -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(); - -function storageKey(projectId: string): string { - return `${STORAGE_KEY_PREFIX}${projectId}`; -} - -function isRecord(value: unknown): value is Record { - 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 }; - }); -} diff --git a/src/client/features/keywords/state/useKeywordResearchController.ts b/src/client/features/keywords/state/useKeywordResearchController.ts index fd87731..9453248 100644 --- a/src/client/features/keywords/state/useKeywordResearchController.ts +++ b/src/client/features/keywords/state/useKeywordResearchController.ts @@ -14,7 +14,7 @@ import { type KeywordMode, type ResultLimit, } from "@/client/features/keywords/keywordResearchTypes"; -import type { OpenTabInput } from "@/client/features/keywords/state/keywordTabsStore"; +import type { OpenTabInput } from "@/client/features/keywords/state/useKeywordTabs"; import type { KeywordResearchRow } from "@/types/keywords"; import type { SortDir, SortField } from "@/client/features/keywords/components"; import { diff --git a/src/client/features/keywords/state/useKeywordTabs.ts b/src/client/features/keywords/state/useKeywordTabs.ts index bf4a973..64d5e4f 100644 --- a/src/client/features/keywords/state/useKeywordTabs.ts +++ b/src/client/features/keywords/state/useKeywordTabs.ts @@ -1,81 +1,158 @@ -import { useCallback, useMemo, useSyncExternalStore } from "react"; +import { useCallback, useMemo } from "react"; +import type { + KeywordSearchTabInput, + SearchTab, +} from "@/client/features/search-tabs/types"; import { - EMPTY_TABS_STATE, - KEYWORD_TABS_LIMIT, - closeTab as closeTabAction, - findMatchingTab, - getKeywordTabsSnapshot, - markTabViewed as markTabViewedAction, - openTabs as openTabsAction, - setActiveTab as setActiveTabAction, - subscribeKeywordTabsStore, - type KeywordTab, - type OpenTabInput, - type ProjectTabsState, -} from "./keywordTabsStore"; + getSearchTabsSnapshot, + useSearchTabs, +} from "@/client/features/search-tabs/useSearchTabs"; -function useKeywordTabsSnapshot(projectId: string): ProjectTabsState { - const getSnapshot = useCallback( - () => getKeywordTabsSnapshot(projectId), - [projectId], - ); - return useSyncExternalStore( - subscribeKeywordTabsStore, - getSnapshot, - () => EMPTY_TABS_STATE, - ); +export type OpenTabInput = Omit; + +type KeywordTab = SearchTab & { + input: KeywordSearchTabInput; + keyword: string; + locationCode: KeywordSearchTabInput["locationCode"]; + resultLimit: KeywordSearchTabInput["resultLimit"]; + mode: KeywordSearchTabInput["mode"]; +}; + +type ProjectTabsState = { + tabs: KeywordTab[]; + activeTabId: string | null; +}; + +type OpenTabsResult = { + opened: KeywordTab[]; + focused: KeywordTab[]; + activeTab: KeywordTab | null; + dropped: OpenTabInput[]; +}; + +const KEYWORD_TABS_KEY_PREFIX = "keyword"; + +function keywordTabsKey(projectId: string) { + return `${KEYWORD_TABS_KEY_PREFIX}:${projectId}`; +} + +function toSearchTabInput(input: OpenTabInput): KeywordSearchTabInput { + return { + type: "keyword", + keyword: input.keyword, + locationCode: input.locationCode, + resultLimit: input.resultLimit, + mode: input.mode, + }; +} + +function toKeywordTab(tab: SearchTab): KeywordTab | null { + if (tab.input.type !== "keyword") return null; + return { + ...tab, + input: tab.input, + keyword: tab.input.keyword, + locationCode: tab.input.locationCode, + resultLimit: tab.input.resultLimit, + mode: tab.input.mode, + }; +} + +function toKeywordTabs(tabs: readonly SearchTab[]): KeywordTab[] { + return tabs.flatMap((tab) => { + const keywordTab = toKeywordTab(tab); + return keywordTab ? [keywordTab] : []; + }); +} + +export function getKeywordTabsSnapshot(projectId: string): ProjectTabsState { + const snapshot = getSearchTabsSnapshot(keywordTabsKey(projectId)); + return { + tabs: toKeywordTabs(snapshot.tabs), + activeTabId: snapshot.activeTabId, + }; } export function useKeywordTabs(projectId: string) { - const state = useKeywordTabsSnapshot(projectId); + const tabs = useSearchTabs(keywordTabsKey(projectId)); + const keywordTabs = useMemo(() => toKeywordTabs(tabs.tabs), [tabs.tabs]); + const activeTab = useMemo( + () => keywordTabs.find((tab) => tab.id === tabs.activeTabId) ?? null, + [keywordTabs, tabs.activeTabId], + ); const openTabs = useCallback( - (inputs: OpenTabInput[]) => openTabsAction(projectId, inputs), - [projectId], + (inputs: OpenTabInput[]): OpenTabsResult => { + const opened: KeywordTab[] = []; + const focused: KeywordTab[] = []; + const dropped: OpenTabInput[] = []; + let resultActiveTab: KeywordTab | null = null; + let simulatedTabs = keywordTabs; + + for (const input of inputs) { + const result = tabs.openTab({ + label: input.keyword, + input: toSearchTabInput(input), + }); + + if (result.dropped) { + dropped.push(input); + continue; + } + + if (!result.tab) continue; + const keywordTab = toKeywordTab(result.tab); + if (!keywordTab) continue; + resultActiveTab = keywordTab; + + const wasAlreadyOpen = simulatedTabs.some( + (tab) => tab.id === keywordTab.id, + ); + if (wasAlreadyOpen) focused.push(keywordTab); + else { + opened.push(keywordTab); + simulatedTabs = [...simulatedTabs, keywordTab]; + } + } + + return { opened, focused, activeTab: resultActiveTab, dropped }; + }, + [keywordTabs, tabs], + ); + + const findMatchingTab = useCallback( + (input: OpenTabInput) => { + const match = tabs.findMatchingTab(toSearchTabInput(input)); + return match ? toKeywordTab(match) : null; + }, + [tabs], ); const closeTab = useCallback( - (tabId: string) => closeTabAction(projectId, tabId), - [projectId], - ); - - const setActiveTab = useCallback( - (tabId: string | null) => setActiveTabAction(projectId, tabId), - [projectId], - ); - - const markTabViewed = useCallback( - (tabId: string, when?: number) => - markTabViewedAction(projectId, tabId, when), - [projectId], - ); - - // Reads the latest module snapshot directly so this callback is safe to use - // in effect dependency arrays — its reference is stable across renders. - const findMatching = useCallback( - (input: OpenTabInput) => - findMatchingTab(getKeywordTabsSnapshot(projectId), input), - [projectId], - ); - - const activeTab = useMemo( - () => state.tabs.find((tab) => tab.id === state.activeTabId) ?? null, - [state], + (tabId: string) => { + const result = tabs.closeTab(tabId); + return { + closedActive: result.closedActive, + nextActiveTab: result.nextActiveTab + ? toKeywordTab(result.nextActiveTab) + : null, + }; + }, + [tabs], ); return { - tabs: state.tabs, - activeTabId: state.activeTabId, + tabs: keywordTabs, + activeTabId: tabs.activeTabId, activeTab, - isAtCap: state.tabs.length >= KEYWORD_TABS_LIMIT, - limit: KEYWORD_TABS_LIMIT, + isAtCap: keywordTabs.length >= tabs.limit, + limit: tabs.limit, openTabs, closeTab, - setActiveTab, - markTabViewed, - findMatchingTab: findMatching, + setActiveTab: tabs.setActiveTab, + markTabViewed: tabs.markTabViewed, + findMatchingTab, }; } export type UseKeywordTabsReturn = ReturnType; -export type { KeywordTab }; diff --git a/src/client/features/search-tabs/SearchTabStrip.tsx b/src/client/features/search-tabs/SearchTabStrip.tsx new file mode 100644 index 0000000..7577cfa --- /dev/null +++ b/src/client/features/search-tabs/SearchTabStrip.tsx @@ -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 ( +
+
+ {tabs.map((tab) => { + const active = tab.id === activeTabId; + return ( +
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} + + {tab.label} + + +
+ ); + })} +
+
+ ); +} diff --git a/src/client/features/search-tabs/types.ts b/src/client/features/search-tabs/types.ts new file mode 100644 index 0000000..bf7de88 --- /dev/null +++ b/src/client/features/search-tabs/types.ts @@ -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; +}; diff --git a/src/client/features/search-tabs/useSearchTabNavigation.ts b/src/client/features/search-tabs/useSearchTabNavigation.ts new file mode 100644 index 0000000..8956686 --- /dev/null +++ b/src/client/features/search-tabs/useSearchTabNavigation.ts @@ -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, + }; +} diff --git a/src/client/features/search-tabs/useSearchTabs.ts b/src/client/features/search-tabs/useSearchTabs.ts new file mode 100644 index 0000000..54f6394 --- /dev/null +++ b/src/client/features/search-tabs/useSearchTabs.ts @@ -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(); +const SEARCH_TABS_LIMIT = 8; + +function isRecord(value: unknown): value is Record { + 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, + }; +}