improve: nav ui

This commit is contained in:
ryana mittens 2024-09-06 16:37:27 +08:00
parent e0b5c6f1d5
commit 2bf8d16250
6 changed files with 252 additions and 144 deletions

11
package-lock.json generated
View file

@ -13,6 +13,7 @@
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-icons": "^5.3.0", "react-icons": "^5.3.0",
"react-spinners": "^0.14.1",
"recharts": "^2.12.7" "recharts": "^2.12.7"
}, },
"devDependencies": { "devDependencies": {
@ -4483,6 +4484,16 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
} }
}, },
"node_modules/react-spinners": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/react-spinners/-/react-spinners-0.14.1.tgz",
"integrity": "sha512-2Izq+qgQ08HTofCVEdcAQCXFEYfqTDdfeDQJeo/HHQiQJD4imOicNLhkfN2eh1NYEWVOX4D9ok2lhuDB0z3Aag==",
"license": "MIT",
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-transition-group": { "node_modules/react-transition-group": {
"version": "4.4.5", "version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",

View file

@ -14,6 +14,7 @@
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-icons": "^5.3.0", "react-icons": "^5.3.0",
"react-spinners": "^0.14.1",
"recharts": "^2.12.7" "recharts": "^2.12.7"
}, },
"devDependencies": { "devDependencies": {

View file

@ -0,0 +1,18 @@
import React from 'react';
import { FadeLoader as Spinner } from 'react-spinners';
interface FadeLoaderProps {
loading: boolean;
color?: string;
}
const FadeLoader: React.FC<FadeLoaderProps> = ({ loading, color }) => {
const themeColor = color || getComputedStyle(document.documentElement).getPropertyValue('--color-text').trim();
return (
<div className="flex justify-center items-center translate-x-3 translate-y-3 px-2">
<Spinner color={themeColor} loading={loading} height={6} padding={0} margin={-12} width={2}/>
</div>
);
};
export default FadeLoader;

View file

@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import debounce from "lodash.debounce"; import debounce from "lodash.debounce";
import { FaSearch, FaTimes } from "react-icons/fa"; import { FaSearch, FaTimes } from "react-icons/fa";
import FadeLoader from "./FadeLoader";
interface SearchBarProps { interface SearchBarProps {
onSelectSymbol: (symbol: string) => void; onSelectSymbol: (symbol: string) => void;
@ -9,39 +10,74 @@ interface SearchBarProps {
const SearchBar = ({ onSelectSymbol }: SearchBarProps) => { const SearchBar = ({ onSelectSymbol }: SearchBarProps) => {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [selectedQuery, setSelectedQuery] = useState(""); const [selectedQuery, setSelectedQuery] = useState("");
const [suggestions, setSuggestions] = useState<{ symbol: string; description: string }[]>([]); const [suggestions, setSuggestions] = useState<
{ symbol: string; description: string }[]
>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [notFound, setNotFound] = useState(false); const [notFound, setNotFound] = useState(false);
const [isPickerVisible, setIsPickerVisible] = useState(false); const [isPickerVisible, setIsPickerVisible] = useState(false);
const [queryTotal, setQueryTotal] = useState(0);
const pickerRef = useRef<HTMLDivElement>(null); const pickerRef = useRef<HTMLDivElement>(null);
const fetchSuggestions = async (query: string) => { const fetchSuggestions = (() => {
let currentController: AbortController | null = null;
return async (query: string) => {
if (currentController) {
currentController.abort();
}
currentController = new AbortController();
const { signal } = currentController;
setLoading(true); setLoading(true);
setQueryTotal(queryTotal + 1);
setNotFound(false); setNotFound(false);
try { try {
const res = await fetch(`/api/search?query=${query}`); const res = await fetch(`/api/search?query=${query}`, { signal });
const data = await res.json(); const data = await res.json();
if (data.result && data.result.length > 0) { if (data.result && data.result.length > 0) {
setTotalCount(data.result.length);
setSuggestions(data.result.slice(0, 5)); setSuggestions(data.result.slice(0, 5));
setLoading(false);
} else { } else {
setNotFound(true); setNotFound(true);
setSuggestions([]); setSuggestions([]);
setTotalCount(0);
setLoading(false);
} }
} catch (error) { } catch (error) {
if (error.name === 'AbortError') {
console.log('Fetch aborted');
} else {
console.error("Error fetching suggestions:", error); console.error("Error fetching suggestions:", error);
setNotFound(true); setNotFound(true);
setSuggestions([]); setSuggestions([]);
} finally { setTotalCount(0);
setLoading(false); setLoading(false);
} }
} finally {
setQueryTotal(queryTotal - 1);
if (queryTotal === 0) {
setLoading(false);
}
currentController = null;
}
}; };
})();
const debouncedFetchSuggestions = useCallback(debounce((query: string) => { const debouncedFetchSuggestions = useCallback(
debounce((query: string) => {
fetchSuggestions(query); fetchSuggestions(query);
}, 300), []); }, 5000),
[]
);
useEffect(() => { useEffect(() => {
if (query.length > 1) { if (query.length > 0) {
debouncedFetchSuggestions(query); debouncedFetchSuggestions(query);
setIsPickerVisible(true); setIsPickerVisible(true);
} else { } else {
@ -60,7 +96,10 @@ const SearchBar = ({ onSelectSymbol }: SearchBarProps) => {
}; };
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (pickerRef.current && !pickerRef.current.contains(event.target as Node)) { if (
pickerRef.current &&
!pickerRef.current.contains(event.target as Node)
) {
setIsPickerVisible(false); setIsPickerVisible(false);
} }
}; };
@ -79,7 +118,10 @@ const SearchBar = ({ onSelectSymbol }: SearchBarProps) => {
type="text" type="text"
placeholder={selectedQuery || "Search stocks..."} placeholder={selectedQuery || "Search stocks..."}
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => {
setLoading(true);
setQuery(e.target.value);
}}
className="pl-8 w-full bg-transparent outline-none text-text" className="pl-8 w-full bg-transparent outline-none text-text"
onFocus={() => setIsPickerVisible(true)} onFocus={() => setIsPickerVisible(true)}
/> />
@ -87,36 +129,68 @@ const SearchBar = ({ onSelectSymbol }: SearchBarProps) => {
<FaSearch className="text-text" /> <FaSearch className="text-text" />
</span> </span>
{query && ( {query && (
<span className="absolute right-2 cursor-pointer" onClick={() => setQuery('')}> <span
className="absolute right-2 cursor-pointer"
onClick={() => setQuery("")}
>
<FaTimes className="text-text" /> <FaTimes className="text-text" />
</span> </span>
)} )}
</div> </div>
{isPickerVisible && ( {isPickerVisible && (
<> <>
{loading && ( {
<div className="absolute border bg-mantle p-2 w-full mt-1 text-center"> <ul className="absolute border bg-mantle w-full mt-2 rounded-md border-surface0 overflow-hidden">
Loading...
</div> {suggestions.map((item, index) => (
)}
{!loading && notFound && (
<div className="absolute border bg-mantle p-2 w-full mt-1 text-center text-red-500">
No results found
</div>
)}
{suggestions.length > 0 && (
<ul className="absolute border bg-mantle w-full mt-1">
{suggestions.map((item) => (
<li <li
key={item.symbol} key={item.symbol}
onClick={() => handleSelect(item.symbol)} onClick={() => handleSelect(item.symbol)}
className="p-2 hover:bg-crust cursor-pointer" className={`p-2 hover:bg-crust cursor-pointer transition-all border-b-2 dark:border-base border-overlay0 `}
> >
{item.description} ({item.symbol}) <div className="flex flex-col">
<div className="font-bold">
{item.symbol}{" "}
</div>
<div className="text-xs flex flex-row gap-2">
{index === 0 && (
<span className=" rounded-md text-xs px-1 dark:text-crust bg-text text-overlay0">
Top Result
</span>
)}
{item.description}
</div>
</div>
</li> </li>
))} ))}
</ul> <li
key="count"
className="bottom p-2 text-xs text-right bg-overlay0 dark:bg-crust "
>
<div className="flex flex-row items-center align-middle justify-between">
<span className="p-1 text-left">
{loading
? notFound
? query
? "Searching..."
: "Start searching"
: `Searching...`
: `Showing top hits for '${query}'. (Matched ${totalCount} result${totalCount > 1 ? "s" : ""})`}
</span>
{loading && (
<span
key="loading"
className=" text-xs text-center"
>
<FadeLoader loading={true} />
</span>
)} )}
</div>
</li>
</ul>
}
</> </>
)} )}
</div> </div>

View file

@ -12,9 +12,9 @@ export default async function handler(
} }
try { try {
const quote = await fetchSymbols(query); const result = await fetchSymbols(query);
res.status(200).json(quote); res.status(200).json({ result: result.symbols, totalCount: result.totalCount });
} catch (error) { } catch (error) {
res.status(500).json({ error: "Error fetching quote" }); res.status(500).json({ error: "Error fetching symbols" });
} }
} }

View file

@ -1,28 +1,32 @@
const SPARKLE_BASE_URL = process.env.NEXT_PUBLIC_SPARKLE_BASE_URL; const SPARKLE_BASE_URL = process.env.NEXT_PUBLIC_SPARKLE_BASE_URL;
export const fetchQuote = async (symbol: string) => { export const fetchQuote = async (symbol: string) => {
const url = `${SPARKLE_BASE_URL}/api/v1/quote?symbol=${symbol}` const url = `${SPARKLE_BASE_URL}/api/v1/quote?symbol=${symbol}`;
const res = await fetch(url) const res = await fetch(url);
if (!res.ok) { if (!res.ok) {
throw new Error('Error fetching quote') throw new Error('Error fetching quote');
}
return res.json()
} }
return res.json();
};
export const fetchSymbols = async (symbol: string) => { export const fetchSymbols = async (symbol: string) => {
const url = `${SPARKLE_BASE_URL}/api/v1/search?query=${symbol}` const url = `${SPARKLE_BASE_URL}/api/v1/search?query=${symbol}`;
const res = await fetch(url) const res = await fetch(url);
if (!res.ok) { if (!res.ok) {
throw new Error('Error fetching quote') throw new Error('Error fetching symbols');
}
return res.json()
} }
const data = await res.json();
return {
symbols: data.result,
totalCount: data.totalCount,
};
};
export const startWebSocket = async () => { export const startWebSocket = async () => {
const url = `${SPARKLE_BASE_URL}/ws/start-websocket` const url = `${SPARKLE_BASE_URL}/ws/start-websocket`;
const res = await fetch(url) const res = await fetch(url);
if (!res.ok) { if (!res.ok) {
throw new Error('Error starting WebSocket') throw new Error('Error starting WebSocket');
}
return res.json()
} }
return res.json();
};