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,126 +1,200 @@
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;
} }
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<
const [loading, setLoading] = useState(false); { symbol: string; description: string }[]
const [notFound, setNotFound] = useState(false); >([]);
const [isPickerVisible, setIsPickerVisible] = useState(false); const [totalCount, setTotalCount] = useState(0);
const pickerRef = useRef<HTMLDivElement>(null); 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 = (() => {
setLoading(true); let currentController: AbortController | null = null;
setNotFound(false);
try { return async (query: string) => {
const res = await fetch(`/api/search?query=${query}`); if (currentController) {
const data = await res.json(); currentController.abort();
if (data.result && data.result.length > 0) { }
setSuggestions(data.result.slice(0, 5));
} else { currentController = new AbortController();
setNotFound(true); const { signal } = currentController;
setLoading(true);
setQueryTotal(queryTotal + 1);
setNotFound(false);
try {
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([]);
setTotalCount(0);
setLoading(false);
}
} finally {
setQueryTotal(queryTotal - 1);
if (queryTotal === 0) {
setLoading(false);
}
currentController = null;
}
};
})();
const debouncedFetchSuggestions = useCallback(
debounce((query: string) => {
fetchSuggestions(query);
}, 5000),
[]
);
useEffect(() => {
if (query.length > 0) {
debouncedFetchSuggestions(query);
setIsPickerVisible(true);
} else {
setSuggestions([]);
setNotFound(false);
setIsPickerVisible(false);
}
}, [query, debouncedFetchSuggestions]);
const handleSelect = (symbol: string) => {
setQuery("");
setSelectedQuery(symbol);
setSuggestions([]); setSuggestions([]);
} setIsPickerVisible(false);
} catch (error) { onSelectSymbol(symbol);
console.error("Error fetching suggestions:", error);
setNotFound(true);
setSuggestions([]);
} finally {
setLoading(false);
}
};
const debouncedFetchSuggestions = useCallback(debounce((query: string) => {
fetchSuggestions(query);
}, 300), []);
useEffect(() => {
if (query.length > 1) {
debouncedFetchSuggestions(query);
setIsPickerVisible(true);
} else {
setSuggestions([]);
setNotFound(false);
setIsPickerVisible(false);
}
}, [query, debouncedFetchSuggestions]);
const handleSelect = (symbol: string) => {
setQuery("");
setSelectedQuery(symbol);
setSuggestions([]);
setIsPickerVisible(false);
onSelectSymbol(symbol);
};
const handleClickOutside = (event: MouseEvent) => {
if (pickerRef.current && !pickerRef.current.contains(event.target as Node)) {
setIsPickerVisible(false);
}
};
useEffect(() => {
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
}; };
}, []);
return ( const handleClickOutside = (event: MouseEvent) => {
<div className="relative w-full" ref={pickerRef}> if (
<div className="relative flex items-center border p-1 w-full border-none md:bg-mantle md:border-surface0 md:rounded-md"> pickerRef.current &&
<input !pickerRef.current.contains(event.target as Node)
type="text" ) {
placeholder={selectedQuery || "Search stocks..."} setIsPickerVisible(false);
value={query} }
onChange={(e) => setQuery(e.target.value)} };
className="pl-8 w-full bg-transparent outline-none text-text"
onFocus={() => setIsPickerVisible(true)} useEffect(() => {
/> document.addEventListener("mousedown", handleClickOutside);
<span className="absolute left-2 text-surface0"> return () => {
<FaSearch className="text-text" /> document.removeEventListener("mousedown", handleClickOutside);
</span> };
{query && ( }, []);
<span className="absolute right-2 cursor-pointer" onClick={() => setQuery('')}>
<FaTimes className="text-text" /> return (
</span> <div className="relative w-full" ref={pickerRef}>
)} <div className="relative flex items-center border p-1 w-full border-none md:bg-mantle md:border-surface0 md:rounded-md">
</div> <input
{isPickerVisible && ( type="text"
<> placeholder={selectedQuery || "Search stocks..."}
{loading && ( value={query}
<div className="absolute border bg-mantle p-2 w-full mt-1 text-center"> onChange={(e) => {
Loading... setLoading(true);
setQuery(e.target.value);
}}
className="pl-8 w-full bg-transparent outline-none text-text"
onFocus={() => setIsPickerVisible(true)}
/>
<span className="absolute left-2 text-surface0">
<FaSearch className="text-text" />
</span>
{query && (
<span
className="absolute right-2 cursor-pointer"
onClick={() => setQuery("")}
>
<FaTimes className="text-text" />
</span>
)}
</div> </div>
)} {isPickerVisible && (
{!loading && notFound && ( <>
<div className="absolute border bg-mantle p-2 w-full mt-1 text-center text-red-500"> {
No results found <ul className="absolute border bg-mantle w-full mt-2 rounded-md border-surface0 overflow-hidden">
</div>
)} {suggestions.map((item, index) => (
{suggestions.length > 0 && ( <li
<ul className="absolute border bg-mantle w-full mt-1"> key={item.symbol}
{suggestions.map((item) => ( onClick={() => handleSelect(item.symbol)}
<li className={`p-2 hover:bg-crust cursor-pointer transition-all border-b-2 dark:border-base border-overlay0 `}
key={item.symbol} >
onClick={() => handleSelect(item.symbol)} <div className="flex flex-col">
className="p-2 hover:bg-crust cursor-pointer"
> <div className="font-bold">
{item.description} ({item.symbol}) {item.symbol}{" "}
</li> </div>
))} <div className="text-xs flex flex-row gap-2">
</ul> {index === 0 && (
)} <span className=" rounded-md text-xs px-1 dark:text-crust bg-text text-overlay0">
</> Top Result
)} </span>
</div> )}
); {item.description}
</div>
</div>
</li>
))}
<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>
);
}; };
export default SearchBar; export default SearchBar;

View file

@ -2,19 +2,19 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { fetchSymbols } from "../../utils/sparkle"; import { fetchSymbols } from "../../utils/sparkle";
export default async function handler( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
res: NextApiResponse res: NextApiResponse
) { ) {
const { query } = req.query; const { query } = req.query;
if (!query || typeof query !== "string") { if (!query || typeof query !== "string") {
res.status(400).json({ error: "Invalid symbol" }); res.status(400).json({ error: "Invalid symbol" });
return; return;
} }
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();
};