melhorias no design
This commit is contained in:
parent
83ff2131f7
commit
b141218adf
45
src/App.css
45
src/App.css
@ -26,6 +26,51 @@ body, html {
|
|||||||
animation: fadeIn 0.3s ease-in-out forwards;
|
animation: fadeIn 0.3s ease-in-out forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* New animations for DataStatsPage */
|
||||||
|
@keyframes fade-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-up {
|
||||||
|
from { opacity: 0; transform: translateY(20px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in-up {
|
||||||
|
from { opacity: 0; transform: translateY(30px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-in-left {
|
||||||
|
from { opacity: 0; transform: translateX(-20px); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fade-in 0.8s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-up {
|
||||||
|
animation: slide-up 0.6s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in-up {
|
||||||
|
animation: fade-in-up 0.8s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-in-left {
|
||||||
|
animation: slide-in-left 0.6s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delay-100 {
|
||||||
|
animation-delay: 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delay-200 {
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
/* Custom scrollbar for search results */
|
/* Custom scrollbar for search results */
|
||||||
.custom-scrollbar::-webkit-scrollbar {
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
|
@ -6,6 +6,8 @@ import StatisticsSection from './components/StatisticsSection';
|
|||||||
import FeaturesSection from './components/FeaturesSection';
|
import FeaturesSection from './components/FeaturesSection';
|
||||||
import Footer from './components/Footer';
|
import Footer from './components/Footer';
|
||||||
import CandidatePage from './components/CandidatePage/CandidatePage';
|
import CandidatePage from './components/CandidatePage/CandidatePage';
|
||||||
|
import DataStatsPage from './components/DataStatsPage';
|
||||||
|
import NotFound from './components/NotFound';
|
||||||
import MatrixBackground from './components/MatrixBackground';
|
import MatrixBackground from './components/MatrixBackground';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
@ -14,6 +16,7 @@ const HomePage: React.FC = () => (
|
|||||||
<main className="flex-grow">
|
<main className="flex-grow">
|
||||||
<HeroSection />
|
<HeroSection />
|
||||||
<StatisticsSection />
|
<StatisticsSection />
|
||||||
|
<div className="w-32 h-1 bg-gradient-to-r from-indigo-400 to-purple-400 mx-auto mt-6 rounded-full"></div>
|
||||||
<FeaturesSection />
|
<FeaturesSection />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
@ -28,6 +31,8 @@ function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/candidato/:id" element={<CandidatePage />} />
|
<Route path="/candidato/:id" element={<CandidatePage />} />
|
||||||
|
<Route path="/dados-disponiveis" element={<DataStatsPage />} />
|
||||||
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
@ -124,3 +124,11 @@ export interface Income {
|
|||||||
valor: number;
|
valor: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OpenCandDataAvailabilityStats {
|
||||||
|
candidatos: number[];
|
||||||
|
bemCandidatos: number[];
|
||||||
|
despesaCandidatos: number[];
|
||||||
|
receitaCandidatos: number[];
|
||||||
|
redeSocialCandidatos: number[];
|
||||||
|
fotosCandidatos: number[];
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
import { BaseApiClient } from './base';
|
import { BaseApiClient } from './base';
|
||||||
import { API_CONFIG } from '../config/api';
|
import { API_CONFIG } from '../config/api';
|
||||||
import type { CandidateAssets, CandidateDetails, CandidateExpenses, CandidateIncome, CandidateRedesSociais, CandidateSearchResult, CpfRevealResult, PlatformStats } from './apiModels';
|
import type { CandidateAssets, CandidateDetails, CandidateExpenses, CandidateIncome, CandidateRedesSociais, CandidateSearchResult, CpfRevealResult, OpenCandDataAvailabilityStats, PlatformStats } from './apiModels';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OpenCand API client for interacting with the OpenCand platform
|
* OpenCand API client for interacting with the OpenCand platform
|
||||||
@ -18,6 +18,14 @@ export class OpenCandApi extends BaseApiClient {
|
|||||||
return this.get<PlatformStats>('/v1/stats');
|
return this.get<PlatformStats>('/v1/stats');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get platform data stats
|
||||||
|
* GET /v1/stats
|
||||||
|
*/
|
||||||
|
async getDataAvailabilityStats(): Promise<OpenCandDataAvailabilityStats> {
|
||||||
|
return this.get<OpenCandDataAvailabilityStats>('/v1/stats/data-availability');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search for candidates by name or other attributes
|
* Search for candidates by name or other attributes
|
||||||
*/
|
*/
|
||||||
|
66
src/components/Button.tsx
Normal file
66
src/components/Button.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ButtonProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
hasAnimation?: boolean;
|
||||||
|
disableCursor?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
href?: string;
|
||||||
|
type?: 'button' | 'submit' | 'reset';
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button: React.FC<ButtonProps> = ({
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
hasAnimation = true,
|
||||||
|
disableCursor = false,
|
||||||
|
onClick,
|
||||||
|
href,
|
||||||
|
type = 'button'
|
||||||
|
}) => {
|
||||||
|
const animationClasses = hasAnimation ? `hover:shadow-xl
|
||||||
|
transform
|
||||||
|
hover:scale-[1.01]`
|
||||||
|
: "";
|
||||||
|
const cursorClasses = disableCursor ? "hover:cursor-default"
|
||||||
|
: "hover:cursor-pointer";
|
||||||
|
|
||||||
|
const baseClasses = `bg-gray-800/30
|
||||||
|
px-4 py-2
|
||||||
|
rounded-full
|
||||||
|
backdrop-blur-xs
|
||||||
|
shadow-xl
|
||||||
|
ring-1
|
||||||
|
transition-all
|
||||||
|
duration-200
|
||||||
|
hover:ring-indigo-400/20
|
||||||
|
hover:bg-gray-700/40
|
||||||
|
ring-gray-900
|
||||||
|
text-white
|
||||||
|
transition-colors
|
||||||
|
${animationClasses} ${cursorClasses} ${className}`;
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
className={baseClasses}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={type}
|
||||||
|
onClick={onClick}
|
||||||
|
className={baseClasses}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Button;
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
import { ArrowLeftIcon } from '@heroicons/react/24/outline';
|
import { ArrowLeftIcon } from '@heroicons/react/24/outline';
|
||||||
import { openCandApi, type CandidateDetails, type CandidateAssets, type CandidateRedesSociais, type CandidateExpenses, type CandidateIncome, ApiError } from '../../api';
|
import { openCandApi, type CandidateDetails, type CandidateAssets, type CandidateRedesSociais, type CandidateExpenses, type CandidateIncome, ApiError } from '../../api';
|
||||||
import ElectionsComponent from './ElectionsComponent';
|
import ElectionsComponent from './ElectionsComponent';
|
||||||
@ -7,6 +7,7 @@ import AssetsComponent from './AssetsComponent';
|
|||||||
import BasicCandidateInfoComponent from './BasicCandidateInfoComponent';
|
import BasicCandidateInfoComponent from './BasicCandidateInfoComponent';
|
||||||
import SocialMediaComponent from './SocialMediaComponent';
|
import SocialMediaComponent from './SocialMediaComponent';
|
||||||
import IncomeExpenseComponent from './IncomeExpenseComponent';
|
import IncomeExpenseComponent from './IncomeExpenseComponent';
|
||||||
|
import Button from '../../Components/Button';
|
||||||
|
|
||||||
const CandidatePage: React.FC = () => {
|
const CandidatePage: React.FC = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@ -116,12 +117,13 @@ const CandidatePage: React.FC = () => {
|
|||||||
<div className="text-center text-white">
|
<div className="text-center text-white">
|
||||||
<h1 className="text-2xl font-bold mb-4">Erro</h1>
|
<h1 className="text-2xl font-bold mb-4">Erro</h1>
|
||||||
<p className="text-red-400 mb-4">{error}</p>
|
<p className="text-red-400 mb-4">{error}</p>
|
||||||
<button
|
<Link
|
||||||
onClick={() => navigate('/')}
|
to="/"
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition-colors"
|
className="inline-block px-8 py-3 bg-gradient-to-r from-indigo-500 to-purple-600 text-white font-semibold rounded-lg hover:from-indigo-600 hover:to-purple-700 transition-all duration-300 transform shadow-lg hover:shadow-xl"
|
||||||
>
|
>
|
||||||
Voltar ao Início
|
Voltar para a página inicial
|
||||||
</button>
|
</Link>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
@ -131,13 +133,14 @@ const CandidatePage: React.FC = () => {
|
|||||||
<main className="flex-grow p-6 max-w-7xl mx-auto">
|
<main className="flex-grow p-6 max-w-7xl mx-auto">
|
||||||
{/* Header with back button */}
|
{/* Header with back button */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<button
|
<Button
|
||||||
onClick={() => navigate('/')}
|
onClick={() => navigate('/')}
|
||||||
className="flex items-center text-white hover:text-gray-300 transition-colors mb-4"
|
className="flex items-center text-white mb-4"
|
||||||
|
hasAnimation={false}
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon className="h-5 w-5 mr-2" />
|
<ArrowLeftIcon className="h-5 w-5 mr-2" />
|
||||||
Voltar à busca
|
Voltar à busca
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
{candidateDetails && (
|
{candidateDetails && (
|
||||||
<h1 className="text-3xl font-bold text-white">{candidateDetails.nome}</h1>
|
<h1 className="text-3xl font-bold text-white">{candidateDetails.nome}</h1>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { DocumentTextIcon } from '@heroicons/react/24/outline';
|
import { DocumentTextIcon } from '@heroicons/react/24/outline';
|
||||||
import { type Election } from '../../api';
|
import { type Election } from '../../api';
|
||||||
import Tooltip from '../Tooltip';
|
import Tooltip from '../../Components/Tooltip';
|
||||||
|
|
||||||
interface ElectionsComponentProps {
|
interface ElectionsComponentProps {
|
||||||
elections: Election[] | null;
|
elections: Election[] | null;
|
||||||
|
@ -21,35 +21,36 @@ interface SocialMediaComponentProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to get social media icons
|
// Helper function to get social media icons
|
||||||
const getSocialMediaIcon = (rede: string): React.ReactElement => {
|
const getSocialMediaIcon = (rede: string, isRecent: boolean = true): React.ReactElement => {
|
||||||
const iconClass = "h-5 w-5 mr-2";
|
const iconClass = "h-5 w-5 mr-2";
|
||||||
|
const opacityClass = isRecent ? "" : "opacity-50";
|
||||||
|
|
||||||
switch (rede.toLowerCase()) {
|
switch (rede.toLowerCase()) {
|
||||||
case 'facebook':
|
case 'facebook':
|
||||||
return <FaFacebook className={`${iconClass} text-blue-600`} />;
|
return <FaFacebook className={`${iconClass} ${opacityClass} text-blue-600`} />;
|
||||||
case 'instagram':
|
case 'instagram':
|
||||||
return <FaInstagram className={`${iconClass} text-pink-600`} />;
|
return <FaInstagram className={`${iconClass} ${opacityClass} text-pink-600`} />;
|
||||||
case 'x/twitter':
|
case 'x/twitter':
|
||||||
case 'twitter':
|
case 'twitter':
|
||||||
return <FaXTwitter className={`${iconClass} text-black`} />;
|
return <FaXTwitter className={`${iconClass} ${opacityClass} text-black`} />;
|
||||||
case 'tiktok':
|
case 'tiktok':
|
||||||
return <FaTiktok className={`${iconClass} text-black`} />;
|
return <FaTiktok className={`${iconClass} ${opacityClass} text-black`} />;
|
||||||
case 'youtube':
|
case 'youtube':
|
||||||
return <FaYoutube className={`${iconClass} text-red-600`} />;
|
return <FaYoutube className={`${iconClass} ${opacityClass} text-red-600`} />;
|
||||||
case 'linkedin':
|
case 'linkedin':
|
||||||
return <FaLinkedin className={`${iconClass} text-blue-700`} />;
|
return <FaLinkedin className={`${iconClass} ${opacityClass} text-blue-700`} />;
|
||||||
case 'whatsapp':
|
case 'whatsapp':
|
||||||
return <FaWhatsapp className={`${iconClass} text-green-600`} />;
|
return <FaWhatsapp className={`${iconClass} ${opacityClass} text-green-600`} />;
|
||||||
case 'threads':
|
case 'threads':
|
||||||
return <FaThreads className={`${iconClass} text-black`} />;
|
return <FaThreads className={`${iconClass} ${opacityClass} text-black`} />;
|
||||||
case 'telegram':
|
case 'telegram':
|
||||||
return <FaTelegram className={`${iconClass} text-blue-500`} />;
|
return <FaTelegram className={`${iconClass} ${opacityClass} text-blue-500`} />;
|
||||||
case 'spotify':
|
case 'spotify':
|
||||||
return <FaSpotify className={`${iconClass} text-green-500`} />;
|
return <FaSpotify className={`${iconClass} ${opacityClass} text-green-500`} />;
|
||||||
case 'kwai':
|
case 'kwai':
|
||||||
case 'outros':
|
case 'outros':
|
||||||
default:
|
default:
|
||||||
return <FaLink className={`${iconClass} text-gray-600`} />;
|
return <FaLink className={`${iconClass} ${opacityClass} text-gray-600`} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -57,6 +58,11 @@ const SocialMediaComponent: React.FC<SocialMediaComponentProps> = ({
|
|||||||
redesSociais,
|
redesSociais,
|
||||||
isLoading
|
isLoading
|
||||||
}) => {
|
}) => {
|
||||||
|
// Calculate the most recent year from all social media entries
|
||||||
|
const mostRecentYear = redesSociais && redesSociais.length > 0
|
||||||
|
? Math.max(...redesSociais.map(rede => rede.ano))
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white/95 backdrop-blur-sm rounded-xl shadow-lg hover:shadow-xl transform hover:scale-[1.01] transition-all duration-200 p-6">
|
<div className="bg-white/95 backdrop-blur-sm rounded-xl shadow-lg hover:shadow-xl transform hover:scale-[1.01] transition-all duration-200 p-6">
|
||||||
<div className="flex items-center mb-6">
|
<div className="flex items-center mb-6">
|
||||||
@ -70,27 +76,41 @@ const SocialMediaComponent: React.FC<SocialMediaComponentProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : redesSociais && redesSociais.length > 0 ? (
|
) : redesSociais && redesSociais.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{redesSociais.map((redeSocial: RedeSocial, index: number) => (
|
{redesSociais.map((redeSocial: RedeSocial, index: number) => {
|
||||||
<div key={`${redeSocial.idCandidato}-${redeSocial.rede}-${index}`} className="border border-gray-200 rounded-lg p-3 hover:bg-gray-50 transition-colors">
|
const isRecent = redeSocial.ano === mostRecentYear;
|
||||||
<div className="flex items-center justify-between">
|
return (
|
||||||
<div className="flex-1">
|
<div
|
||||||
<div className="flex items-center mb-2">
|
key={`${redeSocial.idCandidato}-${redeSocial.rede}-${index}`}
|
||||||
{getSocialMediaIcon(redeSocial.rede)}
|
className={`border border-gray-200 rounded-lg p-3 hover:bg-gray-50 transition-colors ${
|
||||||
<span className="font-semibold text-gray-900 mr-2">{redeSocial.rede}</span>
|
isRecent ? '' : 'opacity-95 hover:bg-gray-100'
|
||||||
<span className="text-sm text-gray-500">({redeSocial.ano})</span>
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center mb-2">
|
||||||
|
{getSocialMediaIcon(redeSocial.rede, isRecent)}
|
||||||
|
<span className={`font-semibold mr-2 ${isRecent ? 'text-gray-900' : 'text-gray-500'}`}>
|
||||||
|
{redeSocial.rede}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-500">({redeSocial.ano})</span>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={redeSocial.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={`text-sm break-all transition-colors ${
|
||||||
|
isRecent
|
||||||
|
? 'text-blue-600 hover:text-blue-800'
|
||||||
|
: 'text-blue-400 hover:text-blue-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{redeSocial.link}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<a
|
|
||||||
href={redeSocial.link}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 hover:text-blue-800 text-sm break-all transition-colors"
|
|
||||||
>
|
|
||||||
{redeSocial.link}
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center text-gray-500 py-8">
|
<div className="text-center text-gray-500 py-8">
|
||||||
|
31
src/components/Card.tsx
Normal file
31
src/components/Card.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
hasAnimation?: boolean;
|
||||||
|
disableCursor?: boolean;
|
||||||
|
height?: number;
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Card: React.FC<CardProps> = ({ children, className = "", hasAnimation = false, disableCursor = false, height, width }) => {
|
||||||
|
const animationClasses = hasAnimation ? "hover:shadow-xl transform hover:scale-[1.01] transition-all duration-200 hover:ring-indigo-300" : "";
|
||||||
|
const cursorClasses = disableCursor ? "hover:cursor-default" : "";
|
||||||
|
|
||||||
|
const sizeStyles = {
|
||||||
|
...(height && { height: `${height}rem` }),
|
||||||
|
...(width && { width: `${width}rem` })
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`bg-gray-800/30 p-6 rounded-lg backdrop-blur-xs shadow-xl ring-1 ring-gray-700 max-w-md ${animationClasses} ${cursorClasses} ${className}`}
|
||||||
|
style={sizeStyles}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Card;
|
186
src/components/DataStatsPage.tsx
Normal file
186
src/components/DataStatsPage.tsx
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { openCandApi } from '../api';
|
||||||
|
import type { OpenCandDataAvailabilityStats } from '../api/apiModels';
|
||||||
|
import Card from '../Components/Card';
|
||||||
|
|
||||||
|
const DataStatsPage: React.FC = () => {
|
||||||
|
const [stats, setStats] = useState<OpenCandDataAvailabilityStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await openCandApi.getDataAvailabilityStats();
|
||||||
|
setStats(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Erro ao carregar estatísticas de disponibilidade de dados');
|
||||||
|
console.error('Error fetching data availability stats:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-16 w-16 border-4 border-indigo-600 border-t-transparent mx-auto mb-4"></div>
|
||||||
|
<p className="text-lg text-gray-700">Carregando dados...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-red-600 text-6xl mb-4">⚠</div>
|
||||||
|
<p className="text-lg text-gray-700">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stats) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
|
<p className="text-lg text-gray-700">Nenhum dado disponível</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all unique years from all data types
|
||||||
|
const allYears = new Set<number>();
|
||||||
|
Object.values(stats).forEach((yearArray: number[]) => {
|
||||||
|
yearArray.forEach((year: number) => allYears.add(year));
|
||||||
|
});
|
||||||
|
const sortedYears = Array.from(allYears).sort((a, b) => b - a);
|
||||||
|
|
||||||
|
const dataTypes = [
|
||||||
|
{ key: 'candidatos', label: 'Candidatos', icon: '👤' },
|
||||||
|
{ key: 'bemCandidatos', label: 'Bens de Candidatos', icon: '💰' },
|
||||||
|
{ key: 'despesaCandidatos', label: 'Despesas de Candidatos', icon: '💸' },
|
||||||
|
{ key: 'receitaCandidatos', label: 'Receitas de Candidatos', icon: '💵' },
|
||||||
|
{ key: 'redeSocialCandidatos', label: 'Redes Sociais', icon: '📱' },
|
||||||
|
{ key: 'fotosCandidatos', label: 'Fotos de Candidatos', icon: '📸' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen py-20 px-4">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-12 animate-fade-in">
|
||||||
|
<h1 className="text-4xl md:text-6xl font-bold mb-4 text-white">
|
||||||
|
Disponibilidade de Dados
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-gray-400">
|
||||||
|
Visualize a disponibilidade dos dados por ano em nossa base
|
||||||
|
</p>
|
||||||
|
<div className="w-32 h-1 bg-gradient-to-r from-indigo-400 to-purple-400 mx-auto mt-6 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
|
||||||
|
<Card hasAnimation={true} className="bg-gray-800/10 rounded-xl">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-3xl mb-2">📊</div>
|
||||||
|
<div className="text-2xl font-bold text-white">{dataTypes.length}</div>
|
||||||
|
<div className="text-gray-400">Tipos de Dados</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card hasAnimation={true} className="bg-gray-800/10 rounded-xl">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-3xl mb-2">📅</div>
|
||||||
|
<div className="text-2xl font-bold text-white">{sortedYears.length}</div>
|
||||||
|
<div className="text-gray-400">Anos Disponíveis</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card hasAnimation={true} className="bg-gray-800/10 rounded-xl">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-3xl mb-2">🗓️</div>
|
||||||
|
<div className="text-2xl font-bold text-white">
|
||||||
|
{sortedYears.length > 0 ? `${Math.min(...sortedYears)} - ${Math.max(...sortedYears)}` : 'N/A'}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-400">Período</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Data Availability Table */}
|
||||||
|
<div className="bg-gray-800/10 backdrop-blur-xs rounded-xl shadow-lg hover:shadow-xl transform hover:scale-[1.005] transition-all duration-200 overflow-hidden ring-1 ring-gray-700 hover:ring-indigo-300">
|
||||||
|
<div className="p-6 border-b border-gray-700/30 bg-gray-800/10">
|
||||||
|
<h2 className="text-2xl font-bold text-white">
|
||||||
|
Matriz de Disponibilidade
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-400 mt-2">
|
||||||
|
✅ Disponível • ❌ Não Disponível
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-800/10">
|
||||||
|
<th className="text-left p-4 text-white font-semibold border-b border-gray-700/30 sticky left-0 bg-gray-800/10">
|
||||||
|
Tipo de Dado
|
||||||
|
</th>
|
||||||
|
{sortedYears.map((year, index) => (
|
||||||
|
<th
|
||||||
|
key={year}
|
||||||
|
className="text-center p-4 text-white font-semibold border-b border-gray-700/30 animate-fade-in"
|
||||||
|
style={{ animationDelay: `${index * 50}ms` }}
|
||||||
|
>
|
||||||
|
{year}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{dataTypes.map((dataType, rowIndex) => (
|
||||||
|
<tr
|
||||||
|
key={dataType.key}
|
||||||
|
className="hover:bg-gray-800/10 transition-all duration-300 animate-slide-in-left"
|
||||||
|
style={{ animationDelay: `${rowIndex * 100}ms` }}
|
||||||
|
>
|
||||||
|
<td className="p-4 border-b border-gray-700/20 text-white sticky left-0 bg-gray-800/10">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<span className="text-xl">{dataType.icon}</span>
|
||||||
|
<span>{dataType.label}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{sortedYears.map((year, cellIndex) => {
|
||||||
|
const isAvailable = (stats[dataType.key as keyof OpenCandDataAvailabilityStats] as number[]).includes(year);
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={year}
|
||||||
|
className="text-center p-4 border-b border-gray-700/20 animate-fade-in"
|
||||||
|
style={{ animationDelay: `${(rowIndex * sortedYears.length + cellIndex) * 30}ms` }}
|
||||||
|
>
|
||||||
|
<div className={`inline-flex items-center justify-center w-8 h-8 rounded-full transition-all duration-300 ${
|
||||||
|
isAvailable
|
||||||
|
? 'bg-green-500/20 text-green-300 hover:bg-green-500/30 hover:scale-110 hover:cursor-default'
|
||||||
|
: 'bg-red-500/20 text-red-300 hover:bg-red-500/30 hover:cursor-default'
|
||||||
|
}`}>
|
||||||
|
{isAvailable ? '✅' : '❌'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DataStatsPage;
|
@ -1,19 +1,20 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ArrowDownOnSquareStackIcon, BookOpenIcon, ChartBarIcon, DocumentTextIcon, LightBulbIcon } from '@heroicons/react/24/outline';
|
import { ArrowDownOnSquareStackIcon, BookOpenIcon, ChartBarIcon, DocumentTextIcon, LightBulbIcon } from '@heroicons/react/24/outline';
|
||||||
|
import Card from '../Components/Card';
|
||||||
|
|
||||||
const FeatureCard: React.FC<{ icon: React.ElementType, title: string, children: React.ReactNode }> = ({ icon: Icon, title, children }) => {
|
const FeatureCard: React.FC<{ icon: React.ElementType, title: string, children: React.ReactNode }> = ({ icon: Icon, title, children }) => {
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-800/30 p-6 rounded-lg backdrop-blur-xs shadow-xl ring-1 ring-gray-700 max-w-md">
|
<Card>
|
||||||
<Icon className="h-10 w-10 text-indigo-400 mb-4 mx-auto" />
|
<Icon className="h-10 w-10 text-indigo-400 mb-4 mx-auto" />
|
||||||
<h3 className="text-xl font-semibold text-white mb-2">{title}</h3>
|
<h3 className="text-xl font-semibold text-white mb-2">{title}</h3>
|
||||||
<p className="text-gray-400">{children}</p>
|
<p className="text-gray-400">{children}</p>
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const FeaturesSection: React.FC = () => {
|
const FeaturesSection: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<section id="features" className="py-20 bg-gray-800/30">
|
<section id="features" className="py-20">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<h2 className="text-3xl font-bold text-center text-white mb-12">
|
<h2 className="text-3xl font-bold text-center text-white mb-12">
|
||||||
Por que OpenCand?
|
Por que OpenCand?
|
||||||
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
|
|
||||||
const Footer: React.FC = () => {
|
const Footer: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<footer className="bg-gray-800/30 text-gray-400 py-8 text-center">
|
<footer className="bg-gray-800/30 text-gray-400 py-8 text-center backdrop-blur-xs shadow-lg ring-1 ring-gray-800">
|
||||||
<div className="container mx-auto">
|
<div className="container mx-auto">
|
||||||
<p className="mb-2">
|
<p className="mb-2">
|
||||||
© {new Date().getFullYear()} OpenCand. Todos os direitos reservados.
|
© {new Date().getFullYear()} OpenCand. Todos os direitos reservados.
|
||||||
|
@ -1,31 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
interface NavButtonProps {
|
|
||||||
href: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NavButton: React.FC<NavButtonProps> = ({ href, children, className = '' }) => {
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={href}
|
|
||||||
className={`
|
|
||||||
inline-block px-4 py-2
|
|
||||||
rounded-full
|
|
||||||
backdrop-blur-sm
|
|
||||||
bg-gray-800/30
|
|
||||||
text-gray-100
|
|
||||||
hover:bg-gray-700/40
|
|
||||||
hover:text-white
|
|
||||||
transition-all duration-300 ease-in-out
|
|
||||||
cursor-pointer
|
|
||||||
${className}
|
|
||||||
`.trim()}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NavButton;
|
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import NavButton from './NavButton';
|
import Button from '../Components/Button';
|
||||||
import NavbarMatrixBackground from './NavbarMatrixBackground';
|
import NavbarMatrixBackground from './NavbarMatrixBackground';
|
||||||
|
|
||||||
const Navbar: React.FC = () => {
|
const Navbar: React.FC = () => {
|
||||||
@ -14,9 +14,10 @@ const Navbar: React.FC = () => {
|
|||||||
OpenCand
|
OpenCand
|
||||||
</a>
|
</a>
|
||||||
<div className="space-x-4">
|
<div className="space-x-4">
|
||||||
<NavButton href="#stats">Estatíscas</NavButton>
|
<Button href="/dados-disponiveis">Dados Disponíveis</Button>
|
||||||
<NavButton href="#features">Recursos</NavButton>
|
<Button href="/estatisticas">Estatíscas</Button>
|
||||||
<NavButton href="/about">Sobre</NavButton>
|
<Button href="/#features">Recursos</Button>
|
||||||
|
<Button href="/about">Sobre</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
59
src/components/NotFound.tsx
Normal file
59
src/components/NotFound.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
const NotFound: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center px-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-9xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-indigo-400 to-purple-400 mb-4">
|
||||||
|
404
|
||||||
|
</h1>
|
||||||
|
<h2 className="text-3xl font-semibold text-white mb-4">
|
||||||
|
Página não encontrada
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-300 text-lg mb-8 max-w-md mx-auto">
|
||||||
|
A página que você está procurando não existe ou foi movida para outro local.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="inline-block px-8 py-3 bg-gradient-to-r from-indigo-500 to-purple-600 text-white font-semibold rounded-lg hover:from-indigo-600 hover:to-purple-700 transition-all duration-300 transform shadow-lg hover:shadow-xl"
|
||||||
|
>
|
||||||
|
Voltar para a página inicial
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<p className="text-gray-400 text-sm">
|
||||||
|
Ou tente uma dessas páginas:
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-center space-x-4 mt-3">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="text-indigo-400 hover:text-indigo-300 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
Início
|
||||||
|
</Link>
|
||||||
|
<span className="text-gray-500">•</span>
|
||||||
|
<Link
|
||||||
|
to="/dados-disponiveis"
|
||||||
|
className="text-indigo-400 hover:text-indigo-300 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
Dados Disponíveis
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Decorative elements */}
|
||||||
|
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 -z-10">
|
||||||
|
<div className="w-96 h-96 bg-gradient-to-r from-indigo-500/10 to-purple-500/10 rounded-full blur-3xl"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotFound;
|
@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { openCandApi, type PlatformStats, ApiError } from '../api';
|
import { openCandApi, type PlatformStats, ApiError } from '../api';
|
||||||
|
import Card from '../Components/Card';
|
||||||
|
|
||||||
interface StatCardProps {
|
interface StatCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
@ -10,17 +11,17 @@ interface StatCardProps {
|
|||||||
|
|
||||||
const StatCard: React.FC<StatCardProps> = ({ title, value, description, isLoading = false }) => {
|
const StatCard: React.FC<StatCardProps> = ({ title, value, description, isLoading = false }) => {
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-800/10 backdrop-blur-xs p-6 rounded-lg shadow-xl ring-1 ring-gray-700 hover:shadow-indigo-500/30 transform hover:-translate-y-1 hover:ring-1 hover:ring-white/10 hover:scale-[1.01] transition-all duration-300">
|
<Card hasAnimation={true} disableCursor={true} height={11} width={20}>
|
||||||
<h3 className="text-indigo-400 text-xl font-semibold mb-2">{title}</h3>
|
<h3 className="text-indigo-400 text-xl font-semibold mb-2">{title}</h3>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="h-12 flex items-center">
|
<div className="h-12 flex items-center justify-center">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-white border-t-transparent"></div>
|
<div className="animate-spin rounded-full border-4 border-indigo-600 border-t-transparent"></div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-4xl font-bold text-white mb-3">{value}</p>
|
<p className="text-4xl font-bold text-white mb-3">{value}</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-gray-400 text-sm">{description}</p>
|
<p className="text-gray-400 text-sm">{description}</p>
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -105,36 +106,26 @@ const StatisticsSection: React.FC = () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="stats" className="py-20 bg-gray-800/30">
|
<section id="stats" className="py-20">
|
||||||
<div className="container mx-auto px-4">
|
<div className="max-w-7xl mx-auto px-4">
|
||||||
<h2 className="text-3xl font-bold text-center text-white mb-12">
|
<div className="text-center mb-12 animate-fade-in">
|
||||||
Dados em Números
|
<h2 className="text-4xl md:text-5xl font-bold text-white mb-4">
|
||||||
</h2>
|
Dados em Números
|
||||||
<div className="flex flex-wrap justify-center gap-8 mx-auto">
|
</h2>
|
||||||
{statisticsData.slice(0, 3).map((stat, index) => (
|
<p className="text-xl text-gray-400">
|
||||||
<div key={index} className="w-full md:w-80 lg:w-96">
|
Estatísticas da nossa plataforma de dados eleitorais
|
||||||
<StatCard
|
</p>
|
||||||
title={stat.title}
|
</div>
|
||||||
value={stat.value}
|
|
||||||
description={stat.description}
|
<div className="flex flex-wrap justify-center gap-8">
|
||||||
isLoading={isLoading}
|
{statisticsData.map((stat) => (
|
||||||
/>
|
<StatCard
|
||||||
</div>
|
title={stat.title}
|
||||||
|
value={stat.value}
|
||||||
|
description={stat.description}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
{statisticsData.length > 3 && (
|
|
||||||
<div className="w-full flex flex-wrap justify-center gap-8">
|
|
||||||
{statisticsData.slice(3).map((stat, index) => (
|
|
||||||
<div key={index + 3} className="w-full md:w-80 lg:w-96">
|
|
||||||
<StatCard
|
|
||||||
title={stat.title}
|
|
||||||
value={stat.value}
|
|
||||||
description={stat.description}
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
@ -1,180 +0,0 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
|
|
||||||
interface TooltipProps {
|
|
||||||
content: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
position?: 'top' | 'bottom' | 'left' | 'right';
|
|
||||||
delay?: number;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Tooltip: React.FC<TooltipProps> = ({
|
|
||||||
content,
|
|
||||||
children,
|
|
||||||
position = 'top',
|
|
||||||
delay = 300,
|
|
||||||
className = ''
|
|
||||||
}) => {
|
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
|
||||||
const [showTooltip, setShowTooltip] = useState(false);
|
|
||||||
const [tooltipPosition, setTooltipPosition] = useState({ top: 0, left: 0 });
|
|
||||||
const timeoutRef = useRef<number | null>(null);
|
|
||||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
|
||||||
const triggerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const calculateTooltipPosition = () => {
|
|
||||||
if (!triggerRef.current) return { top: 0, left: 0 };
|
|
||||||
|
|
||||||
const triggerRect = triggerRef.current.getBoundingClientRect();
|
|
||||||
|
|
||||||
let top = 0;
|
|
||||||
let left = 0;
|
|
||||||
|
|
||||||
switch (position) {
|
|
||||||
case 'top':
|
|
||||||
top = triggerRect.top - 8; // 8px offset for arrow and spacing
|
|
||||||
left = triggerRect.left + triggerRect.width / 2;
|
|
||||||
break;
|
|
||||||
case 'bottom':
|
|
||||||
top = triggerRect.bottom + 8;
|
|
||||||
left = triggerRect.left + triggerRect.width / 2;
|
|
||||||
break;
|
|
||||||
case 'left':
|
|
||||||
top = triggerRect.top + triggerRect.height / 2;
|
|
||||||
left = triggerRect.left - 8;
|
|
||||||
break;
|
|
||||||
case 'right':
|
|
||||||
top = triggerRect.top + triggerRect.height / 2;
|
|
||||||
left = triggerRect.right + 8;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { top, left };
|
|
||||||
};
|
|
||||||
|
|
||||||
const showTooltipWithDelay = () => {
|
|
||||||
if (timeoutRef.current) {
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
timeoutRef.current = window.setTimeout(() => {
|
|
||||||
const position = calculateTooltipPosition();
|
|
||||||
setTooltipPosition(position);
|
|
||||||
setIsVisible(true);
|
|
||||||
// Small delay to allow for positioning before showing
|
|
||||||
setTimeout(() => setShowTooltip(true), 10);
|
|
||||||
}, delay);
|
|
||||||
};
|
|
||||||
|
|
||||||
const hideTooltip = () => {
|
|
||||||
if (timeoutRef.current) {
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
setShowTooltip(false);
|
|
||||||
setTimeout(() => setIsVisible(false), 150); // Match transition duration
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update tooltip position on scroll or resize
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isVisible) return;
|
|
||||||
|
|
||||||
const updatePosition = () => {
|
|
||||||
const position = calculateTooltipPosition();
|
|
||||||
setTooltipPosition(position);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleScroll = () => updatePosition();
|
|
||||||
const handleResize = () => updatePosition();
|
|
||||||
|
|
||||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
||||||
window.addEventListener('resize', handleResize, { passive: true });
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('scroll', handleScroll);
|
|
||||||
window.removeEventListener('resize', handleResize);
|
|
||||||
};
|
|
||||||
}, [isVisible, position]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (timeoutRef.current) {
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getTooltipClasses = () => {
|
|
||||||
const baseClasses = `
|
|
||||||
fixed z-[9999] px-3 py-2 text-sm font-medium text-white bg-gray-900
|
|
||||||
rounded-lg shadow-xl backdrop-blur-sm border border-gray-700
|
|
||||||
transition-all duration-150 ease-in-out pointer-events-none
|
|
||||||
max-w-xs break-words
|
|
||||||
`;
|
|
||||||
|
|
||||||
const transformClasses = {
|
|
||||||
top: '-translate-x-1/2 -translate-y-full',
|
|
||||||
bottom: '-translate-x-1/2',
|
|
||||||
left: '-translate-x-full -translate-y-1/2',
|
|
||||||
right: '-translate-y-1/2'
|
|
||||||
};
|
|
||||||
|
|
||||||
const visibilityClasses = showTooltip
|
|
||||||
? 'opacity-100 scale-100'
|
|
||||||
: 'opacity-0 scale-95';
|
|
||||||
|
|
||||||
return `${baseClasses} ${transformClasses[position]} ${visibilityClasses}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getArrowClasses = () => {
|
|
||||||
const baseClasses = 'absolute w-2 h-2 bg-gray-900 border border-gray-700 transform rotate-45';
|
|
||||||
|
|
||||||
const arrowPositions = {
|
|
||||||
top: 'bottom-0 left-1/2 transform -translate-x-1/2 translate-y-1/2 border-t-0 border-l-0',
|
|
||||||
bottom: 'top-0 left-1/2 transform -translate-x-1/2 -translate-y-1/2 border-b-0 border-r-0',
|
|
||||||
left: 'right-0 top-1/2 transform translate-x-1/2 -translate-y-1/2 border-l-0 border-b-0',
|
|
||||||
right: 'left-0 top-1/2 transform -translate-x-1/2 -translate-y-1/2 border-r-0 border-t-0'
|
|
||||||
};
|
|
||||||
|
|
||||||
return `${baseClasses} ${arrowPositions[position]}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!content) {
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
ref={triggerRef}
|
|
||||||
className={`relative inline-block ${className}`}
|
|
||||||
onMouseEnter={showTooltipWithDelay}
|
|
||||||
onMouseLeave={hideTooltip}
|
|
||||||
onFocus={showTooltipWithDelay}
|
|
||||||
onBlur={hideTooltip}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isVisible && createPortal(
|
|
||||||
<div
|
|
||||||
ref={tooltipRef}
|
|
||||||
className={getTooltipClasses()}
|
|
||||||
style={{
|
|
||||||
top: tooltipPosition.top,
|
|
||||||
left: tooltipPosition.left,
|
|
||||||
}}
|
|
||||||
role="tooltip"
|
|
||||||
aria-hidden={!showTooltip}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
<div className={getArrowClasses()} />
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Tooltip;
|
|
Loading…
x
Reference in New Issue
Block a user