mirror of
https://github.com/ryanamay/twinkle.git
synced 2024-09-20 02:20:34 +00:00
feat: implement skeletons, fix css, etc
This commit is contained in:
parent
d099694583
commit
4d8f7f72e5
14 changed files with 341 additions and 69 deletions
4
next copy.config.mjs
Normal file
4
next copy.config.mjs
Normal file
|
@ -0,0 +1,4 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
|
||||
export default nextConfig;
|
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 512 512" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M208 512a24.84 24.84 0 0 1-23.34-16l-39.84-103.6a16.06 16.06 0 0 0-9.19-9.19L32 343.34a25 25 0 0 1 0-46.68l103.6-39.84a16.06 16.06 0 0 0 9.19-9.19L184.66 144a25 25 0 0 1 46.68 0l39.84 103.6a16.06 16.06 0 0 0 9.19 9.19l103 39.63a25.49 25.49 0 0 1 16.63 24.1 24.82 24.82 0 0 1-16 22.82l-103.6 39.84a16.06 16.06 0 0 0-9.19 9.19L231.34 496A24.84 24.84 0 0 1 208 512zm66.85-254.84zM88 176a14.67 14.67 0 0 1-13.69-9.4l-16.86-43.84a7.28 7.28 0 0 0-4.21-4.21L9.4 101.69a14.67 14.67 0 0 1 0-27.38l43.84-16.86a7.31 7.31 0 0 0 4.21-4.21L74.16 9.79A15 15 0 0 1 86.23.11a14.67 14.67 0 0 1 15.46 9.29l16.86 43.84a7.31 7.31 0 0 0 4.21 4.21l43.84 16.86a14.67 14.67 0 0 1 0 27.38l-43.84 16.86a7.28 7.28 0 0 0-4.21 4.21l-16.86 43.84A14.67 14.67 0 0 1 88 176zm312 80a16 16 0 0 1-14.93-10.26l-22.84-59.37a8 8 0 0 0-4.6-4.6l-59.37-22.84a16 16 0 0 1 0-29.86l59.37-22.84a8 8 0 0 0 4.6-4.6l22.67-58.95a16.45 16.45 0 0 1 13.17-10.57 16 16 0 0 1 16.86 10.15l22.84 59.37a8 8 0 0 0 4.6 4.6l59.37 22.84a16 16 0 0 1 0 29.86l-59.37 22.84a8 8 0 0 0-4.6 4.6l-22.84 59.37A16 16 0 0 1 400 256z"></path></svg>
|
After Width: | Height: | Size: 1.2 KiB |
|
@ -45,8 +45,20 @@ const CompanyNewsWidget = ({ symbol }: CompanyNewsWidgetProps) => {
|
|||
}
|
||||
}, [symbol]);
|
||||
|
||||
const SkeletonLoader = () => (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="rounded-lg bg-gray-300 dark:bg-gray-700 p-4 gap-3 animate-pulse">
|
||||
<div className="h-6 bg-gray-400 dark:bg-gray-600 rounded mb-2"></div>
|
||||
<div className="h-4 bg-gray-400 dark:bg-gray-600 rounded mb-2"></div>
|
||||
<div className="h-4 bg-gray-400 dark:bg-gray-600 rounded w-3/4"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <p>Loading...</p>;
|
||||
return <SkeletonLoader />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
|
@ -57,7 +69,7 @@ const CompanyNewsWidget = ({ symbol }: CompanyNewsWidgetProps) => {
|
|||
<div className="p-6 bg-surface0 dark:bg-surface0 rounded-lg shadow-md mt-4">
|
||||
<h2 className="text-xl font-bold text-text dark:text-text mb-4">Company News for {symbol}</h2>
|
||||
<div className="space-y-4">
|
||||
{news.map((article) => (
|
||||
{news.slice(0, 20).map((article) => (
|
||||
<div key={article.id} className="rounded-lg bg-overlay0 dark:bg-mantle p-4">
|
||||
<a href={article.url} target="_blank" rel="noopener noreferrer" className="text-text dark:text-text hover:underline">
|
||||
<h3 className="text-lg font-semibold mb-2">{article.headline}</h3>
|
||||
|
|
|
@ -23,10 +23,12 @@ interface CompanyProfileData {
|
|||
const CompanyProfileCard = ({ ticker }: CompanyProfileProps) => {
|
||||
const [profile, setProfile] = useState<CompanyProfileData | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchCompanyProfile = async (ticker: string) => {
|
||||
setError('');
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch(`/api/profile?symbol=${ticker}`);
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
|
@ -38,6 +40,8 @@ const CompanyProfileCard = ({ ticker }: CompanyProfileProps) => {
|
|||
} catch (err) {
|
||||
setError('Failed to fetch company profile');
|
||||
setProfile(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -47,8 +51,39 @@ const CompanyProfileCard = ({ ticker }: CompanyProfileProps) => {
|
|||
}
|
||||
}, [ticker]);
|
||||
|
||||
if (!profile) {
|
||||
return error ? <p className="text-red-500">{error}</p> : <p>Loading...</p>;
|
||||
const SkeletonLoader = () => (
|
||||
<div className="p-6 bg-surface0 dark:bg-surface0 rounded-lg gap-4 shadow-md animate-pulse">
|
||||
<div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-1/3 mb-4"></div>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-16 h-16 bg-gray-300 dark:bg-gray-700 rounded-lg"></div>
|
||||
<div className="flex-1">
|
||||
<div className="h-5 bg-gray-300 dark:bg-gray-700 rounded mb-2"></div>
|
||||
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
|
||||
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
|
||||
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
|
||||
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
|
||||
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
|
||||
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
|
||||
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <SkeletonLoader />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <p className="text-red-500">{error}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -58,8 +58,19 @@ const FinancialsWidget = ({ symbol }: FinancialsWidgetProps) => {
|
|||
}
|
||||
}, [symbol]);
|
||||
|
||||
const SkeletonLoader = () => (
|
||||
<div className="hidden md:block p-6 bg-surface0 dark:bg-surface0 rounded-lg shadow-md mt-4 animate-pulse">
|
||||
<h2 className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-1/3 mb-4"></h2>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div key={index} className="bg-gray-300 dark:bg-gray-700 p-4 rounded-lg flex-1 min-w-[200px] h-16"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <p>Loading...</p>;
|
||||
return <SkeletonLoader />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
|
@ -71,7 +82,7 @@ const FinancialsWidget = ({ symbol }: FinancialsWidgetProps) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-surface0 dark:bg-surface0 rounded-lg shadow-md mt-4">
|
||||
<div className="hidden md:block p-6 bg-surface0 dark:bg-surface0 rounded-lg shadow-md mt-4">
|
||||
<h2 className="text-xl font-bold text-text dark:text-text mb-4">Basic Financials for {symbol}</h2>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{formattedData.map((data, index) => (
|
||||
|
|
12
src/components/Footer.tsx
Normal file
12
src/components/Footer.tsx
Normal file
|
@ -0,0 +1,12 @@
|
|||
// components/Footer.tsx
|
||||
const Footer = () => {
|
||||
return (
|
||||
<footer className="w-full bg-crust text-text py-4 border-t-2 border-surface0">
|
||||
<div className="container mx-auto max-w-7xl flex flex-col text-center items-center justify-center space-y-2">
|
||||
<p className="text-sm">this project is open-source and is available at github.com/ryanamay or code.lgbt/ryanamay</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
15
src/components/HeadTemplate.tsx
Normal file
15
src/components/HeadTemplate.tsx
Normal file
|
@ -0,0 +1,15 @@
|
|||
// components/HeadTemplate.tsx
|
||||
import Head from 'next/head';
|
||||
|
||||
const HeadTemplate = () => {
|
||||
return (
|
||||
<Head>
|
||||
<title>Twinkle - Track Your Stocks</title>
|
||||
<meta name="description" content="Twinkle helps you track your favorite stocks in a delightful and user-friendly way." />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.svg" />
|
||||
</Head>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeadTemplate;
|
44
src/components/HeroSection.tsx
Normal file
44
src/components/HeroSection.tsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
import React from 'react';
|
||||
import { FaArrowUp } from 'react-icons/fa';
|
||||
|
||||
const HeroSection = () => {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[70vh] mt-8 text-center bg-base text-text px-4">
|
||||
<div className="relative mb-6">
|
||||
<FaArrowUp className="text-4xl text-overlay0 animate-bounce" />
|
||||
<p className="absolute top-[-2rem] left-1/2 transform -translate-x-1/2 text-xs w-[400px] text-text">Search your first stock</p>
|
||||
</div>
|
||||
<h1 className="text-6xl font-bold text-lavender mb-3">twinkle</h1>
|
||||
<p className="text-3xl font-extrabold mb-8 bg-clip-text text-transparent bg-gradient-to-r from-purple-400 via-pink-500 to-red-500 ">
|
||||
your symbol twinkling at the distance
|
||||
</p>
|
||||
<p className="text-lg text-overlay1-dark mb-4 max-w-2xl mx-auto px-4">
|
||||
Welcome to <span className="font-bold text-lavender">twinkle</span>, your charming companion for keeping an eye on your favorite stocks. ✨
|
||||
</p>
|
||||
<p className="text-lg text-overlay1-dark mb-8 max-w-2xl mx-auto px-4">
|
||||
Powered by our magical <span className="font-bold text-lavender">sparkle aggregator API</span>, twinkle makes it a breeze to stay updated with the latest stock prices in a delightful and user-friendly way.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mt-6">
|
||||
<a
|
||||
href="https://code.lgbt/ryanamay/sparkle"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-6 py-3 bg-gradient-to-r from-cyan-500 to-blue-500 hover:from-cyan-600 hover:to-blue-600 text-white font-semibold rounded-lg shadow-md transform transition-all duration-300"
|
||||
>
|
||||
View Sparkle Source Code on Code.lgbt
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://code.lgbt/ryanamay/twinkle"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-6 py-3 bg-gradient-to-r from-teal-400 to-green-500 hover:from-teal-500 hover:to-green-600 text-white font-semibold rounded-lg shadow-md transform transition-all duration-300"
|
||||
>
|
||||
View Twinkle Source Code on Code.lgbt
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroSection;
|
|
@ -1,43 +1,46 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import SearchBar from './SearchBar'
|
||||
import Link from 'next/link'
|
||||
import ThemeSwitcher from './ThemeSwitcher'
|
||||
import { IoSparkles } from 'react-icons/io5'
|
||||
import { FaHamburger } from 'react-icons/fa'
|
||||
import { GiHamburgerMenu } from 'react-icons/gi'
|
||||
import { useState } from 'react';
|
||||
import SearchBar from './SearchBar';
|
||||
import Link from 'next/link';
|
||||
import ThemeSwitcher from './ThemeSwitcher';
|
||||
import { IoSparkles } from 'react-icons/io5';
|
||||
|
||||
interface NavigationBarProps {
|
||||
onSelectSymbol: (symbol: string) => void
|
||||
onSelectSymbol: (symbol: string) => void;
|
||||
}
|
||||
|
||||
const NavigationBar = ({ onSelectSymbol }: NavigationBarProps) => {
|
||||
const [currency, setCurrency] = useState('USD')
|
||||
const [watchlistView, setWatchlistView] = useState('priceChange')
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||
const [currency, setCurrency] = useState('USD');
|
||||
const [watchlistView, setWatchlistView] = useState('priceChange');
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
const handleCurrencyChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setCurrency(event.target.value)
|
||||
}
|
||||
setCurrency(event.target.value);
|
||||
};
|
||||
|
||||
const handleWatchlistViewChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setWatchlistView(event.target.value)
|
||||
}
|
||||
setWatchlistView(event.target.value);
|
||||
};
|
||||
|
||||
const resetSymbol = () => {
|
||||
onSelectSymbol('');
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="w-full g-crust transition-all text-text border-b-2 border-surface0 px-4">
|
||||
<nav className="w-full bg-crust transition-all text-text border-b-2 border-surface0 px-4">
|
||||
<div className="container mx-auto max-w-7xl w-full flex items-center justify-between py-1">
|
||||
<Link className="text-lg font-bold flex flex-row align-middle items-center gap-2" href="/"><IoSparkles /> <span className="md:block hidden">twinkle</span>
|
||||
</Link>
|
||||
<div onClick={resetSymbol} className="cursor-pointer text-lg font-bold flex flex-row align-middle items-center gap-2">
|
||||
<IoSparkles />
|
||||
<span className="md:block hidden">twinkle</span>
|
||||
</div>
|
||||
<div className="flex-grow max-w-md mx-auto lg:max-w-lg w-full">
|
||||
<SearchBar onSelectSymbol={onSelectSymbol} />
|
||||
</div>
|
||||
<div className="relative flex items-center space-x-4">
|
||||
<ThemeSwitcher />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default NavigationBar
|
||||
export default NavigationBar;
|
|
@ -12,6 +12,11 @@ interface NewsArticle {
|
|||
url: string;
|
||||
}
|
||||
|
||||
// Skeleton components for loading state
|
||||
const Skeleton = ({ className }: { className: string }) => (
|
||||
<div className={`animate-pulse bg-overlay1 ${className}`}></div>
|
||||
);
|
||||
|
||||
const NewsColumn = () => {
|
||||
const [news, setNews] = useState<NewsArticle[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
@ -22,7 +27,9 @@ const NewsColumn = () => {
|
|||
try {
|
||||
const res = await fetch('/api/news');
|
||||
const data = await res.json();
|
||||
const filteredNews = data.result.filter((article: any) => article.source !== 'MarketWatch');
|
||||
const filteredNews = data.result.filter(
|
||||
(article: any) => article.source !== 'MarketWatch'
|
||||
);
|
||||
setNews(filteredNews);
|
||||
} catch (err) {
|
||||
setError('Failed to fetch news');
|
||||
|
@ -35,7 +42,54 @@ const NewsColumn = () => {
|
|||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <p>Loading...</p>;
|
||||
return (
|
||||
<div className="lg:flex lg:space-x-4 w-full">
|
||||
<div className="lg:flex-1 lg:space-y-4">
|
||||
{/* Main featured article skeleton */}
|
||||
<div className="mb-4 lg:mb-0">
|
||||
<div className="block overflow-hidden rounded-lg shadow-lg">
|
||||
<Skeleton className="w-full h-64" />
|
||||
<div className="p-4 bg-surface0 dark:bg-surface0">
|
||||
<Skeleton className="h-6 mb-2" />
|
||||
<Skeleton className="h-4 mb-1" />
|
||||
<Skeleton className="h-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Other articles skeleton */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="block overflow-hidden rounded-lg shadow-lg"
|
||||
>
|
||||
<Skeleton className="w-full h-32 rounded-t-lg" />
|
||||
<div className="p-2 bg-surface0 dark:bg-surface0 h-full">
|
||||
<Skeleton className="h-4 mb-1" />
|
||||
<Skeleton className="h-4" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Latest news sidebar skeleton */}
|
||||
<div className="mt-4 lg:mt-0 lg:w-1/4 lg:order-last border-text dark:border-crust border p-4 rounded-lg bg-surface0 dark:bg-surface0 lg:overflow-y-auto lg:h-screen">
|
||||
<h2 className="text-xl font-bold text-text dark:text-text mb-4">
|
||||
Latest
|
||||
</h2>
|
||||
<ul>
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<li key={index} className="mb-4">
|
||||
<Skeleton className="h-4 mb-1" />
|
||||
<Skeleton className="h-4" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
|
@ -64,11 +118,16 @@ const NewsColumn = () => {
|
|||
className="w-full h-64 object-cover"
|
||||
/>
|
||||
<div className="p-4 bg-surface0 dark:bg-surface0">
|
||||
<h3 className="text-xl font-bold text-text dark:text-text">{featuredArticle.headline}</h3>
|
||||
<h3 className="text-xl font-bold text-text dark:text-text">
|
||||
{featuredArticle.headline}
|
||||
</h3>
|
||||
<p className="text-sm text-subtext1 dark:text-subtext1">
|
||||
{new Date(featuredArticle.datetime * 1000).toLocaleString()} | {featuredArticle.source}
|
||||
{new Date(featuredArticle.datetime * 1000).toLocaleString()} |{' '}
|
||||
{featuredArticle.source}
|
||||
</p>
|
||||
<p className="text-sm mt-2 text-text dark:text-subtext1">
|
||||
{featuredArticle.summary}
|
||||
</p>
|
||||
<p className="text-sm mt-2 text-text dark:text-subtext1">{featuredArticle.summary}</p>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
|
@ -76,7 +135,7 @@ const NewsColumn = () => {
|
|||
|
||||
{/* Other articles */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{otherArticles.map(article => (
|
||||
{otherArticles.map((article) => (
|
||||
<a
|
||||
key={article.id}
|
||||
href={article.url}
|
||||
|
@ -90,9 +149,12 @@ const NewsColumn = () => {
|
|||
className="w-full h-32 object-cover rounded-t-lg"
|
||||
/>
|
||||
<div className="p-2 bg-surface0 dark:bg-surface0 h-full">
|
||||
<h3 className="text-sm font-semibold text-text dark:text-text">{article.headline}</h3>
|
||||
<h3 className="text-sm font-semibold text-text dark:text-text">
|
||||
{article.headline}
|
||||
</h3>
|
||||
<p className="text-xs text-subtext1 dark:text-subtext1">
|
||||
{new Date(article.datetime * 1000).toLocaleString()} | {article.source}
|
||||
{new Date(article.datetime * 1000).toLocaleString()} |{' '}
|
||||
{article.source}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
@ -101,10 +163,12 @@ const NewsColumn = () => {
|
|||
</div>
|
||||
|
||||
{/* Latest news sidebar */}
|
||||
<div className="mt-4 lg:mt-0 lg:w-1/4 lg:order-last border-text dark:border-crust border p-4 rounded-lg bg-surface0 dark:bg-surface0 lg:overflow-y-auto lg:h-screen">
|
||||
<h2 className="text-xl font-bold text-text dark:text-text mb-4">Latest</h2>
|
||||
<div className="mt-4 lg:mt-0 lg:w-1/4 lg:order-last border- dark:border-crust border p-4 lg:overflow-y-auto lg:h-screen bg-surface0 dark:bg-surface0 rounded-lg shadow-md">
|
||||
<h2 className="text-xl font-bold text-text dark:text-text mb-4">
|
||||
Latest
|
||||
</h2>
|
||||
<ul>
|
||||
{latestArticles.map(article => (
|
||||
{latestArticles.map((article) => (
|
||||
<li key={article.id} className="mb-4">
|
||||
<a
|
||||
href={article.url}
|
||||
|
@ -112,7 +176,9 @@ const NewsColumn = () => {
|
|||
rel="noopener noreferrer"
|
||||
className="hover:underline block"
|
||||
>
|
||||
<p className="text-sm font-semibold text-text dark:text-text">{article.headline}</p>
|
||||
<p className="text-sm font-semibold text-text dark:text-text">
|
||||
{article.headline}
|
||||
</p>
|
||||
<p className="text-xs text-subtext1 dark:text-subtext1">
|
||||
{new Date(article.datetime * 1000).toLocaleString()}
|
||||
</p>
|
||||
|
|
|
@ -52,8 +52,15 @@ const RecommendationTrendsWidget = ({ symbol }: RecommendationTrendsWidgetProps)
|
|||
}
|
||||
}, [symbol]);
|
||||
|
||||
const SkeletonLoader = () => (
|
||||
<div className="p-6 bg-surface0 dark:bg-surface0 rounded-lg shadow-md mt-4 animate-pulse">
|
||||
<div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-1/3 mb-4"></div>
|
||||
<div className="h-80 bg-gray-300 dark:bg-gray-700 rounded"></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <p>Loading...</p>;
|
||||
return <SkeletonLoader />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PriceGraph from './PriceGraph';
|
||||
import StockPriceGraph from './StockPriceGraph';
|
||||
|
||||
|
@ -20,9 +20,11 @@ const StockPrice = ({ symbol }: StockPriceProps) => {
|
|||
const [stockData, setStockData] = useState<StockData | null>(null);
|
||||
const [stockDescription, setStockDescription] = useState<string>('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchStockPrice = async (selectedSymbol: string) => {
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/quote?symbol=${selectedSymbol}`);
|
||||
const data = await res.json();
|
||||
|
@ -35,6 +37,8 @@ const StockPrice = ({ symbol }: StockPriceProps) => {
|
|||
} catch (err) {
|
||||
setError('Failed to fetch stock price');
|
||||
setStockData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -71,6 +75,10 @@ const StockPrice = ({ symbol }: StockPriceProps) => {
|
|||
</span>
|
||||
);
|
||||
|
||||
const Skeleton = ({ width, height }: { width: string, height: string }) => (
|
||||
<div className={`bg-gray-300 dark:bg-gray-700 rounded-md`} style={{ width, height }}></div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative bg-surface0 dark:bg-surface0 p-6 rounded-t-lg shadow-md">
|
||||
<div className="absolute inset-0 opacity-20">
|
||||
|
@ -79,16 +87,36 @@ const StockPrice = ({ symbol }: StockPriceProps) => {
|
|||
{symbol && (
|
||||
<div className="relative z-10">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-3xl font-bold text-text dark:text-text">{symbol}</h1>
|
||||
{stockData && (
|
||||
{loading ? (
|
||||
<Skeleton width="150px" height="2rem" />
|
||||
) : (
|
||||
<h1 className="text-3xl font-bold text-text dark:text-text">{symbol}</h1>
|
||||
)}
|
||||
{loading ? (
|
||||
<div className="flex space-x-2 my-4">
|
||||
<Skeleton width="100px" height="2rem" />
|
||||
<Skeleton width="100px" height="2rem" />
|
||||
</div>
|
||||
) : stockData ? (
|
||||
<div className="flex space-x-2">
|
||||
<PriceBadge label="Change:" value={stockData.d} isPositive={stockData.d >= 0} />
|
||||
<PriceBadge label="Percent Change:" value={`${stockData.dp}%`} isPositive={stockData.dp >= 0} />
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
{stockDescription && <p className="text-lg mb-4 text-text dark:text-subtext1">{stockDescription}</p>}
|
||||
{stockData && (
|
||||
{loading ? (
|
||||
<Skeleton width="100%" height="1.5rem" className="my-4" />
|
||||
) : stockDescription && (
|
||||
<p className="text-lg mb-4 text-text dark:text-subtext1">{stockDescription}</p>
|
||||
)}
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-4 gap-4 mt-4">
|
||||
<Skeleton width="100%" height="100px" />
|
||||
<Skeleton width="100%" height="100px" />
|
||||
<Skeleton width="100%" height="100px" />
|
||||
<Skeleton width="100%" height="100px" />
|
||||
</div>
|
||||
) : stockData && (
|
||||
<div className="mt-4">
|
||||
<div className="text-5xl font-bold mb-2 text-subtext0 dark:text-subtext0">${stockData.c}</div>
|
||||
<div className="grid grid-cols-4 gap-4 mt-4">
|
||||
|
|
|
@ -15,6 +15,7 @@ const Ticker = ({ symbol }: { symbol: string }) => {
|
|||
const [bid, setBid] = useState<number | null>(null);
|
||||
const [ask, setAsk] = useState<number | null>(null);
|
||||
const [webSocketInitialized, setWebSocketInitialized] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const initializePrices = async () => {
|
||||
|
@ -47,27 +48,38 @@ const Ticker = ({ symbol }: { symbol: string }) => {
|
|||
useEffect(() => {
|
||||
if (!webSocketInitialized) return;
|
||||
|
||||
setLoading(true); // Set loading to true when the symbol changes
|
||||
|
||||
const socket = new WebSocket(`${process.env.NEXT_PUBLIC_SPARKLE_BASE_URL!.replace('http', 'ws')}/ws/trades`);
|
||||
|
||||
// Define a function to handle incoming WebSocket messages
|
||||
const handleWebSocketMessage = (event: MessageEvent) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'trade' && data.data.some((trade: Trade) => trade.s === symbol)) {
|
||||
const tradeForSymbol = data.data.find((trade: Trade) => trade.s === symbol);
|
||||
setLatestTrade(tradeForSymbol);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Subscribe to the symbol when the socket opens
|
||||
socket.onopen = () => {
|
||||
console.log('WebSocket connection established');
|
||||
socket.send(JSON.stringify({ type: 'subscribe', symbol }));
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'trade' && data.data.some((trade: Trade) => trade.s === symbol)) {
|
||||
const tradeForSymbol = data.data.find((trade: Trade) => trade.s === symbol);
|
||||
setLatestTrade(tradeForSymbol);
|
||||
}
|
||||
};
|
||||
// Handle incoming messages
|
||||
socket.onmessage = handleWebSocketMessage;
|
||||
|
||||
socket.onclose = () => {
|
||||
console.log('WebSocket connection closed');
|
||||
};
|
||||
|
||||
// Clean up by unsubscribing from the symbol and closing the socket on component unmount or symbol change
|
||||
return () => {
|
||||
socket.send(JSON.stringify({ type: 'unsubscribe', symbol }));
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'unsubscribe', symbol }));
|
||||
}
|
||||
socket.close();
|
||||
};
|
||||
}, [symbol, webSocketInitialized]);
|
||||
|
@ -90,15 +102,28 @@ const Ticker = ({ symbol }: { symbol: string }) => {
|
|||
return 'No conditions';
|
||||
};
|
||||
|
||||
const SkeletonPlaceholder = () => (
|
||||
<div className="flex flex-row space-x-4 ed-lg text-xs">
|
||||
<strong>LIVE: </strong>
|
||||
<div>Waiting for a trade to happen...</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
{symbol && latestTrade && (
|
||||
{symbol && (
|
||||
<div className="border-b dark:bg-overlay0 bg-overlay0 p-2 px-4 rounded-b-lg">
|
||||
<div className="flex flex-row space-x-4 ed-lg text-xs">
|
||||
<strong>LIVE ${latestTrade.s}</strong>
|
||||
<div>Traded {latestTrade.v} @ ${latestTrade.p} on {new Date(latestTrade.t).toLocaleTimeString()}</div>
|
||||
<div>{getTradeConditions(latestTrade)}</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<SkeletonPlaceholder />
|
||||
) : (
|
||||
latestTrade && (
|
||||
<div className="flex flex-row space-x-4 ed-lg text-xs">
|
||||
<strong>LIVE ${latestTrade.s}</strong>
|
||||
<div>Traded {latestTrade.v} @ ${latestTrade.p} on {new Date(latestTrade.t).toLocaleTimeString()}</div>
|
||||
<div>{getTradeConditions(latestTrade)}</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { useState } from 'react';
|
||||
import HeadTemplate from '@/components/HeadTemplate';
|
||||
import NavigationBar from '../components/NavigationBar';
|
||||
import StockPrice from '../components/StockPrice';
|
||||
import Ticker from '../components/Ticker';
|
||||
|
@ -9,6 +10,8 @@ import RecommendationTrendsWidget from '@/components/RecommendationTrends';
|
|||
import FinancialsWidget from '@/components/FinancialsWidget';
|
||||
import CompanyNews from './api/company-news';
|
||||
import CompanyNewsWidget from '@/components/CompanyNewsWidget';
|
||||
import HeroSection from '@/components/HeroSection';
|
||||
import Footer from '@/components/Footer';
|
||||
|
||||
export default function Home() {
|
||||
const [symbol, setSymbol] = useState('');
|
||||
|
@ -18,25 +21,31 @@ export default function Home() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center w-full">
|
||||
<div className="flex flex-col min-h-screen items-center w-full">
|
||||
<HeadTemplate />
|
||||
<NavigationBar onSelectSymbol={handleSelectSymbol} />
|
||||
<div className="gap-8 flex-wrap lg:flex-nowrap flex-row flex max-w-7xl w-full p-4">
|
||||
<main className="gap-8 flex-wrap lg:flex-nowrap flex-row flex max-w-7xl w-full p-4 flex-1">
|
||||
{symbol ? (
|
||||
<>
|
||||
<div className="flex flex-col w-full">
|
||||
<StockPrice symbol={symbol} />
|
||||
<Ticker symbol={symbol} />
|
||||
<RecommendationTrendsWidget symbol={symbol} />
|
||||
<FinancialsWidget symbol={symbol} />
|
||||
<RecommendationTrendsWidget symbol={symbol} />
|
||||
<FinancialsWidget symbol={symbol} />
|
||||
</div>
|
||||
<div className="flex flex-col max-w-md">
|
||||
<div className="flex flex-col w-full md:max-w-md gap-2">
|
||||
<CompanyProfileCard ticker={symbol} />
|
||||
<CompanyNewsWidget symbol={symbol} />
|
||||
</div>
|
||||
</>
|
||||
) : (<NewsColumn />)}
|
||||
{/* */}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full flex flex-col items-center">
|
||||
<HeroSection />
|
||||
<NewsColumn />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
Loading…
Reference in a new issue