mirror of
https://github.com/ryanamay/twinkle.git
synced 2024-09-20 02:40:35 +00:00
improve: nav ui
This commit is contained in:
parent
e0b5c6f1d5
commit
2bf8d16250
6 changed files with 252 additions and 144 deletions
11
package-lock.json
generated
11
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -14,6 +14,7 @@
|
|||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-icons": "^5.3.0",
|
||||
"react-spinners": "^0.14.1",
|
||||
"recharts": "^2.12.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
18
src/components/FadeLoader.tsx
Normal file
18
src/components/FadeLoader.tsx
Normal 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;
|
|
@ -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>
|
||||
|
|
|
@ -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" });
|
||||
}
|
||||
}
|
|
@ -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')
|
||||
}
|
||||
return res.json()
|
||||
throw new Error('Error fetching quote');
|
||||
}
|
||||
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)
|
||||
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();
|
||||
};
|
Loading…
Reference in a new issue