mirror of
https://github.com/ryanamay/twinkle.git
synced 2024-09-20 05:30: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": "^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",
|
||||||
|
|
|
@ -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": {
|
||||||
|
|
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 { 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>
|
||||||
|
|
|
@ -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" });
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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();
|
||||||
|
};
|
Loading…
Reference in a new issue