Skip to content

Commit 4834cf5

Browse files
feat(search): add advanced search tab component and state management
1 parent e1dabe2 commit 4834cf5

File tree

9 files changed

+430
-1
lines changed

9 files changed

+430
-1
lines changed
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { SearchIcon } from "lucide-react";
2+
import { useCallback } from "react";
3+
import { useShallow } from "zustand/shallow";
4+
5+
import { type Tab, useTabs } from "../../../../store/zustand/tabs";
6+
import { StandardTabWrapper } from "../index";
7+
import { type TabItem, TabItemBase } from "../shared";
8+
import { AdvancedSearchView } from "./view";
9+
10+
export const TabItemSearch: TabItem<Extract<Tab, { type: "search" }>> = ({
11+
tab,
12+
tabIndex,
13+
handleCloseThis,
14+
handleSelectThis,
15+
handleCloseOthers,
16+
handleCloseAll,
17+
handlePinThis,
18+
handleUnpinThis,
19+
}) => {
20+
return (
21+
<TabItemBase
22+
icon={<SearchIcon className="w-4 h-4" />}
23+
title="Advanced Search"
24+
selected={tab.active}
25+
pinned={tab.pinned}
26+
tabIndex={tabIndex}
27+
handleCloseThis={() => handleCloseThis(tab)}
28+
handleSelectThis={() => handleSelectThis(tab)}
29+
handleCloseOthers={handleCloseOthers}
30+
handleCloseAll={handleCloseAll}
31+
handlePinThis={() => handlePinThis(tab)}
32+
handleUnpinThis={() => handleUnpinThis(tab)}
33+
/>
34+
);
35+
};
36+
37+
export function TabContentSearch({
38+
tab,
39+
}: {
40+
tab: Extract<Tab, { type: "search" }>;
41+
}) {
42+
return (
43+
<StandardTabWrapper>
44+
<SearchView tab={tab} />
45+
</StandardTabWrapper>
46+
);
47+
}
48+
49+
function SearchView({ tab }: { tab: Extract<Tab, { type: "search" }> }) {
50+
const updateSearchTabState = useTabs((state) => state.updateSearchTabState);
51+
const { openCurrent } = useTabs(
52+
useShallow((state) => ({
53+
openCurrent: state.openCurrent,
54+
})),
55+
);
56+
57+
const { selectedTypes } = tab.state;
58+
59+
const setSelectedTypes = useCallback(
60+
(types: string[] | null) => {
61+
updateSearchTabState(tab, {
62+
...tab.state,
63+
selectedTypes: types,
64+
});
65+
},
66+
[updateSearchTabState, tab],
67+
);
68+
69+
const handleResultClick = useCallback(
70+
(type: string, id: string) => {
71+
if (type === "session") {
72+
openCurrent({ type: "sessions", id });
73+
} else if (type === "human") {
74+
openCurrent({ type: "humans", id });
75+
} else if (type === "organization") {
76+
openCurrent({ type: "organizations", id });
77+
}
78+
},
79+
[openCurrent],
80+
);
81+
82+
return (
83+
<AdvancedSearchView
84+
selectedTypes={selectedTypes}
85+
setSelectedTypes={setSelectedTypes}
86+
onResultClick={handleResultClick}
87+
/>
88+
);
89+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { Building2Icon, FileTextIcon, UserIcon } from "lucide-react";
2+
3+
import { cn } from "@hypr/utils";
4+
5+
import type { SearchResult } from "../../../../contexts/search/ui";
6+
7+
const TYPE_ICONS = {
8+
session: FileTextIcon,
9+
human: UserIcon,
10+
organization: Building2Icon,
11+
};
12+
13+
interface ResultItemProps {
14+
result: SearchResult;
15+
onClick: () => void;
16+
}
17+
18+
export function ResultItem({ result, onClick }: ResultItemProps) {
19+
const Icon = TYPE_ICONS[result.type] || FileTextIcon;
20+
21+
return (
22+
<button
23+
onClick={onClick}
24+
className={cn([
25+
"w-full flex items-start gap-3 p-3",
26+
"rounded-lg text-left",
27+
"hover:bg-neutral-100 transition-colors",
28+
])}
29+
>
30+
<div className="mt-0.5 shrink-0">
31+
<Icon className="h-5 w-5 text-neutral-400" />
32+
</div>
33+
<div className="flex-1 min-w-0">
34+
<div
35+
className="font-medium text-neutral-900 truncate"
36+
dangerouslySetInnerHTML={{ __html: result.titleHighlighted }}
37+
/>
38+
{result.content && (
39+
<div
40+
className="text-sm text-neutral-500 truncate mt-0.5"
41+
dangerouslySetInnerHTML={{ __html: result.contentHighlighted }}
42+
/>
43+
)}
44+
</div>
45+
</button>
46+
);
47+
}
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import { Loader2Icon, SearchIcon, SparklesIcon, XIcon } from "lucide-react";
2+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3+
4+
import { Badge } from "@hypr/ui/components/ui/badge";
5+
import { cn } from "@hypr/utils";
6+
7+
import {
8+
type GroupedSearchResults,
9+
type SearchEntityType,
10+
useSearch,
11+
} from "../../../../contexts/search/ui";
12+
import { ResultItem } from "./result-item";
13+
14+
const FILTER_OPTIONS: { type: SearchEntityType; label: string }[] = [
15+
{ type: "session", label: "Meeting note" },
16+
{ type: "human", label: "Person" },
17+
{ type: "organization", label: "Organization" },
18+
];
19+
20+
interface AdvancedSearchViewProps {
21+
selectedTypes: string[] | null;
22+
setSelectedTypes: (types: string[] | null) => void;
23+
onResultClick: (type: string, id: string) => void;
24+
}
25+
26+
export function AdvancedSearchView({
27+
selectedTypes,
28+
setSelectedTypes,
29+
onResultClick,
30+
}: AdvancedSearchViewProps) {
31+
const { query, setQuery, results, isSearching, isIndexing } = useSearch();
32+
const [localQuery, setLocalQuery] = useState(query);
33+
const inputRef = useRef<HTMLInputElement>(null);
34+
35+
useEffect(() => {
36+
const timer = setTimeout(() => {
37+
setQuery(localQuery);
38+
}, 200);
39+
return () => clearTimeout(timer);
40+
}, [localQuery, setQuery]);
41+
42+
useEffect(() => {
43+
inputRef.current?.focus();
44+
}, []);
45+
46+
const toggleFilter = useCallback(
47+
(type: SearchEntityType) => {
48+
if (!selectedTypes) {
49+
setSelectedTypes([type]);
50+
} else if (selectedTypes.includes(type)) {
51+
const newTypes = selectedTypes.filter((t) => t !== type);
52+
setSelectedTypes(newTypes.length > 0 ? newTypes : null);
53+
} else {
54+
setSelectedTypes([...selectedTypes, type]);
55+
}
56+
},
57+
[selectedTypes, setSelectedTypes],
58+
);
59+
60+
const filteredResults = useMemo(() => {
61+
if (!results || !selectedTypes || selectedTypes.length === 0) {
62+
return results;
63+
}
64+
return {
65+
...results,
66+
groups: results.groups.filter((group) =>
67+
selectedTypes.includes(group.type),
68+
),
69+
totalResults: results.groups
70+
.filter((group) => selectedTypes.includes(group.type))
71+
.reduce((acc, group) => acc + group.totalCount, 0),
72+
};
73+
}, [results, selectedTypes]);
74+
75+
const showLoading = isSearching || isIndexing;
76+
const hasQuery = query.trim().length > 0;
77+
const hasResults = filteredResults && filteredResults.totalResults > 0;
78+
79+
return (
80+
<div className="flex flex-col h-full">
81+
<div className="px-6 pt-6 pb-4 border-b border-neutral-100">
82+
<div className="relative">
83+
{showLoading ? (
84+
<Loader2Icon className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-neutral-400 animate-spin" />
85+
) : (
86+
<SearchIcon className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-neutral-400" />
87+
)}
88+
<input
89+
ref={inputRef}
90+
type="text"
91+
placeholder="Try 'budget', '@john', or '#design'"
92+
value={localQuery}
93+
onChange={(e) => setLocalQuery(e.target.value)}
94+
className={cn([
95+
"w-full pl-12 pr-10 py-3",
96+
"text-base placeholder:text-neutral-400",
97+
"rounded-xl bg-neutral-100",
98+
"border border-transparent",
99+
"focus:outline-none focus:border-neutral-300 focus:bg-white",
100+
"transition-all",
101+
])}
102+
/>
103+
{localQuery && (
104+
<button
105+
onClick={() => setLocalQuery("")}
106+
className="absolute right-4 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600"
107+
>
108+
<XIcon className="h-5 w-5" />
109+
</button>
110+
)}
111+
</div>
112+
113+
<div className="flex gap-2 mt-4">
114+
{FILTER_OPTIONS.map((option) => {
115+
const isActive = selectedTypes?.includes(option.type);
116+
return (
117+
<Badge
118+
key={option.type}
119+
variant={isActive ? "default" : "outline"}
120+
className={cn([
121+
"cursor-pointer transition-all",
122+
isActive
123+
? "bg-neutral-900 text-white hover:bg-neutral-800"
124+
: "bg-white text-neutral-600 hover:bg-neutral-100 border-neutral-200",
125+
])}
126+
onClick={() => toggleFilter(option.type)}
127+
>
128+
{option.label}
129+
</Badge>
130+
);
131+
})}
132+
</div>
133+
</div>
134+
135+
<div className="flex-1 overflow-y-auto">
136+
{!hasQuery ? (
137+
<SuggestionsView
138+
results={filteredResults}
139+
onResultClick={onResultClick}
140+
/>
141+
) : hasResults ? (
142+
<SearchResultsView
143+
results={filteredResults!}
144+
onResultClick={onResultClick}
145+
/>
146+
) : (
147+
<NoResultsView query={query} />
148+
)}
149+
</div>
150+
</div>
151+
);
152+
}
153+
154+
function SuggestionsView({
155+
results,
156+
onResultClick,
157+
}: {
158+
results: GroupedSearchResults | null;
159+
onResultClick: (type: string, id: string) => void;
160+
}) {
161+
return (
162+
<div className="p-6">
163+
<div className="flex items-center gap-2 text-sm text-neutral-500 mb-4">
164+
<SparklesIcon className="h-4 w-4" />
165+
<span>Suggestions</span>
166+
</div>
167+
{results && results.totalResults > 0 ? (
168+
<div className="space-y-1">
169+
{results.groups
170+
.slice(0, 3)
171+
.flatMap((group) =>
172+
group.results
173+
.slice(0, 5)
174+
.map((result) => (
175+
<ResultItem
176+
key={result.id}
177+
result={result}
178+
onClick={() => onResultClick(result.type, result.id)}
179+
/>
180+
)),
181+
)}
182+
</div>
183+
) : (
184+
<div className="text-center py-12 text-neutral-400">
185+
<p>Start typing to search</p>
186+
<p className="text-sm mt-1">or browse your recent notes</p>
187+
</div>
188+
)}
189+
</div>
190+
);
191+
}
192+
193+
function SearchResultsView({
194+
results,
195+
onResultClick,
196+
}: {
197+
results: GroupedSearchResults;
198+
onResultClick: (type: string, id: string) => void;
199+
}) {
200+
return (
201+
<div className="p-6">
202+
{results.groups.map((group) => (
203+
<div key={group.key} className="mb-6">
204+
<h3 className="text-sm font-semibold text-neutral-900 mb-3">
205+
{group.title}
206+
</h3>
207+
<div className="space-y-1">
208+
{group.results.map((result) => (
209+
<ResultItem
210+
key={result.id}
211+
result={result}
212+
onClick={() => onResultClick(result.type, result.id)}
213+
/>
214+
))}
215+
</div>
216+
</div>
217+
))}
218+
</div>
219+
);
220+
}
221+
222+
function NoResultsView({ query }: { query: string }) {
223+
return (
224+
<div className="flex flex-col items-center justify-center h-full py-12">
225+
<SearchIcon className="h-12 w-12 text-neutral-200 mb-4" />
226+
<p className="text-neutral-600 font-medium">No results found</p>
227+
<p className="text-sm text-neutral-400 mt-1">No matches for "{query}"</p>
228+
</div>
229+
);
230+
}

0 commit comments

Comments
 (0)