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;
|
||||
}
|
||||
|
||||
/* 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::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
|
@ -6,6 +6,8 @@ import StatisticsSection from './components/StatisticsSection';
|
||||
import FeaturesSection from './components/FeaturesSection';
|
||||
import Footer from './components/Footer';
|
||||
import CandidatePage from './components/CandidatePage/CandidatePage';
|
||||
import DataStatsPage from './components/DataStatsPage';
|
||||
import NotFound from './components/NotFound';
|
||||
import MatrixBackground from './components/MatrixBackground';
|
||||
import './App.css';
|
||||
|
||||
@ -14,6 +16,7 @@ const HomePage: React.FC = () => (
|
||||
<main className="flex-grow">
|
||||
<HeroSection />
|
||||
<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 />
|
||||
</main>
|
||||
);
|
||||
@ -28,6 +31,8 @@ function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/candidato/:id" element={<CandidatePage />} />
|
||||
<Route path="/dados-disponiveis" element={<DataStatsPage />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</div>
|
||||
<Footer />
|
||||
|
@ -124,3 +124,11 @@ export interface Income {
|
||||
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 { 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
|
||||
@ -18,6 +18,14 @@ export class OpenCandApi extends BaseApiClient {
|
||||
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
|
||||
*/
|
||||
|
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 { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { ArrowLeftIcon } from '@heroicons/react/24/outline';
|
||||
import { openCandApi, type CandidateDetails, type CandidateAssets, type CandidateRedesSociais, type CandidateExpenses, type CandidateIncome, ApiError } from '../../api';
|
||||
import ElectionsComponent from './ElectionsComponent';
|
||||
@ -7,6 +7,7 @@ import AssetsComponent from './AssetsComponent';
|
||||
import BasicCandidateInfoComponent from './BasicCandidateInfoComponent';
|
||||
import SocialMediaComponent from './SocialMediaComponent';
|
||||
import IncomeExpenseComponent from './IncomeExpenseComponent';
|
||||
import Button from '../../Components/Button';
|
||||
|
||||
const CandidatePage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@ -116,12 +117,13 @@ const CandidatePage: React.FC = () => {
|
||||
<div className="text-center text-white">
|
||||
<h1 className="text-2xl font-bold mb-4">Erro</h1>
|
||||
<p className="text-red-400 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition-colors"
|
||||
<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 ao Início
|
||||
</button>
|
||||
Voltar para a página inicial
|
||||
</Link>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
@ -131,13 +133,14 @@ const CandidatePage: React.FC = () => {
|
||||
<main className="flex-grow p-6 max-w-7xl mx-auto">
|
||||
{/* Header with back button */}
|
||||
<div className="mb-6">
|
||||
<button
|
||||
<Button
|
||||
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" />
|
||||
Voltar à busca
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
{candidateDetails && (
|
||||
<h1 className="text-3xl font-bold text-white">{candidateDetails.nome}</h1>
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { DocumentTextIcon } from '@heroicons/react/24/outline';
|
||||
import { type Election } from '../../api';
|
||||
import Tooltip from '../Tooltip';
|
||||
import Tooltip from '../../Components/Tooltip';
|
||||
|
||||
interface ElectionsComponentProps {
|
||||
elections: Election[] | null;
|
||||
|
@ -21,35 +21,36 @@ interface SocialMediaComponentProps {
|
||||
}
|
||||
|
||||
// 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 opacityClass = isRecent ? "" : "opacity-50";
|
||||
|
||||
switch (rede.toLowerCase()) {
|
||||
case 'facebook':
|
||||
return <FaFacebook className={`${iconClass} text-blue-600`} />;
|
||||
return <FaFacebook className={`${iconClass} ${opacityClass} text-blue-600`} />;
|
||||
case 'instagram':
|
||||
return <FaInstagram className={`${iconClass} text-pink-600`} />;
|
||||
return <FaInstagram className={`${iconClass} ${opacityClass} text-pink-600`} />;
|
||||
case 'x/twitter':
|
||||
case 'twitter':
|
||||
return <FaXTwitter className={`${iconClass} text-black`} />;
|
||||
return <FaXTwitter className={`${iconClass} ${opacityClass} text-black`} />;
|
||||
case 'tiktok':
|
||||
return <FaTiktok className={`${iconClass} text-black`} />;
|
||||
return <FaTiktok className={`${iconClass} ${opacityClass} text-black`} />;
|
||||
case 'youtube':
|
||||
return <FaYoutube className={`${iconClass} text-red-600`} />;
|
||||
return <FaYoutube className={`${iconClass} ${opacityClass} text-red-600`} />;
|
||||
case 'linkedin':
|
||||
return <FaLinkedin className={`${iconClass} text-blue-700`} />;
|
||||
return <FaLinkedin className={`${iconClass} ${opacityClass} text-blue-700`} />;
|
||||
case 'whatsapp':
|
||||
return <FaWhatsapp className={`${iconClass} text-green-600`} />;
|
||||
return <FaWhatsapp className={`${iconClass} ${opacityClass} text-green-600`} />;
|
||||
case 'threads':
|
||||
return <FaThreads className={`${iconClass} text-black`} />;
|
||||
return <FaThreads className={`${iconClass} ${opacityClass} text-black`} />;
|
||||
case 'telegram':
|
||||
return <FaTelegram className={`${iconClass} text-blue-500`} />;
|
||||
return <FaTelegram className={`${iconClass} ${opacityClass} text-blue-500`} />;
|
||||
case 'spotify':
|
||||
return <FaSpotify className={`${iconClass} text-green-500`} />;
|
||||
return <FaSpotify className={`${iconClass} ${opacityClass} text-green-500`} />;
|
||||
case 'kwai':
|
||||
case 'outros':
|
||||
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,
|
||||
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 (
|
||||
<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">
|
||||
@ -70,27 +76,41 @@ const SocialMediaComponent: React.FC<SocialMediaComponentProps> = ({
|
||||
</div>
|
||||
) : redesSociais && redesSociais.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{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">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center mb-2">
|
||||
{getSocialMediaIcon(redeSocial.rede)}
|
||||
<span className="font-semibold text-gray-900 mr-2">{redeSocial.rede}</span>
|
||||
<span className="text-sm text-gray-500">({redeSocial.ano})</span>
|
||||
{redesSociais.map((redeSocial: RedeSocial, index: number) => {
|
||||
const isRecent = redeSocial.ano === mostRecentYear;
|
||||
return (
|
||||
<div
|
||||
key={`${redeSocial.idCandidato}-${redeSocial.rede}-${index}`}
|
||||
className={`border border-gray-200 rounded-lg p-3 hover:bg-gray-50 transition-colors ${
|
||||
isRecent ? '' : 'opacity-95 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
<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 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 { 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 }) => {
|
||||
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" />
|
||||
<h3 className="text-xl font-semibold text-white mb-2">{title}</h3>
|
||||
<p className="text-gray-400">{children}</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const FeaturesSection: React.FC = () => {
|
||||
return (
|
||||
<section id="features" className="py-20 bg-gray-800/30">
|
||||
<section id="features" className="py-20">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-3xl font-bold text-center text-white mb-12">
|
||||
Por que OpenCand?
|
||||
|
@ -2,7 +2,7 @@ import React from 'react';
|
||||
|
||||
const Footer: React.FC = () => {
|
||||
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">
|
||||
<p className="mb-2">
|
||||
© {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 NavButton from './NavButton';
|
||||
import Button from '../Components/Button';
|
||||
import NavbarMatrixBackground from './NavbarMatrixBackground';
|
||||
|
||||
const Navbar: React.FC = () => {
|
||||
@ -14,9 +14,10 @@ const Navbar: React.FC = () => {
|
||||
OpenCand
|
||||
</a>
|
||||
<div className="space-x-4">
|
||||
<NavButton href="#stats">Estatíscas</NavButton>
|
||||
<NavButton href="#features">Recursos</NavButton>
|
||||
<NavButton href="/about">Sobre</NavButton>
|
||||
<Button href="/dados-disponiveis">Dados Disponíveis</Button>
|
||||
<Button href="/estatisticas">Estatíscas</Button>
|
||||
<Button href="/#features">Recursos</Button>
|
||||
<Button href="/about">Sobre</Button>
|
||||
</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 { openCandApi, type PlatformStats, ApiError } from '../api';
|
||||
import Card from '../Components/Card';
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
@ -10,17 +11,17 @@ interface StatCardProps {
|
||||
|
||||
const StatCard: React.FC<StatCardProps> = ({ title, value, description, isLoading = false }) => {
|
||||
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>
|
||||
{isLoading ? (
|
||||
<div className="h-12 flex items-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-white border-t-transparent"></div>
|
||||
<div className="h-12 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full border-4 border-indigo-600 border-t-transparent"></div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-4xl font-bold text-white mb-3">{value}</p>
|
||||
)}
|
||||
<p className="text-gray-400 text-sm">{description}</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@ -105,36 +106,26 @@ const StatisticsSection: React.FC = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<section id="stats" className="py-20 bg-gray-800/30">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-3xl font-bold text-center text-white mb-12">
|
||||
Dados em Números
|
||||
</h2>
|
||||
<div className="flex flex-wrap justify-center gap-8 mx-auto">
|
||||
{statisticsData.slice(0, 3).map((stat, index) => (
|
||||
<div key={index} className="w-full md:w-80 lg:w-96">
|
||||
<StatCard
|
||||
title={stat.title}
|
||||
value={stat.value}
|
||||
description={stat.description}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<section id="stats" className="py-20">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="text-center mb-12 animate-fade-in">
|
||||
<h2 className="text-4xl md:text-5xl font-bold text-white mb-4">
|
||||
Dados em Números
|
||||
</h2>
|
||||
<p className="text-xl text-gray-400">
|
||||
Estatísticas da nossa plataforma de dados eleitorais
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-8">
|
||||
{statisticsData.map((stat) => (
|
||||
<StatCard
|
||||
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>
|
||||
</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