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-dom": "^18",
"react-icons": "^5.3.0",
"react-spinners": "^0.14.1",
"recharts": "^2.12.7"
},
"devDependencies": {
@ -4483,6 +4484,16 @@
"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": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",

View file

@ -14,6 +14,7 @@
"react": "^18",
"react-dom": "^18",
"react-icons": "^5.3.0",
"react-spinners": "^0.14.1",
"recharts": "^2.12.7"
},
"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 debounce from "lodash.debounce";
import { FaSearch, FaTimes } from "react-icons/fa";
import FadeLoader from "./FadeLoader";
interface SearchBarProps {
onSelectSymbol: (symbol: string) => void;
@ -9,39 +10,74 @@ interface SearchBarProps {
const SearchBar = ({ onSelectSymbol }: SearchBarProps) => {
const [query, setQuery] = 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 [notFound, setNotFound] = useState(false);
const [isPickerVisible, setIsPickerVisible] = useState(false);
const [queryTotal, setQueryTotal] = useState(0);
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);
setQueryTotal(queryTotal + 1);
setNotFound(false);
try {
const res = await fetch(`/api/search?query=${query}`);
const res = await fetch(`/api/search?query=${query}`, { signal });
const data = await res.json();
if (data.result && data.result.length > 0) {
setTotalCount(data.result.length);
setSuggestions(data.result.slice(0, 5));
setLoading(false);
} else {
setNotFound(true);
setSuggestions([]);
setTotalCount(0);
setLoading(false);
}
} catch (error) {
if (error.name === 'AbortError') {
console.log('Fetch aborted');
} else {
console.error("Error fetching suggestions:", error);
setNotFound(true);
setSuggestions([]);
} finally {
setTotalCount(0);
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);
}, 300), []);
}, 5000),
[]
);
useEffect(() => {
if (query.length > 1) {
if (query.length > 0) {
debouncedFetchSuggestions(query);
setIsPickerVisible(true);
} else {
@ -60,7 +96,10 @@ const SearchBar = ({ onSelectSymbol }: SearchBarProps) => {
};
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);
}
};
@ -79,7 +118,10 @@ const SearchBar = ({ onSelectSymbol }: SearchBarProps) => {
type="text"
placeholder={selectedQuery || "Search stocks..."}
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"
onFocus={() => setIsPickerVisible(true)}
/>
@ -87,36 +129,68 @@ const SearchBar = ({ onSelectSymbol }: SearchBarProps) => {
<FaSearch className="text-text" />
</span>
{query && (
<span className="absolute right-2 cursor-pointer" onClick={() => setQuery('')}>
<span
className="absolute right-2 cursor-pointer"
onClick={() => setQuery("")}
>
<FaTimes className="text-text" />
</span>
)}
</div>
{isPickerVisible && (
<>
{loading && (
<div className="absolute border bg-mantle p-2 w-full mt-1 text-center">
Loading...
</div>
)}
{!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) => (
{
<ul className="absolute border bg-mantle w-full mt-2 rounded-md border-surface0 overflow-hidden">
{suggestions.map((item, index) => (
<li
key={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>
))}
</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>

View file

@ -12,9 +12,9 @@ export default async function handler(
}
try {
const quote = await fetchSymbols(query);
res.status(200).json(quote);
const result = await fetchSymbols(query);
res.status(200).json({ result: result.symbols, totalCount: result.totalCount });
} 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;
export const fetchQuote = async (symbol: string) => {
const url = `${SPARKLE_BASE_URL}/api/v1/quote?symbol=${symbol}`
const res = await fetch(url)
const url = `${SPARKLE_BASE_URL}/api/v1/quote?symbol=${symbol}`;
const res = await fetch(url);
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) => {
const url = `${SPARKLE_BASE_URL}/api/v1/search?query=${symbol}`
const res = await fetch(url)
const url = `${SPARKLE_BASE_URL}/api/v1/search?query=${symbol}`;
const res = await fetch(url);
if (!res.ok) {
throw new Error('Error fetching quote')
}
return res.json()
throw new Error('Error fetching symbols');
}
const data = await res.json();
return {
symbols: data.result,
totalCount: data.totalCount,
};
};
export const startWebSocket = async () => {
const url = `${SPARKLE_BASE_URL}/ws/start-websocket`
const res = await fetch(url)
export const startWebSocket = async () => {
const url = `${SPARKLE_BASE_URL}/ws/start-websocket`;
const res = await fetch(url);
if (!res.ok) {
throw new Error('Error starting WebSocket')
}
return res.json()
throw new Error('Error starting WebSocket');
}
return res.json();
};