Skip to content

Commit 22b076d

Browse files
committed
feat: show search history
1 parent 77c14a0 commit 22b076d

File tree

7 files changed

+371
-66
lines changed

7 files changed

+371
-66
lines changed

app/[lang]/(common)/Button.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ const Button: FC<ButtonProps> = ({
5151
disabled={loading}
5252
>
5353
{loading ? (
54-
<div className="self-center border-2 border-white border-t-transparent rounded-full w-5 h-5 animate-spin" />
54+
<div className="self-center border-2 border-gray8 dark:border-gray3 border-t-transparent rounded-full w-5 h-5 animate-spin" />
5555
) : (
5656
<div
5757
className={clsx(

app/[lang]/(common)/Header/SwitchToggle.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export default function SwitchToggle({isDark, onToggle}: Props): ReactElement {
2121
/>
2222
<div
2323
onClick={onToggle}
24+
suppressHydrationWarning
2425
className={twMerge(
2526
clsx(isDark ? 'bg-gray8' : 'bg-gray3'),
2627
clsx(
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
'use client';
2+
3+
import type {ReactElement} from 'react';
4+
import {XIcon, ClockIcon} from '@primer/octicons-react';
5+
import clsx from 'clsx';
6+
7+
type Props = {
8+
history: string[];
9+
query: string;
10+
onSelectAction: (item: string) => void;
11+
onRemoveAction: (item: string) => void;
12+
show: boolean;
13+
};
14+
15+
export default function SearchHistoryDropdown({
16+
history,
17+
query,
18+
onSelectAction,
19+
onRemoveAction,
20+
show,
21+
}: Props): ReactElement | null {
22+
const filteredHistory = query
23+
? history.filter((item) =>
24+
item.toLowerCase().includes(query.toLowerCase()),
25+
)
26+
: history;
27+
28+
if (!show || filteredHistory.length === 0) {
29+
return null;
30+
}
31+
32+
return (
33+
<div
34+
className={clsx(
35+
'absolute top-full left-0 right-0 mt-2 mx-2',
36+
'bg-white/95 dark:bg-black/95',
37+
'backdrop-blur-xl',
38+
'border border-black/10 dark:border-white/20',
39+
'rounded-2xl shadow-xl',
40+
'overflow-hidden',
41+
'z-50',
42+
)}
43+
onMouseDown={(e) => e.preventDefault()}
44+
>
45+
<div className="max-h-[240px] overflow-y-auto">
46+
<div className="flex flex-wrap gap-2 p-3">
47+
{filteredHistory.map((item, index) => (
48+
<div
49+
key={`${item}-${index}`}
50+
className={clsx(
51+
'flex items-center gap-2',
52+
'px-3 py-1.5',
53+
'cursor-pointer',
54+
'transition-colors duration-150',
55+
'group',
56+
'rounded-full',
57+
'bg-black/5 dark:bg-white/10',
58+
'hover:bg-black/10 dark:hover:bg-white/20',
59+
)}
60+
onClick={() => onSelectAction?.(item)}
61+
>
62+
<ClockIcon
63+
size={14}
64+
className="text-gray5 dark:text-gray4 flex-shrink-0"
65+
/>
66+
<span className="text-gray5 dark:text-gray3 group-hover:text-black dark:group-hover:text-white text-sm">
67+
{item}
68+
</span>
69+
<button
70+
onClick={(e) => {
71+
e.stopPropagation();
72+
onRemoveAction?.(item);
73+
}}
74+
className={clsx(
75+
'ml-1',
76+
'text-gray4 dark:text-gray3 hover:text-red1',
77+
'transition-colors duration-150',
78+
'flex-shrink-0',
79+
)}
80+
aria-label="Remove from history"
81+
>
82+
<XIcon size={14} />
83+
</button>
84+
</div>
85+
))}
86+
</div>
87+
</div>
88+
</div>
89+
);
90+
}

app/[lang]/(home)/Hero/index.tsx

Lines changed: 87 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client';
22

33
import type {ReactElement} from 'react';
4-
import {useState} from 'react';
4+
import {useState, useRef} from 'react';
55
import {useForm} from 'react-hook-form';
66
import {track} from '@amplitude/analytics-browser';
77
import {MarkGithubIcon, SearchIcon} from '@primer/octicons-react';
@@ -17,8 +17,10 @@ import TextInput from '../../(common)/TextInput';
1717

1818
import StatsSymbols from './StatsSymbol';
1919
import StatsUrlCard from './StatsUrlCards';
20+
import SearchHistoryDropdown from './SearchHistoryDropdown';
2021
import {useMediaQuery} from 'usehooks-ts';
2122
import GreatFrontEnd from '../../(common)/GreatFrontEnd';
23+
import {useSearchHistory} from '../../../../src/hooks/useSearchHistory';
2224

2325
const rootUrl = `${process.env.NEXT_PUBLIC_ROOT_URL}/api`;
2426
const baseUrl = process.env.NEXT_PUBLIC_ROOT_URL;
@@ -72,13 +74,20 @@ function Hero({t, statsInfo}: Props): ReactElement {
7274
const [svgStatsURLDisplay, setSvgStatsURLDisplay] = useState<string>('');
7375
const [svgStatsURLCopy, setSvgStatsURLCopy] = useState<string>('');
7476
const [isBasic, setIsBasic] = useState<boolean | undefined>(undefined);
77+
const [showHistory, setShowHistory] = useState(false);
7578
const {register, formState, handleSubmit} = useForm();
79+
const {history, addToHistory, removeFromHistory} = useSearchHistory();
80+
const searchContainerRef = useRef<HTMLDivElement>(null);
7681

7782
const searchUser = async (): Promise<void> => {
7883
if (selectedPluginType.domain === 'github.com') {
7984
try {
8085
const svgStats = await fetchGithubStats(login);
8186

87+
// Add to search history
88+
addToHistory(login);
89+
setShowHistory(false);
90+
8291
track('Search User', {login});
8392

8493
// NOTE: Should use `unescape` to translate chinese letters. The new api `decodeURIComponent` won't work.
@@ -100,6 +109,18 @@ function Hero({t, statsInfo}: Props): ReactElement {
100109
}
101110
};
102111

112+
const handleHistorySelect = (item: string) => {
113+
setLogin(item);
114+
setShowHistory(false);
115+
// Trigger search
116+
setTimeout(() => {
117+
const form = searchContainerRef.current?.querySelector('form');
118+
if (form) {
119+
form.requestSubmit();
120+
}
121+
}, 100);
122+
};
123+
103124
return (
104125
<div
105126
className={clsx(
@@ -129,67 +150,81 @@ function Hero({t, statsInfo}: Props): ReactElement {
129150
/>
130151
{/* End: GreatFrontEnd Banner */}
131152
{/* Begin: Search Form */}
132-
<form
133-
onSubmit={handleSubmit(searchUser)}
134-
className="self-stretch"
135-
autoComplete="off"
136-
>
137-
<div
138-
className={clsx(
139-
'rounded-[16px] px-3 h-[64px] relative body2 max-w-[800px]',
140-
'flex flex-row-reverse items-center',
141-
'bg-white/50 dark:bg-black/40',
142-
'backdrop-blur-2xl',
143-
'border border-black/10 dark:border-white/20',
144-
'shadow-[0_20px_60px_-15px_rgba(0,0,0,0.2)]',
145-
'hover:bg-white/60 dark:hover:bg-black/50',
146-
'transition-all duration-300',
147-
'max-[425px]:p-3 max-[425px]:self-stretch max-[425px]:h-auto max-[425px]:flex-wrap',
148-
'max-[320px]:py-3 max-[320px]:items-start',
149-
)}
153+
<div ref={searchContainerRef} className="relative self-stretch max-w-[800px] w-full">
154+
<form
155+
onSubmit={handleSubmit(searchUser)}
156+
className="self-stretch w-full"
157+
autoComplete="off"
150158
>
151-
<Button
152-
loading={formState.isSubmitting}
153-
type="submit"
154-
className={clsx(
155-
'bg-transparent border-0 text-center max-w-[100px] p-2',
156-
'absolute',
157-
)}
158-
text={<SearchIcon size={22} className="text-gray8 dark:text-white" />}
159-
/>
160159
<div
161160
className={clsx(
162-
'flex-1',
163-
'flex flex-row items-center overflow-x-clip',
161+
'rounded-[16px] px-3 h-[64px] relative body2 w-full',
162+
'flex flex-row-reverse items-center',
163+
'bg-white/50 dark:bg-black/40',
164+
'backdrop-blur-2xl',
165+
'border border-black/10 dark:border-white/20',
166+
'shadow-[0_20px_60px_-15px_rgba(0,0,0,0.2)]',
167+
'hover:bg-white/60 dark:hover:bg-black/50',
168+
'transition-all duration-300',
169+
'max-[425px]:p-3 max-[425px]:self-stretch max-[425px]:h-auto max-[425px]:flex-wrap',
170+
'max-[320px]:py-3 max-[320px]:items-start',
164171
)}
165172
>
166-
<Dropdown
167-
data={statTypes}
168-
selected={selectedPluginType}
169-
setSelected={(el) => {
170-
setSelectedPluginType(el);
171-
}}
173+
<Button
174+
loading={formState.isSubmitting}
175+
type="submit"
176+
className={clsx(
177+
'bg-transparent border-0 text-center max-w-[100px] p-2',
178+
'absolute',
179+
)}
180+
text={<SearchIcon size={22} className="text-gray8 dark:text-white" />}
172181
/>
173-
<span
182+
<div
174183
className={clsx(
175-
'text-gray5 dark:text-gray3',
176-
'mx-3 body3 text-[22px]',
177-
'max-[425px]:invisible max-[425px]:hidden',
184+
'flex-1',
185+
'flex flex-row items-center overflow-x-clip',
178186
)}
179187
>
180-
/
181-
</span>
182-
<TextInput
183-
className="text-gray7 dark:text-white placeholder:text-gray5 dark:placeholder:text-gray4"
184-
{...register('githubID')}
185-
placeholder={t.githubUsername}
186-
onChange={(e) => {
187-
setLogin(e.target.value.trim());
188-
}}
189-
/>
188+
<Dropdown
189+
data={statTypes}
190+
selected={selectedPluginType}
191+
setSelected={(el) => {
192+
setSelectedPluginType(el);
193+
}}
194+
/>
195+
<span
196+
className={clsx(
197+
'text-gray5 dark:text-gray3',
198+
'mx-3 body3 text-[22px]',
199+
'max-[425px]:invisible max-[425px]:hidden',
200+
)}
201+
>
202+
/
203+
</span>
204+
<TextInput
205+
className="text-gray7 dark:text-white placeholder:text-gray5 dark:placeholder:text-gray4"
206+
placeholder={t.githubUsername}
207+
value={login}
208+
onChange={(e) => {
209+
setLogin(e.target.value.trim());
210+
}}
211+
onFocus={() => setShowHistory(true)}
212+
onBlur={() => {
213+
// Delay to allow click on history items
214+
setTimeout(() => setShowHistory(false), 200);
215+
}}
216+
/>
217+
</div>
190218
</div>
191-
</div>
192-
</form>
219+
</form>
220+
<SearchHistoryDropdown
221+
history={history}
222+
query={login}
223+
onSelectAction={handleHistorySelect}
224+
onRemoveAction={removeFromHistory}
225+
show={showHistory}
226+
/>
227+
</div>
193228
{/* End: Search Form */}
194229
{/* Begin: Stats */}
195230
{githubSVG ? (

app/[lang]/stats/[login]/SearchTextInput.tsx

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
'use client';
22

33
import type {ReactElement} from 'react';
4-
import {useState} from 'react';
4+
import {useState, useRef} from 'react';
55
import {useForm} from 'react-hook-form';
66
import {SearchIcon} from '@primer/octicons-react';
77
import clsx from 'clsx';
88

99
import type {Translates} from '../../../../src/localization';
1010
import Button from '../../(common)/Button';
1111
import TextInput from '../../(common)/TextInput';
12+
import SearchHistoryDropdown from '../../(home)/Hero/SearchHistoryDropdown';
13+
import {useSearchHistory} from '../../../../src/hooks/useSearchHistory';
1214

1315
export default function SearchTextInput({
1416
t,
@@ -20,15 +22,37 @@ export default function SearchTextInput({
2022
initialValue: string;
2123
}): ReactElement {
2224
const [login, setLogin] = useState(initialValue);
25+
const [showHistory, setShowHistory] = useState(false);
2326
const {formState} = useForm();
27+
const {history, addToHistory, removeFromHistory} = useSearchHistory();
28+
const searchContainerRef = useRef<HTMLDivElement>(null);
29+
30+
const handleHistorySelect = (item: string) => {
31+
setLogin(item);
32+
setShowHistory(false);
33+
addToHistory(item);
34+
// Trigger navigation
35+
setTimeout(() => {
36+
window.location.href = `/stats/${item}`;
37+
}, 100);
38+
};
39+
40+
const handleSubmit = (e: React.FormEvent) => {
41+
e.preventDefault();
42+
if (login) {
43+
addToHistory(login);
44+
setShowHistory(false);
45+
window.location.href = `/stats/${login}`;
46+
}
47+
};
2448

2549
return (
26-
<form
27-
action={`/stats/${login}`}
28-
method="get"
29-
className={clsx('', className)}
30-
autoComplete="off"
31-
>
50+
<div ref={searchContainerRef} className={clsx('relative', className)}>
51+
<form
52+
onSubmit={handleSubmit}
53+
className="w-full"
54+
autoComplete="off"
55+
>
3256
<div
3357
className={clsx(
3458
'rounded-[16px] body4 px-4 h-[56px]',
@@ -46,6 +70,11 @@ export default function SearchTextInput({
4670
onChange={(e) => {
4771
setLogin(e.target.value.trim());
4872
}}
73+
onFocus={() => setShowHistory(true)}
74+
onBlur={() => {
75+
// Delay to allow click on history items
76+
setTimeout(() => setShowHistory(false), 200);
77+
}}
4978
/>
5079
<Button
5180
loading={formState.isSubmitting}
@@ -57,6 +86,14 @@ export default function SearchTextInput({
5786
text={<SearchIcon size={14} className="text-basic" />}
5887
/>
5988
</div>
60-
</form>
89+
</form>
90+
<SearchHistoryDropdown
91+
history={history}
92+
query={login}
93+
onSelectAction={handleHistorySelect}
94+
onRemoveAction={removeFromHistory}
95+
show={showHistory}
96+
/>
97+
</div>
6198
);
6299
}

0 commit comments

Comments
 (0)