Compare commits

..

17 Commits

Author SHA1 Message Date
720596f6f8 adding dynamic hero text header
All checks were successful
Frontend Build and Deploy / build (push) Successful in 44s
2025-09-12 21:36:04 -03:00
dd2af50722 scroll not working for recursos 2025-09-12 21:30:30 -03:00
f56b030435 removing firebase hard-dependency 2025-09-12 21:27:05 -03:00
4e12ab32e8 add filters to statistics 2025-09-12 21:26:58 -03:00
8090fd13a3 add Firebase integration and configuration
All checks were successful
Frontend Build and Deploy / build (push) Successful in 32s
2025-07-11 22:32:45 -03:00
3084a8e27d small fix
All checks were successful
Frontend Build and Deploy / build (push) Successful in 1m4s
2025-06-30 09:37:23 -03:00
3e8ce05f79 refactors
All checks were successful
Frontend Build and Deploy / build (push) Successful in 27s
2025-06-20 20:00:15 -03:00
a1d3add884 fix disabled
All checks were successful
Frontend Build and Deploy / build (push) Successful in 28s
2025-06-19 21:43:23 -03:00
46e76acad3 tech stats + db dump
Some checks failed
Frontend Build and Deploy / build (push) Failing after 57s
2025-06-19 21:40:38 -03:00
ceaffc088e adding logos e melhorias 2025-06-19 20:33:02 -03:00
af7bd64617 solving scrollbar missing "issue" 2025-06-19 20:07:20 -03:00
c347d5ce24 cpf masking 2025-06-19 19:55:35 -03:00
7acbc48f43 mais melhorias gerais 2025-06-19 18:10:49 -03:00
7512f42e2f pequenas mudanças 2025-06-19 09:58:58 -03:00
885b8a6599 aumentando timeout para endpoints de estatistica
All checks were successful
Frontend Build and Deploy / build (push) Successful in 1m46s
2025-06-18 19:17:14 -03:00
2e94f46b5b small improvements
All checks were successful
Frontend Build and Deploy / build (push) Successful in 22s
2025-06-13 20:21:57 -03:00
02e1663594 fix
All checks were successful
Frontend Build and Deploy / build (push) Successful in 23s
2025-06-12 21:02:11 -03:00
38 changed files with 1879 additions and 285 deletions

View File

@@ -1,3 +1,11 @@
# OpenCand API Configuration # OpenCand API Configuration
# The base URL for the OpenCand API # The base URL for the OpenCand API
VITE_API_BASE_URL=https://api.example.com VITE_API_BASE_URL=https://api.example.com
VITE_FIREBASE_API_KEY="your_api_key"
VITE_FIREBASE_AUTH_DOMAIN="your_auth_domain"
VITE_FIREBASE_PROJECT_ID="your_project_id"
VITE_FIREBASE_STORAGE_BUCKET="your_storage_bucket"
VITE_FIREBASE_MESSAGING_SENDER_ID="your_messaging_sender_id"
VITE_FIREBASE_APP_ID="your_app_id"
VITE_FIREBASE_MEASUREMENT_ID="your_measurement_id"

View File

@@ -32,9 +32,15 @@ jobs:
docker build \ docker build \
--build-arg VITE_API_BASE_URL="https://api.opencand.ivanch.me" \ --build-arg VITE_API_BASE_URL="https://api.opencand.ivanch.me" \
--build-arg VITE_FIREBASE_API_KEY="${{ secrets.VITE_FIREBASE_API_KEY }}" \
--build-arg VITE_FIREBASE_AUTH_DOMAIN="${{ secrets.VITE_FIREBASE_AUTH_DOMAIN }}" \
--build-arg VITE_FIREBASE_PROJECT_ID="${{ secrets.VITE_FIREBASE_PROJECT_ID }}" \
--build-arg VITE_FIREBASE_STORAGE_BUCKET="${{ secrets.VITE_FIREBASE_STORAGE_BUCKET }}" \
--build-arg VITE_FIREBASE_MESSAGING_SENDER_ID="${{ secrets.VITE_FIREBASE_MESSAGING_SENDER_ID }}" \
--build-arg VITE_FIREBASE_APP_ID="${{ secrets.VITE_FIREBASE_APP_ID }}" \
--build-arg VITE_FIREBASE_MEASUREMENT_ID="${{ secrets.VITE_FIREBASE_MEASUREMENT_ID }}" \
--file OpenCand.UI.dockerfile \ --file OpenCand.UI.dockerfile \
-t "${{ env.IMAGE_FRONTEND }}:${TAG}" \ -t "${{ env.IMAGE_FRONTEND }}:${TAG}" .
.
docker push "${{ env.IMAGE_FRONTEND }}:${TAG}" docker push "${{ env.IMAGE_FRONTEND }}:${TAG}"

View File

@@ -11,6 +11,21 @@ COPY . .
ARG VITE_API_BASE_URL ARG VITE_API_BASE_URL
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
ARG VITE_FIREBASE_API_KEY
ENV VITE_FIREBASE_API_KEY=${VITE_FIREBASE_API_KEY}
ARG VITE_FIREBASE_AUTH_DOMAIN
ENV VITE_FIREBASE_AUTH_DOMAIN=${VITE_FIREBASE_AUTH_DOMAIN}
ARG VITE_FIREBASE_PROJECT_ID
ENV VITE_FIREBASE_PROJECT_ID=${VITE_FIREBASE_PROJECT_ID}
ARG VITE_FIREBASE_STORAGE_BUCKET
ENV VITE_FIREBASE_STORAGE_BUCKET=${VITE_FIREBASE_STORAGE_BUCKET}
ARG VITE_FIREBASE_MESSAGING_SENDER_ID
ENV VITE_FIREBASE_MESSAGING_SENDER_ID=${VITE_FIREBASE_MESSAGING_SENDER_ID}
ARG VITE_FIREBASE_APP_ID
ENV VITE_FIREBASE_APP_ID=${VITE_FIREBASE_APP_ID}
ARG VITE_FIREBASE_MEASUREMENT_ID
ENV VITE_FIREBASE_MEASUREMENT_ID=${VITE_FIREBASE_MEASUREMENT_ID}
RUN yarn build RUN yarn build
# ─── Stage 2: Serve ──────────────────────────────────────────────────────── # ─── Stage 2: Serve ────────────────────────────────────────────────────────
@@ -20,6 +35,7 @@ RUN rm -rf /usr/share/nginx/html/*
# Replace default nginx.conf # Replace default nginx.conf
COPY nginx.conf /etc/nginx/nginx.conf COPY nginx.conf /etc/nginx/nginx.conf
# Copy our built files into nginxs html folder # Copy our built files into nginxs html folder
COPY ./public/assets /usr/share/nginx/html
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
# (Optional) If you need any custom nginx.conf, COPY it here— # (Optional) If you need any custom nginx.conf, COPY it here—

View File

@@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<title>OpenCand</title> <title>OpenCand</title>
</head> </head>
<body> <body>

996
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"@tailwindcss/postcss": "^4.1.8", "@tailwindcss/postcss": "^4.1.8",
"@tailwindcss/vite": "^4.1.8", "@tailwindcss/vite": "^4.1.8",
"firebase": "^11.10.0",
"postcss": "^8.5.4", "postcss": "^8.5.4",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -13,7 +13,8 @@ body, html {
padding: 0; padding: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow-x: hidden; scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
} }
/* Custom animations */ /* Custom animations */
@@ -95,3 +96,52 @@ body, html {
-ms-overflow-style: none; /* Internet Explorer and Edge */ -ms-overflow-style: none; /* Internet Explorer and Edge */
overflow: -moz-scrollbars-none; /* Old versions of Firefox */ overflow: -moz-scrollbars-none; /* Old versions of Firefox */
} }
h1 {
font-size: 3.2em;
line-height: 1.1;
}
/* Custom minimal scrollbar styles */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 1px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5);
border-radius: 2px;
transition: background-color 0.2s ease;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(107, 114, 128, 0.7);
}
.custom-scrollbar::-webkit-scrollbar-corner {
background: transparent;
}
/* Custom fade-in animation for dropdown content */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.3s ease-out forwards;
}

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React, { useEffect } from 'react';
import { Routes, Route } from 'react-router-dom'; import { Routes, Route, useLocation } from 'react-router-dom';
import Navbar from './components/Navbar'; import Navbar from './components/Navbar';
import HeroSection from './components/HeroSection'; import HeroSection from './components/HeroSection';
import StatisticsSection from './components/StatisticsSection'; import StatisticsSection from './components/StatisticsSection';
@@ -8,21 +8,48 @@ import Footer from './components/Footer';
import CandidatePage from './components/CandidatePage/CandidatePage'; import CandidatePage from './components/CandidatePage/CandidatePage';
import DataStatsPage from './components/DataStatsPage'; import DataStatsPage from './components/DataStatsPage';
import StatisticsPage from './components/StatisticsPage'; import StatisticsPage from './components/StatisticsPage';
import SobrePage from './components/SobrePage';
import NotFound from './components/NotFound'; import NotFound from './components/NotFound';
import MatrixBackground from './components/MatrixBackground'; import MatrixBackground from './components/MatrixBackground';
import './App.css'; import './App.css';
// HomePage component // HomePage component
const HomePage: React.FC = () => ( const HomePage: React.FC = () => {
<main className="flex-grow"> const location = useLocation();
<HeroSection />
<StatisticsSection /> useEffect(() => {
<div className="w-32 h-1 bg-gradient-to-r from-indigo-400 to-purple-400 mx-auto mt-6 rounded-full"></div> if (location.hash) {
<FeaturesSection /> const element = document.getElementById(location.hash.substring(1));
</main> if (element) {
); element.scrollIntoView({ behavior: 'smooth' });
}
}
}, [location]);
return (
<main className="flex-grow">
<HeroSection />
<div className="w-32 h-1 bg-gradient-to-r from-indigo-400 to-purple-400 mx-auto mt-12 rounded-full"></div>
<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>
);
};
function App() { function App() {
const location = useLocation();
useEffect(() => {
const segment = location.hash;
if (segment) {
const element = document.getElementById(segment.replace('#', ''));
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
}, [location]);
return ( return (
<div className="min-h-screen w-full flex flex-col relative" style={{ backgroundColor: 'transparent' }}> <div className="min-h-screen w-full flex flex-col relative" style={{ backgroundColor: 'transparent' }}>
<MatrixBackground /> <MatrixBackground />
@@ -34,6 +61,7 @@ function App() {
<Route path="/candidato/:id" element={<CandidatePage />} /> <Route path="/candidato/:id" element={<CandidatePage />} />
<Route path="/dados-disponiveis" element={<DataStatsPage />} /> <Route path="/dados-disponiveis" element={<DataStatsPage />} />
<Route path="/estatisticas" element={<StatisticsPage />} /> <Route path="/estatisticas" element={<StatisticsPage />} />
<Route path="/sobre" element={<SobrePage />} />
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>
</div> </div>

View File

@@ -145,3 +145,22 @@ export interface OpenCandDataAvailabilityStats {
redeSocialCandidatos: number[]; redeSocialCandidatos: number[];
fotosCandidatos: number[]; fotosCandidatos: number[];
} }
export interface OpenCandDatabaseStats {
tables: {
name: string;
totalSize: number; // in bytes
entries: number; // number of rows
}[];
materializedViews: {
name: string;
totalSize: number; // in bytes
entries: number; // number of rows
}[];
indexes: {
amount: number; // number of indexes
size: number; // total size of indexes in bytes
};
totalSize: number; // total size of the database in bytes
totalEntries: number; // total number of entries across all tables
}

View File

@@ -1,7 +1,8 @@
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, OpenCandDataAvailabilityStats, PlatformStats, RandomCandidate } from './apiModels'; import type { CandidateAssets, CandidateDetails, CandidateExpenses, CandidateIncome, CandidateRedesSociais, CandidateSearchResult, CpfRevealResult, OpenCandDataAvailabilityStats, OpenCandDatabaseStats, PlatformStats, RandomCandidate } from './apiModels';
import type { EnrichmentResponse, StatisticsConfig, ValueSumRequest, ValueSumResponse } from './apiStatisticsModels'; import type { EnrichmentResponse, StatisticsConfig, ValueSumRequest, ValueSumResponse } from './apiStatisticsModels';
import type { StatisticsRequestFilters, StatisticsRequestOptions } from '../components/StatisticsPage/statisticsRequests';
/** /**
* OpenCand API client for interacting with the OpenCand platform * OpenCand API client for interacting with the OpenCand platform
@@ -27,6 +28,13 @@ export class OpenCandApi extends BaseApiClient {
return this.get<OpenCandDataAvailabilityStats>('/v1/stats/data-availability'); return this.get<OpenCandDataAvailabilityStats>('/v1/stats/data-availability');
} }
/**
* Get the database tech stats
*/
async getDatabaseTechStats(): Promise<OpenCandDatabaseStats> {
return this.get<OpenCandDatabaseStats>(`/v1/stats/tech`);
}
/** /**
* Search for candidates by name or other attributes * Search for candidates by name or other attributes
*/ */
@@ -94,15 +102,35 @@ export class OpenCandApi extends BaseApiClient {
/** /**
* Get the enrichment statistics for candidates * Get the enrichment statistics for candidates
*/ */
async getStatisticsEnrichment(): Promise<EnrichmentResponse[]> { async getStatisticsEnrichment(filters?: StatisticsRequestFilters): Promise<EnrichmentResponse[]> {
return this.get<EnrichmentResponse[]>(`/v1/estatistica/enriquecimento`); let url = `/v1/estatistica/enriquecimento`;
if (filters) {
const params = new URLSearchParams();
if (filters.partido !== null && filters.partido !== undefined) {
params.append('partido', filters.partido);
}
if (filters.uf !== null && filters.uf !== undefined) {
params.append('uf', filters.uf);
}
if (filters.ano !== null && filters.ano !== undefined) {
params.append('ano', String(filters.ano));
}
if (filters.cargo !== null && filters.cargo !== undefined) {
params.append('cargo', filters.cargo);
}
const queryString = params.toString();
if (queryString) {
url += `?${queryString}`;
}
}
return this.get<EnrichmentResponse[]>(url, { timeout: 90000 });
} }
/** /**
* Get the sum of values for a specific type and grouping * Get the sum of values for a specific type and grouping
*/ */
async getStatisticsValueSum(request: ValueSumRequest): Promise<ValueSumResponse> { async getStatisticsValueSum(request: ValueSumRequest): Promise<ValueSumResponse> {
return this.post<ValueSumResponse>(`/v1/estatistica/values-sum`, request); return this.post<ValueSumResponse>(`/v1/estatistica/values-sum`, request, { timeout: 90000 });
} }
} }

View File

@@ -11,12 +11,25 @@ interface IncomeExpenseComponentProps {
isLoadingIncome: boolean; isLoadingIncome: boolean;
} }
const hasIncomeData = (income: CandidateIncome | null) => {
if (!income || income.receitas.length === 0) return false;
return true;
};
const hasExpenseData = (expenses: CandidateExpenses | null) => {
if (!expenses || expenses.despesas.length === 0) return false;
return true;
};
const IncomeExpenseComponent: React.FC<IncomeExpenseComponentProps> = ({ const IncomeExpenseComponent: React.FC<IncomeExpenseComponentProps> = ({
expenses, expenses,
income, income,
isLoadingExpenses, isLoadingExpenses,
isLoadingIncome isLoadingIncome
}) => { }) => {
const showIncome = hasIncomeData(income);
const showExpenses = hasExpenseData(expenses);
if (!showIncome && !showExpenses) return 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">
@@ -25,25 +38,31 @@ const IncomeExpenseComponent: React.FC<IncomeExpenseComponentProps> = ({
</div> </div>
{/* Income Section */} {/* Income Section */}
<IncomeSection {showIncome && (
income={income} <IncomeSection
isLoadingIncome={isLoadingIncome} income={income}
/> isLoadingIncome={isLoadingIncome}
/>
)}
{/* Separator */} {/* Separator */}
<div className="flex items-center my-8"> {showIncome && showExpenses && (
<div className="flex-grow border-t border-gray-300"></div> <div className="flex items-center my-8">
<div className="flex-shrink-0 px-4"> <div className="flex-grow border-t border-gray-300"></div>
<div className="w-3 h-3 bg-gray-300 rounded-full"></div> <div className="flex-shrink-0 px-4">
<div className="w-3 h-3 bg-gray-300 rounded-full"></div>
</div>
<div className="flex-grow border-t border-gray-300"></div>
</div> </div>
<div className="flex-grow border-t border-gray-300"></div> )}
</div>
{/* Expenses Section */} {/* Expenses Section */}
<ExpenseSection {showExpenses && (
expenses={expenses} <ExpenseSection
isLoadingExpenses={isLoadingExpenses} expenses={expenses}
/> isLoadingExpenses={isLoadingExpenses}
/>
)}
</div> </div>
); );
}; };

View File

@@ -0,0 +1,80 @@
import React from 'react';
import type { OpenCandDataAvailabilityStats } from '../../api/apiModels';
interface DataAvailabilityTableProps {
stats: OpenCandDataAvailabilityStats;
sortedYears: number[];
}
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 (API)', icon: '📸' },
];
const DataAvailabilityTable: React.FC<DataAvailabilityTableProps> = ({ stats, sortedYears }) => {
return (
<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-slide-in-left"
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) => {
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">
<div className={`inline-flex items-center justify-center w-8 h-8 rounded-full transition-all duration-100 ${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>
);
};
export default DataAvailabilityTable;

View File

@@ -0,0 +1,39 @@
import React from 'react';
interface DbIndexesStatsProps {
amount: number;
size: number;
}
const formatSize = (sizeInBytes: number): string => {
const sizeMB = sizeInBytes / 1024 / 1024;
return sizeMB > 1024
? `${(sizeMB / 1024).toFixed(2)} GB`
: `${sizeMB.toFixed(2)} MB`;
};
const DbIndexesStats: React.FC<DbIndexesStatsProps> = ({ amount, size }) => {
return (
<div>
<h3 className="text-xl font-semibold text-white mb-2">Índices</h3>
<div className="overflow-x-auto max-h-96 flex justify-center" style={{maxHeight: '28rem'}}>
<table className="w-auto text-left border-collapse mx-auto">
<thead>
<tr className="bg-gray-800/10">
<th className="p-3 text-white font-semibold border-b border-gray-700/30">Quantidade</th>
<th className="p-3 text-white font-semibold border-b border-gray-700/30">Tamanho Total</th>
</tr>
</thead>
<tbody>
<tr>
<td className="p-3 text-gray-300">{amount}</td>
<td className="p-3 text-gray-300">{formatSize(size)}</td>
</tr>
</tbody>
</table>
</div>
</div>
);
};
export default DbIndexesStats;

View File

@@ -0,0 +1,60 @@
import React from 'react';
interface DbStatItem {
name: string;
totalSize: number;
entries: number;
}
interface DbStatsTableProps {
title: string;
items: DbStatItem[];
showTotal?: boolean;
}
const formatSize = (sizeInBytes: number): string => {
const sizeMB = sizeInBytes / 1024 / 1024;
return sizeMB > 1024
? `${(sizeMB / 1024).toFixed(2)} GB`
: `${sizeMB.toFixed(2)} MB`;
};
const DbStatsTable: React.FC<DbStatsTableProps> = ({ title, items, showTotal = false }) => {
const totalSize = showTotal ? items.reduce((acc, item) => acc + item.totalSize, 0) : 0;
const totalEntries = showTotal ? items.reduce((acc, item) => acc + item.entries, 0) : 0;
return (
<div>
<h3 className="text-xl font-semibold text-white mb-2">{title}</h3>
<div className="overflow-x-auto" style={{maxHeight: '32rem'}}>
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-gray-800/10">
<th className="p-3 text-white font-semibold border-b border-gray-700/30">Nome</th>
<th className="p-3 text-white font-semibold border-b border-gray-700/30">Tamanho Total</th>
<th className="p-3 text-white font-semibold border-b border-gray-700/30">Entradas</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.name} className="hover:bg-gray-800/10 transition-all duration-200">
<td className="p-3 text-white">{item.name.replace(/^public\./, '')}</td>
<td className="p-3 text-gray-300">{formatSize(item.totalSize)}</td>
<td className="p-3 text-gray-300">{item.entries.toLocaleString()}</td>
</tr>
))}
{showTotal && (
<tr className="font-bold bg-gray-900/30">
<td className="p-3 text-white">Total</td>
<td className="p-3 text-gray-300">{formatSize(totalSize)}</td>
<td className="p-3 text-gray-300">{totalEntries.toLocaleString()}</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
};
export default DbStatsTable;

View File

@@ -1,13 +1,21 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { FaCloudDownloadAlt, FaGoogleDrive } from 'react-icons/fa';
import { openCandApi } from '../api'; import { openCandApi } from '../api';
import type { OpenCandDataAvailabilityStats } from '../api/apiModels'; import type { OpenCandDataAvailabilityStats, OpenCandDatabaseStats } from '../api/apiModels';
import Card from '../shared/Card'; import Card from '../shared/Card';
import ErrorPage from './ErrorPage'; import ErrorPage from './ErrorPage';
import DataAvailabilityTable from './DataStats/DataAvailabilityTable';
import DbStatsTable from './DataStats/DbStatsTable';
import GradientButton from '../shared/GradientButton';
import DbIndexesStats from './DataStats/DbIndexesStats';
const DataStatsPage: React.FC = () => { const DataStatsPage: React.FC = () => {
const [stats, setStats] = useState<OpenCandDataAvailabilityStats | null>(null); const [stats, setStats] = useState<OpenCandDataAvailabilityStats | null>(null);
const [dbStats, setDbStats] = useState<OpenCandDatabaseStats | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [dbLoading, setDbLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [dbError, setDbError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const fetchStats = async () => { const fetchStats = async () => {
@@ -22,16 +30,28 @@ const DataStatsPage: React.FC = () => {
setLoading(false); setLoading(false);
} }
}; };
const fetchDbStats = async () => {
try {
setDbLoading(true);
const dbData = await openCandApi.getDatabaseTechStats();
setDbStats(dbData);
} catch (err) {
setDbError('Erro ao carregar estatísticas técnicas do banco de dados');
console.error('Error fetching database tech stats:', err);
} finally {
setDbLoading(false);
}
};
fetchStats(); fetchStats();
fetchDbStats();
}, []); }, []);
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center"> <div className="flex items-center justify-center min-h-[40vh]">
<div className="text-center"> <div className="backdrop-blur-md bg-white/90 rounded-2xl shadow-xl p-8 max-w-md w-full border border-white/30 flex flex-col items-center">
<div className="animate-spin rounded-full h-16 w-16 border-4 border-indigo-600 border-t-transparent mx-auto mb-4"></div> <div className="animate-spin rounded-full h-10 w-10 border-4 border-indigo-600 border-t-transparent mb"></div>
<p className="text-lg text-gray-700">Carregando dados...</p> <p className="text-base text-gray-700">Carregando dados...</p>
</div> </div>
</div> </div>
); );
@@ -62,20 +82,11 @@ const DataStatsPage: React.FC = () => {
}); });
const sortedYears = Array.from(allYears).sort((a, b) => b - a); 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 ( return (
<div className="min-h-screen py-20 px-4 hover:cursor-default"> <div className="min-h-screen py-20 px-4 hover:cursor-default">
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
{/* Header */} {/* Header */}
<div className="text-center mb-12 animate-fade-in"> <div className="text-center mb-12 animate-slide-in-left">
<h1 className="text-4xl md:text-6xl font-bold mb-4 text-white"> <h1 className="text-4xl md:text-6xl font-bold mb-4 text-white">
Disponibilidade de Dados Disponibilidade de Dados
</h1> </h1>
@@ -85,12 +96,29 @@ const DataStatsPage: React.FC = () => {
<div className="w-32 h-1 bg-gradient-to-r from-indigo-400 to-purple-400 mx-auto mt-6 rounded-full"></div> <div className="w-32 h-1 bg-gradient-to-r from-indigo-400 to-purple-400 mx-auto mt-6 rounded-full"></div>
</div> </div>
{/* Download DB Dump Section */}
<div className="flex justify-center mb-10 animate-slide-in-left backdrop-blur-xs bg-gray-800/10 rounded-xl shadow-lg hover:shadow-xl transform transition-all duration-200 overflow-hidden ring-1 ring-gray-700 hover:ring-indigo-300 px-8 py-6 flex flex-col md:flex-row items-center gap-4 max-w-xl-auto">
<div className="flex items-center gap-3 mb-2 md:mb-0">
<FaCloudDownloadAlt className="text-3xl text-green-400" />
<span className="text-lg font-semibold text-white">Download do Dump do Banco de Dados</span>
</div>
<GradientButton
href="https://drive.google.com/file/d/1cfMItrsAdv8y8YUNp04D33s6pYrRbmDn/view?usp=sharing"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 py-2 px-5"
>
<FaGoogleDrive className="text-xl" />
Google Drive
</GradientButton>
</div>
{/* Stats Cards */} {/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
<Card hasAnimation={true} className="bg-gray-800/10 rounded-xl"> <Card hasAnimation={true} className="bg-gray-800/10 rounded-xl">
<div className="text-center"> <div className="text-center">
<div className="text-3xl mb-2">📊</div> <div className="text-3xl mb-2">📊</div>
<div className="text-2xl font-bold text-white">{dataTypes.length}</div> <div className="text-2xl font-bold text-white">6</div>
<div className="text-gray-400">Tipos de Dados</div> <div className="text-gray-400">Tipos de Dados</div>
</div> </div>
</Card> </Card>
@@ -113,69 +141,30 @@ const DataStatsPage: React.FC = () => {
</div> </div>
{/* Data Availability Table */} {/* 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"> <DataAvailabilityTable stats={stats} sortedYears={sortedYears} />
<div className="p-6 border-b border-gray-700/30 bg-gray-800/10"> </div>
<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"> {/* Database Tech Stats Section */}
<table className="w-full"> <div className="mt-20 flex justify-center">
<thead> <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 mb-12 w-full max-w-2xl">
<tr className="bg-gray-800/10"> <div className="p-6 border-b border-gray-700/30 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"> <h2 className="text-2xl font-bold text-white">Dados Técnicas do Banco de Dados</h2>
Tipo de Dado <p className="text-gray-400 mt-2">Informações sobre tabelas, views materializadas e índices</p>
</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) => {
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"
>
<div className={`inline-flex items-center justify-center w-8 h-8 rounded-full transition-all duration-100 ${
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>
{dbLoading ? (
<div className="flex items-center justify-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-4 border-indigo-600 border-t-transparent mr-4"></div>
<span className="text-gray-300">Carregando dados do banco de dados...</span>
</div>
) : dbError ? (
<div className="p-8 text-red-400">{dbError}</div>
) : dbStats ? (
<div className="p-6 space-y-10">
<DbStatsTable title="Tabelas" items={dbStats.tables} showTotal />
<DbStatsTable title="Views Materializadas" items={dbStats.materializedViews} />
<DbIndexesStats amount={dbStats.indexes.amount} size={dbStats.indexes.size} />
</div>
) : null}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import GradientButton from '../shared/GradientButton';
interface ErrorPageProps { interface ErrorPageProps {
title: string; title: string;
@@ -21,12 +22,9 @@ const ErrorPage: React.FC<ErrorPageProps> = ({ title, description, helperText })
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<Link <GradientButton to="/" className="inline-block px-8 py-3">
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 Voltar para a página inicial
</Link> </GradientButton>
{helperText && ( {helperText && (
<div className="mt-6"> <div className="mt-6">

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { ArrowDownOnSquareStackIcon, BookOpenIcon, ChartBarIcon, DocumentTextIcon, LightBulbIcon } from '@heroicons/react/24/outline'; import { ArrowDownOnSquareStackIcon, BookOpenIcon, ChartBarIcon, ChartBarSquareIcon, DocumentMagnifyingGlassIcon, DocumentTextIcon, IdentificationIcon, LightBulbIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import Card from '../shared/Card'; import Card from '../shared/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 }) => {
@@ -14,23 +14,26 @@ const FeatureCard: React.FC<{ icon: React.ElementType, title: string, children:
const FeaturesSection: React.FC = () => { const FeaturesSection: React.FC = () => {
return ( return (
<section id="features" className="py-20"> <section id="recursos" 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-4xl md:text-5xl font-bold text-center text-white mb-12">
Por que OpenCand? Recursos
</h2> </h2>
<div className="flex flex-wrap justify-center gap-8"> <div className="flex flex-wrap justify-center gap-8">
<FeatureCard icon={DocumentTextIcon} title="Acesso Simplificado"> <FeatureCard icon={DocumentTextIcon} title="Acesso Simplificado">
Navegue facilmente por dados complexos do TSE com uma interface limpa e amigável. Navegue facilmente por dados complexos do TSE com uma interface limpa e amigável.
</FeatureCard> </FeatureCard>
<FeatureCard icon={ChartBarIcon} title="Visualizações Claras"> <FeatureCard icon={IdentificationIcon} title="Visualizações Claras">
Entenda as tendências e padrões com gráficos e resumos visuais dos dados eleitorais. Visualização detalhada de perfis, redes sociais e histórico eleitoral.
</FeatureCard> </FeatureCard>
<FeatureCard icon={LightBulbIcon} title="Insights Valiosos"> <FeatureCard icon={DocumentMagnifyingGlassIcon} title="Declaração de Bens">
Obtenha informações relevantes sobre candidatos, partidos e financiamento de campanhas. Visualização acerca de bens declarados para cada candidato.
</FeatureCard> </FeatureCard>
<FeatureCard icon={BookOpenIcon} title="Open Source"> <FeatureCard icon={MagnifyingGlassIcon} title="Informações de Campanha">
Contribua para um projeto aberto e transparente, ajudando a melhorar a plataforma para todos. Análise de receitas e despesas de campanha quando disponível.
</FeatureCard>
<FeatureCard icon={ChartBarSquareIcon} title="Estatísticas">
Estatísticas e gráficos interativos para entender melhor o cenário eleitoral.
</FeatureCard> </FeatureCard>
<FeatureCard icon={ArrowDownOnSquareStackIcon} title="Dados Abertos"> <FeatureCard icon={ArrowDownOnSquareStackIcon} title="Dados Abertos">
Os dados são acessíveis através do TSE e também disponibilizados em nosso repositório GitHub, garantindo transparência e confiabilidade. Os dados são acessíveis através do TSE e também disponibilizados em nosso repositório GitHub, garantindo transparência e confiabilidade.

View File

@@ -2,13 +2,15 @@ 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 backdrop-blur-xs shadow-lg ring-1 ring-gray-800"> <footer className="bg-gray-800/30 text-gray-400 py-4 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 text-md">
&copy; {new Date().getFullYear()} OpenCand. Todos os direitos reservados. OpenCand
</p> </p>
<p className="text-sm"> <p className="text-xs">
Democratizando o acesso à informação eleitoral. Não nos responsabilizamos por quaisquer erros ou inconsistências nos dados apresentados, pois estes são de livre interpretação da plataforma e devem ser verificados com os dados oficiais do TSE.
<br />
Logos desenhados por Freepik.
</p> </p>
</div> </div>
</footer> </footer>

View File

@@ -1,17 +1,33 @@
import React from 'react'; import React, { useState } from 'react';
import SearchBar from './SearchBar'; import SearchBar from './SearchBar';
import RandomCandButton from '../shared/RandomCandButton'; import RandomCandButton from '../shared/RandomCandButton';
const HeroSection: React.FC = () => { const HeroSection: React.FC = () => {
const headers = [
"Explore Dados Eleitorais",
"Analise Dados Eleitorais",
"Consulte Dados Eleitorais",
"Verifique Dados Eleitorais",
"Descubra Dados Eleitorais",
"Acesse Informações Eleitorais",
"Verifique Candidatos",
"Descubra Candidatos",
"Pesquise Candidaturas",
"Consulte Candidatos",
"Navegue pelos Dados do TSE"
];
const [header] = useState(headers[Math.floor(Math.random() * headers.length)]);
return ( return (
<section <section
className="min-h-screen flex flex-col justify-center items-center text-white bg-cover bg-center bg-no-repeat bg-gray-900 relative" className="min-h-screen flex flex-col justify-center items-center text-white bg-cover bg-center bg-no-repeat bg-gray-900 relative"
style={{ backgroundImage: "url('https://upload.wikimedia.org/wikipedia/commons/thumb/2/21/National_Congress_of_Brazil%2C_Brasilia.jpg/1024px-National_Congress_of_Brazil%2C_Brasilia.jpg')" }} style={{ backgroundImage: "url('/assets/Congresso_Nacional_hero.jpg')" }}
> >
<div className="absolute inset-0 bg-black/60"></div> <div className="absolute inset-0 bg-black/60"></div>
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-t from-[#0a0f1a] to-transparent"></div>
<div className="relative z-10 text-center max-w-6xl"> <div className="relative z-10 text-center max-w-6xl">
<h1 className="text-5xl md:text-7xl font-bold mb-6"> <h1 className="text-5xl md:text-7xl font-bold mb-6">
Explore Dados Eleitorais {header}
</h1> </h1>
<p className="text-lg md:text-xl mb-10 text-gray-300"> <p className="text-lg md:text-xl mb-10 text-gray-300">
OpenCand oferece acesso fácil e visualizações intuitivas de dados abertos do Tribunal Superior Eleitoral (TSE). OpenCand oferece acesso fácil e visualizações intuitivas de dados abertos do Tribunal Superior Eleitoral (TSE).
@@ -26,6 +42,18 @@ const HeroSection: React.FC = () => {
/> />
</div> </div>
</div> </div>
{/* Image credit */}
<span className="absolute left-4 bottom-2 z-20 text-[10px] text-gray-200 bg-black/30 px-1.5 py-0.5 rounded select-none pointer-events-none opacity-55">
Edilson Rodrigues/Agência Senado. Senado Federal,
<a
href="https://creativecommons.org/licenses/by/2.0"
target="_blank"
rel="noopener noreferrer"
className="ml-1 text-gray-200 hover:text-white"
>
CC BY 2.0
</a>, via Wikimedia Commons
</span>
</section> </section>
); );
}; };

View File

@@ -34,8 +34,8 @@ const MatrixBackground: React.FC = () => {
connectionDistance: 150, connectionDistance: 150,
dotSpeed: 0.3, dotSpeed: 0.3,
hoverRadius: 120, hoverRadius: 120,
baseBrightness: 0.4, // Moderate base brightness for main background baseBrightness: 0.2, // Moderate base brightness for main background
hoverBrightness: 0.7, // Moderate hover brightness hoverBrightness: 0.5, // Moderate hover brightness
baseThickness: 0.6, baseThickness: 0.6,
hoverThickness: 1.8, hoverThickness: 1.8,
fadeSpeed: 0.08, // Slightly faster fade for better responsiveness fadeSpeed: 0.08, // Slightly faster fade for better responsiveness

View File

@@ -10,14 +10,15 @@ const Navbar: React.FC = () => {
<NavbarMatrixBackground /> <NavbarMatrixBackground />
<div className="relative z-10 p-4"> <div className="relative z-10 p-4">
<div className="container mx-auto flex justify-between items-center"> <div className="container mx-auto flex justify-between items-center">
<a href="/" className="text-2xl font-bold text-indigo-400 hover:text-indigo-300 transition-colors"> <a href="/" className="flex items-center gap-2 text-2xl font-bold text-indigo-400 hover:text-indigo-300 transition-colors">
OpenCand <img src="/assets/opencand-line.png" alt="OpenCand logo" className="h-8 w-auto" />
<span>OpenCand</span>
</a> </a>
<div className="space-x-4"> <div className="space-x-4">
<Button href="/dados-disponiveis">Dados Disponíveis</Button> <Button href="/dados-disponiveis">Dados Disponíveis</Button>
<Button href="/estatisticas">Estatíscas</Button> <Button href="/estatisticas">Estatíscas</Button>
<Button href="/#features">Recursos</Button> <Button href="/#recursos">Recursos</Button>
<Button href="/about">Sobre</Button> <Button href="/sobre">Sobre</Button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -15,9 +15,11 @@ const SearchBar: React.FC<SearchBarProps> = ({ className = '' }) => {
const [showResults, setShowResults] = useState(false); const [showResults, setShowResults] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const navigate = useNavigate(); const navigate = useNavigate();
const searchTimeoutRef = useRef<number | null>(null); const searchTimeoutRef = useRef<number | null>(null);
const resultsRef = useRef<HTMLDivElement>(null); const resultsRef = useRef<HTMLDivElement>(null);
const resultsContainerRef = useRef<HTMLDivElement>(null);
// Debounced search function // Debounced search function
const performSearch = useCallback(async (query: string) => { const performSearch = useCallback(async (query: string) => {
@@ -74,10 +76,12 @@ const SearchBar: React.FC<SearchBarProps> = ({ className = '' }) => {
// Handle form submission // Handle form submission
const handleSubmit = useCallback((e: React.FormEvent) => { const handleSubmit = useCallback((e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (searchResults.length > 0) { if (activeIndex > -1 && searchResults[activeIndex]) {
handleCandidateSelect(searchResults[activeIndex]);
} else if (searchResults.length > 0) {
handleCandidateSelect(searchResults[0]); handleCandidateSelect(searchResults[0]);
} }
}, [searchResults, handleCandidateSelect]); }, [searchResults, handleCandidateSelect, activeIndex]);
// Close results when clicking outside // Close results when clicking outside
useEffect(() => { useEffect(() => {
@@ -93,6 +97,43 @@ const SearchBar: React.FC<SearchBarProps> = ({ className = '' }) => {
}; };
}, []); }, []);
// Reset active index when search results change
useEffect(() => {
setActiveIndex(-1);
}, [searchResults]);
// Scroll active item into view
useEffect(() => {
if (activeIndex < 0 || !resultsContainerRef.current) return;
const resultsContainer = resultsContainerRef.current;
const activeButton = resultsContainer.children[activeIndex] as HTMLElement;
if (activeButton) {
activeButton.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
});
}
}, [activeIndex]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
setShowResults(false);
return;
}
if (showResults && searchResults.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex(prevIndex => (prevIndex + 1) % searchResults.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex(prevIndex => (prevIndex - 1 + searchResults.length) % searchResults.length);
}
}
};
const getCandidateDescription = (candidate: Candidate) => { const getCandidateDescription = (candidate: Candidate) => {
const desc = ['']; const desc = [''];
@@ -126,6 +167,7 @@ const SearchBar: React.FC<SearchBarProps> = ({ className = '' }) => {
onChange={handleInputChange} onChange={handleInputChange}
onFocus={() => setIsFocused(true)} onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)} onBlur={() => setIsFocused(false)}
onKeyDown={handleKeyDown}
placeholder="Pesquisar candidatos..." placeholder="Pesquisar candidatos..."
className="flex-grow bg-transparent text-white placeholder-gray-400 p-3 focus:outline-none" className="flex-grow bg-transparent text-white placeholder-gray-400 p-3 focus:outline-none"
autoComplete="off" autoComplete="off"
@@ -160,12 +202,13 @@ const SearchBar: React.FC<SearchBarProps> = ({ className = '' }) => {
{error} {error}
</div> </div>
) : ( ) : (
<div className="max-h-80 overflow-y-auto custom-scrollbar"> <div ref={resultsContainerRef} className="max-h-80 overflow-y-auto custom-scrollbar">
{searchResults.map((candidate) => ( {searchResults.map((candidate, index) => (
<button <button
key={candidate.idCandidato} key={candidate.idCandidato}
onClick={() => handleCandidateSelect(candidate)} onClick={() => handleCandidateSelect(candidate)}
className="w-full p-4 text-left hover:bg-gray-100 transition-colors border-b border-gray-100 last:border-b-0 focus:outline-none focus:bg-gray-100" onMouseEnter={() => setActiveIndex(index)}
className={`w-full p-4 text-left hover:bg-gray-100 transition-colors border-b border-gray-100 last:border-b-0 focus:outline-none focus:bg-gray-100 ${index === activeIndex ? 'bg-gray-100' : ''}`}
> >
<div className="text-black font-semibold text-base">{candidate.nome}</div> <div className="text-black font-semibold text-base">{candidate.nome}</div>
<div className="text-gray-600 text-sm mt-1"> <div className="text-gray-600 text-sm mt-1">

View File

@@ -0,0 +1,120 @@
import React from 'react';
import { FaGithub, FaLinkedin } from 'react-icons/fa';
const SobrePage: React.FC = () => {
return (
<div className="flex justify-center items-center min-h-[80vh] py-12 px-4">
<div
className="backdrop-blur-md bg-white/90 rounded-2xl shadow-xl p-8 max-w-3xl w-full border border-white/30"
style={{ boxShadow: '0 8px 32px 0 rgba(31, 38, 135, 0.2)' }}
>
<div className="space-y-4 text-gray-700">
<p className="text-lg leading-relaxed">
O <strong>OpenCand</strong> é uma plataforma que visa explorar de forma intuitiva os dados eleitorais brasileiros e possui
o objetivo de ser uma alternativa para o acesso às informações públicas do Tribunal Superior Eleitoral (TSE).
</p>
<p className="leading-relaxed">
O projeto foi desenvolvido por <strong className="inline-flex items-center gap-1">
ivanch
<a
href="https://github.com/ivanch"
target="_blank"
rel="noopener noreferrer"
className="align-middle inline-block hover:scale-110 transition-transform"
aria-label="GitHub"
>
<FaGithub className="text-gray-700 hover:text-black" size={16} />
</a>
<a
href="https://www.linkedin.com/in/joseivanch"
target="_blank"
rel="noopener noreferrer"
className="align-middle inline-block hover:scale-110 transition-transform"
aria-label="LinkedIn"
>
<FaLinkedin className="text-blue-700 hover:text-blue-900" size={16} />
</a>
</strong> como hobby, o código é semi-aberto e está disponível no <strong>GitHub</strong>.
Atualmente apenas o ETL (Extract-Transform-Load) está disponível, juntamente com o banco de dados final.
</p>
<p className="leading-relaxed">
A API também pode ser utilizada para acessar os dados de forma pública, porém ainda não está documentada. A documentação da API será disponibilizada em breve.
</p>
<div className="w-32 h-1 bg-gradient-to-r from-indigo-400 to-purple-400 mx-auto mt-6 rounded-full"></div>
<p className="leading-relaxed">
Não nos responsabilizamos por quaisquer erros ou inconsistências nos dados apresentados, pois estes são de livre interpretação da plataforma e devem ser verificados com os dados oficiais do TSE.
<br />
A plataforma é uma iniciativa independente e não possui qualquer vínculo com o TSE ou órgãos governamentais.
</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 className="mt-6 flex flex-col items-center">
<p className="font-semibold text-gray-800 mb-2 text-center">Links úteis:</p>
<ul className="space-y-2 w-full max-w-md">
<li className="flex items-center justify-center">
<span className="w-2 h-2 bg-indigo-500 rounded-full mr-3"></span>
<a
href="https://divulgacandcontas.tse.jus.br/divulga/#/home"
target="_blank"
rel="noopener noreferrer"
className="text-indigo-700 hover:underline hover:text-indigo-900 transition-colors text-center"
>
DivulgaCand do TSE
</a>
</li>
<li className="flex items-center justify-center">
<span className="w-2 h-2 bg-indigo-500 rounded-full mr-3"></span>
<a
href="https://sig.tse.jus.br/ords/dwapr/r/seai/sig-eleicao/home"
target="_blank"
rel="noopener noreferrer"
className="text-indigo-700 hover:underline hover:text-indigo-900 transition-colors text-center"
>
Estatísticas do TSE
</a>
</li>
<li className="flex items-center justify-center">
<span className="w-2 h-2 bg-indigo-500 rounded-full mr-3"></span>
<a
href="https://dadosabertos.tse.jus.br/dataset/"
target="_blank"
rel="noopener noreferrer"
className="text-indigo-700 hover:underline hover:text-indigo-900 transition-colors text-center"
>
Dataset do TSE
</a>
</li>
</ul>
</div>
<div className="w-32 h-1 bg-gradient-to-r from-indigo-400 to-purple-400 mx-auto mt-6 rounded-full"></div>
<p className="leading-relaxed">
Contato: {[
'opencand',
<span key="at" style={{ userSelect: 'text' }}>@</span>,
'ivanch',
<span key="dot" style={{ userSelect: 'text' }}>.</span>,
'me'
]}
</p>
<div className="mt-6 pt-4 border-t border-gray-300">
<p className="text-sm text-gray-600 text-center">
Desenvolvido com .NET, PostgreSQL, React (TypeScript/Tailwind CSS) e dados abertos do TSE.<br />
<span className="text-xs">
</span>
</p>
</div>
</div>
</div>
</div>
);
};
export default SobrePage;

View File

@@ -79,7 +79,7 @@ const StatisticsFilters: React.FC<StatisticsFiltersProps> = ({
{/* Party Filter */} {/* Party Filter */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-semibold text-gray-600 uppercase tracking-wide"> <label className="block text-sm font-semibold text-gray-600 uppercase tracking-wide">
Partido (Opcional) Partido
</label> </label>
<select <select
value={localFilters.partido || ''} value={localFilters.partido || ''}
@@ -99,7 +99,7 @@ const StatisticsFilters: React.FC<StatisticsFiltersProps> = ({
{/* UF Filter */} {/* UF Filter */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-semibold text-gray-600 uppercase tracking-wide"> <label className="block text-sm font-semibold text-gray-600 uppercase tracking-wide">
UF (Opcional) UF
</label> </label>
<select <select
value={localFilters.uf || ''} value={localFilters.uf || ''}
@@ -119,7 +119,7 @@ const StatisticsFilters: React.FC<StatisticsFiltersProps> = ({
{/* Year Filter */} {/* Year Filter */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-semibold text-gray-600 uppercase tracking-wide"> <label className="block text-sm font-semibold text-gray-600 uppercase tracking-wide">
Ano (Opcional) Ano
</label> </label>
<select <select
value={localFilters.ano || ''} value={localFilters.ano || ''}
@@ -139,7 +139,7 @@ const StatisticsFilters: React.FC<StatisticsFiltersProps> = ({
{/* Cargo Filter */} {/* Cargo Filter */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-semibold text-gray-600 uppercase tracking-wide"> <label className="block text-sm font-semibold text-gray-600 uppercase tracking-wide">
Cargo (Opcional) Cargo
</label> </label>
<select <select
value={localFilters.cargo || ''} value={localFilters.cargo || ''}

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import type { StatisticsData } from './statisticsRequests'; import type { StatisticsData } from './statisticsRequests';
import GlassCard from '../../shared/GlassCard'; import WhiteCard from '../../shared/WhiteCard';
interface StatisticsGraphsProps { interface StatisticsGraphsProps {
isLoading: boolean; isLoading: boolean;
@@ -13,31 +13,32 @@ const StatisticsGraphs: React.FC<StatisticsGraphsProps> = ({
error, error,
statisticsData, statisticsData,
}) => { }) => {
const [currentEnrichmentIndex, setCurrentEnrichmentIndex] = React.useState(0);
if (error) { if (error) {
return ( return (
<GlassCard className="flex items-center justify-center h-64"> <WhiteCard className="flex items-center justify-center h-64">
<div className="text-center"> <div className="text-center">
<div className="text-red-600 text-lg mb-2"> Erro</div> <div className="text-red-600 text-lg mb-2"> Erro</div>
<p className="text-gray-700">{error}</p> <p className="text-gray-700">{error}</p>
</div> </div>
</GlassCard> </WhiteCard>
); );
} }
if (isLoading) { if (isLoading) {
return ( return (
<GlassCard className="flex items-center justify-center h-64"> <WhiteCard className="flex items-center justify-center h-64">
<div className="text-center"> <div className="text-center">
<div className="animate-spin w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full mx-auto mb-4"></div> <div className="animate-spin w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full mx-auto mb-4"></div>
<p className="text-gray-700">Carregando dados...</p> <p className="text-gray-700">Carregando dados...</p>
</div> </div>
</GlassCard> </WhiteCard>
); );
} }
if (!statisticsData) { if (!statisticsData) {
return ( return (
<GlassCard className="flex items-center justify-center h-64"> <WhiteCard className="flex items-center justify-center h-64">
<div className="text-center"> <div className="text-center">
<div className="text-gray-500 text-4xl mb-4">📊</div> <div className="text-gray-500 text-4xl mb-4">📊</div>
<h3 className="text-gray-900 text-lg mb-2">Nenhum dado encontrado</h3> <h3 className="text-gray-900 text-lg mb-2">Nenhum dado encontrado</h3>
@@ -45,44 +46,63 @@ const StatisticsGraphs: React.FC<StatisticsGraphsProps> = ({
Os dados estatísticos não puderam ser carregados Os dados estatísticos não puderam ser carregados
</p> </p>
</div> </div>
</GlassCard> </WhiteCard>
); );
} }
const renderDataTable = (title: string, data: any[], type: 'candidate' | 'party' | 'state' | 'enrichment') => { const renderDataTable = (title: string, data: any[], type: 'candidate' | 'party' | 'state' | 'enrichment') => {
if (!data || data.length === 0) { if (!data || data.length === 0) {
return ( return (
<GlassCard> <WhiteCard>
<div> <div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">{title}</h3> <h3 className="text-lg font-semibold text-gray-900 mb-4">{title}</h3>
<p className="text-gray-700">Nenhum dado disponível</p> <p className="text-gray-700">Nenhum dado disponível</p>
</div> </div>
</GlassCard> </WhiteCard>
); );
} }
if (type === 'enrichment') { if (type === 'enrichment') {
const enrichmentData = data[0]; // Single enrichment response const enrichmentData = data[currentEnrichmentIndex]; // Use current index instead of always first item
const hasMultipleItems = data.length > 1;
const handleNext = () => {
if (currentEnrichmentIndex < data.length - 1) {
setCurrentEnrichmentIndex(currentEnrichmentIndex + 1);
}
};
const handleBack = () => {
if (currentEnrichmentIndex > 0) {
setCurrentEnrichmentIndex(currentEnrichmentIndex - 1);
}
};
return ( return (
<GlassCard> <WhiteCard>
<div> <div className="relative">
<h3 className="text-lg font-semibold text-gray-900 mb-4">{title}</h3> <h3 className="text-lg font-semibold text-gray-900 mb-4">{title}</h3>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-gray-600">Candidato:</span> <span className="text-gray-600">Candidato:</span>
<span className="text-gray-900 font-medium">{enrichmentData.nome}</span> <a
href={`/candidato/${enrichmentData.idCandidato}`}
className="text-gray-900 font-medium hover:text-blue-600 hover:underline transition-colors duration-200"
>
{enrichmentData.nome}
</a>
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-gray-600">Patrimônio Inicial (<span className="text-xs text-gray-500">{enrichmentData.anoInicial}</span>):</span> <span className="text-gray-600">Patrimônio Inicial (<span className="text-xs text-gray-500">{enrichmentData.anoInicial}</span>):</span>
<span className="text-gray-900 font-medium"> <span className="text-gray-900 font-medium">
R$ {enrichmentData.patrimonioInicial?.toLocaleString('pt-BR') || '0'} R$ {enrichmentData.patrimonioInicial?.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) || '0,00'}
</span> </span>
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-gray-600">Patrimônio Final (<span className="text-xs text-gray-500">{enrichmentData.anoFinal}</span>):</span> <span className="text-gray-600">Patrimônio Final (<span className="text-xs text-gray-500">{enrichmentData.anoFinal}</span>):</span>
<span className="text-gray-900 font-medium"> <span className="text-gray-900 font-medium">
R$ {enrichmentData.patrimonioFinal?.toLocaleString('pt-BR') || '0'} R$ {enrichmentData.patrimonioFinal?.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) || '0,00'}
</span> </span>
</div> </div>
<div className="border-t border-gray-200 pt-3"> <div className="border-t border-gray-200 pt-3">
@@ -92,18 +112,57 @@ const StatisticsGraphs: React.FC<StatisticsGraphsProps> = ({
enrichmentData.enriquecimento >= 0 ? 'text-green-600' : 'text-red-600' enrichmentData.enriquecimento >= 0 ? 'text-green-600' : 'text-red-600'
}`}> }`}>
{enrichmentData.enriquecimento >= 0 ? '+' : ''} {enrichmentData.enriquecimento >= 0 ? '+' : ''}
R$ {enrichmentData.enriquecimento?.toLocaleString('pt-BR') || '0'} R$ {enrichmentData.enriquecimento?.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) || '0,00'}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
{/* Navigation buttons */}
{hasMultipleItems && (
<div className="flex justify-between items-center mt-4 pt-3 border-t border-gray-200">
<button
onClick={handleBack}
disabled={currentEnrichmentIndex === 0}
className={`flex items-center px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
currentEnrichmentIndex === 0
? 'text-gray-400 cursor-not-allowed opacity-50'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100 active:bg-gray-200'
}`}
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Anterior
</button>
<div className="text-xs text-gray-500">
{currentEnrichmentIndex + 1} de {data.length}
</div>
<button
onClick={handleNext}
disabled={currentEnrichmentIndex === data.length - 1}
className={`flex items-center px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
currentEnrichmentIndex === data.length - 1
? 'text-gray-400 cursor-not-allowed opacity-50'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100 active:bg-gray-200'
}`}
>
Próximo
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
)}
</div> </div>
</GlassCard> </WhiteCard>
); );
} }
return ( return (
<GlassCard> <WhiteCard>
<div> <div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">{title}</h3> <h3 className="text-lg font-semibold text-gray-900 mb-4">{title}</h3>
<div className="overflow-auto max-h-90 overflow-y-auto custom-scrollbar pr-2"> <div className="overflow-auto max-h-90 overflow-y-auto custom-scrollbar pr-2">
@@ -129,9 +188,11 @@ const StatisticsGraphs: React.FC<StatisticsGraphsProps> = ({
{data.slice(0, 5).map((item, index) => ( {data.slice(0, 5).map((item, index) => (
<tr key={index} className="border-b border-gray-100 hover:bg-gray-50 transition-colors duration-150"> <tr key={index} className="border-b border-gray-100 hover:bg-gray-50 transition-colors duration-150">
{type === 'candidate' && ( {type === 'candidate' && (
<> <a href={`/candidato/${item.idCandidato}`}
<td className="py-3 text-gray-900">{item.nome || 'N/A'}</td> className="text-gray-900 font-medium hover:text-blue-600 hover:underline transition-colors duration-200"
</> >
<td className="py-3">{item.nome || 'N/A'}</td>
</a>
)} )}
{type === 'party' && ( {type === 'party' && (
<td className="py-3 text-gray-900">{item.sgpartido || 'N/A'}</td> <td className="py-3 text-gray-900">{item.sgpartido || 'N/A'}</td>
@@ -141,7 +202,7 @@ const StatisticsGraphs: React.FC<StatisticsGraphsProps> = ({
)} )}
<td className="py-3 text-gray-900">{item.ano || 'N/A'}</td> <td className="py-3 text-gray-900">{item.ano || 'N/A'}</td>
<td className="py-3 text-right font-medium text-gray-900"> <td className="py-3 text-right font-medium text-gray-900">
R$ {item.valor?.toLocaleString('pt-BR') || '0'} R$ {item.valor?.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) || '0,00'}
</td> </td>
</tr> </tr>
))} ))}
@@ -149,7 +210,7 @@ const StatisticsGraphs: React.FC<StatisticsGraphsProps> = ({
</table> </table>
</div> </div>
</div> </div>
</GlassCard> </WhiteCard>
); );
}; };

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import GlassCard from '../../shared/GlassCard'; import WhiteCard from '../../shared/WhiteCard';
import StatisticsFilters from './StatisticsFilters'; import StatisticsFilters from './StatisticsFilters';
import StatisticsGraphs from './StatisticsGraphs'; import StatisticsGraphs from './StatisticsGraphs';
import { fetchAllStatisticsData, type StatisticsData, type StatisticsRequestOptions } from './statisticsRequests'; import { fetchAllStatisticsData, type StatisticsData, type StatisticsRequestOptions } from './statisticsRequests';
@@ -66,13 +66,16 @@ const StatisticsPage: React.FC = () => {
<p className="text-white/70 text-lg"> <p className="text-white/70 text-lg">
Análise de dados e estatísticas dos candidatos e partidos brasileiros Análise de dados e estatísticas dos candidatos e partidos brasileiros
</p> </p>
<p className="text-white/70 text-lg">
Para mais informações, acesse a <a href="https://sig.tse.jus.br/ords/dwapr/r/seai/sig-eleicao/home" target="_blank" rel="noopener noreferrer" className="text-indigo-400 hover:underline">página de estatísticas do TSE</a> ou o <a href="https://divulgacandcontas.tse.jus.br/divulga/#/home" target="_blank" rel="noopener noreferrer" className="text-indigo-400 hover:underline">DivulgaCand do TSE</a>.
</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 className="w-32 h-1 bg-gradient-to-r from-indigo-400 to-purple-400 mx-auto mt-6 rounded-full"></div>
</div> </div>
<div className="flex gap-6 "> <div className="flex gap-6 ">
{/* Left Sidebar - Filters (20% width) */} {/* Left Sidebar - Filters (20% width) */}
<div className="w-1/5 min-w-[300px] h-[calc(100vh-12rem)]"> <div className="w-1/5 min-w-[300px] h-[calc(100vh-12rem)]">
<GlassCard <WhiteCard
fullHeight fullHeight
className="overflow-y-auto" className="overflow-y-auto"
> >
@@ -81,7 +84,7 @@ const StatisticsPage: React.FC = () => {
onFiltersChange={handleFiltersChange} onFiltersChange={handleFiltersChange}
isLoading={isLoading} isLoading={isLoading}
/> />
</GlassCard> </WhiteCard>
</div> </div>
{/* Right Content - Graphs (80% width) */} {/* Right Content - Graphs (80% width) */}

View File

@@ -24,12 +24,14 @@ export interface StatisticsData {
} }
export interface StatisticsRequestOptions { export interface StatisticsRequestOptions {
filters?: { filters?: StatisticsRequestFilters;
partido?: string | null; }
uf?: string | null;
ano?: number | null; export interface StatisticsRequestFilters {
cargo?: CargoFilter; partido?: string | null;
}; uf?: string | null;
ano?: number | null;
cargo?: CargoFilter;
} }
// First Row Requests // First Row Requests
@@ -44,9 +46,9 @@ export async function getCandidatesWithMostAssets(options?: StatisticsRequestOpt
return Array.isArray(response) ? response : [response]; return Array.isArray(response) ? response : [response];
} }
export async function getEnrichmentData(): Promise<EnrichmentResponse[] | null> { export async function getEnrichmentData(filters?: StatisticsRequestFilters): Promise<EnrichmentResponse[] | null> {
try { try {
return await openCandApi.getStatisticsEnrichment(); return await openCandApi.getStatisticsEnrichment(filters);
} catch (error) { } catch (error) {
console.error('Error fetching enrichment data:', error); console.error('Error fetching enrichment data:', error);
return null; return null;
@@ -167,7 +169,7 @@ export async function fetchAllStatisticsData(options?: StatisticsRequestOptions)
statesWithMostRevenue statesWithMostRevenue
] = await Promise.all([ ] = await Promise.all([
getCandidatesWithMostAssets(options), getCandidatesWithMostAssets(options),
getEnrichmentData(), getEnrichmentData(options?.filters),
getCandidatesWithMostRevenue(options), getCandidatesWithMostRevenue(options),
getCandidatesWithMostExpenses(options), getCandidatesWithMostExpenses(options),
getPartiesWithMostAssets(options), getPartiesWithMostAssets(options),

25
src/config/firebase.ts Normal file
View File

@@ -0,0 +1,25 @@
import { initializeApp } from "firebase/app";
import { getAnalytics } from "firebase/analytics";
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID,
measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID
};
let app = null;
let analytics = null;
if (firebaseConfig.apiKey) {
app = initializeApp(firebaseConfig);
analytics = getAnalytics(app);
} else {
console.warn("Firebase API key is not set. Firebase will not be initialized.");
}
export { app, analytics };

View File

@@ -3,8 +3,6 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@plugin 'tailwind-scrollbar';
:root { :root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5; line-height: 1.5;
@@ -29,81 +27,3 @@ body {
@apply bg-gray-900 text-white; @apply bg-gray-900 text-white;
} }
h1 {
font-size: 3.2em;
line-height: 1.1;
}
/* button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
} */
/* Custom minimal scrollbar styles */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 1px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5);
border-radius: 2px;
transition: background-color 0.2s ease;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(107, 114, 128, 0.7);
}
.custom-scrollbar::-webkit-scrollbar-corner {
background: transparent;
}
/* Ensure full width layout */
html, body, #root {
width: 100%;
margin: 0;
padding: 0;
}
#root {
min-height: 100vh;
}
/* Custom fade-in animation for dropdown content */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.3s ease-out forwards;
}

View File

@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
import './config/firebase.ts'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>

View File

@@ -5,6 +5,7 @@ interface ButtonProps {
className?: string; className?: string;
hasAnimation?: boolean; hasAnimation?: boolean;
disableCursor?: boolean; disableCursor?: boolean;
disabled?: boolean;
onClick?: () => void; onClick?: () => void;
href?: string; href?: string;
type?: 'button' | 'submit' | 'reset'; type?: 'button' | 'submit' | 'reset';
@@ -15,6 +16,7 @@ const Button: React.FC<ButtonProps> = ({
className = "", className = "",
hasAnimation = true, hasAnimation = true,
disableCursor = false, disableCursor = false,
disabled = false,
onClick, onClick,
href, href,
type = 'button' type = 'button'
@@ -54,6 +56,7 @@ const Button: React.FC<ButtonProps> = ({
type={type} type={type}
onClick={onClick} onClick={onClick}
className={baseClasses} className={baseClasses}
disabled={disabled}
> >
{children} {children}
</button> </button>

View File

@@ -0,0 +1,46 @@
import React from 'react';
import { Link, type LinkProps } from 'react-router-dom';
interface GradientButtonProps extends React.PropsWithChildren {
className?: string;
to?: LinkProps['to'];
href?: string;
[x: string]: any; // To allow other props like target, rel, type, etc.
}
const GradientButton: React.FC<GradientButtonProps> = ({
children,
className = '',
to,
href,
...props
}) => {
const baseClasses =
'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';
const combinedClassName = `${baseClasses} ${className}`;
if (to) {
return (
<Link to={to} {...props} className={combinedClassName}>
{children}
</Link>
);
}
if (href) {
return (
<a href={href} {...props} className={combinedClassName}>
{children}
</a>
);
}
return (
<button type="button" {...props} className={combinedClassName}>
{children}
</button>
);
};
export default GradientButton;

View File

@@ -9,30 +9,44 @@ interface RandomCandButtonProps {
hasAnimation?: boolean; hasAnimation?: boolean;
} }
const RandomCandButton: React.FC<RandomCandButtonProps> = ({ const RandomCandButton: React.FC<RandomCandButtonProps> = ({
className = '', className = '',
hasAnimation = false hasAnimation = false
}) => { }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = React.useState(false);
const handleRandomCandidate = async () => { const handleRandomCandidate = async () => {
setLoading(true);
try { try {
const randomCandidate = await openCandApi.getRandomCandidate(); const randomCandidate = await openCandApi.getRandomCandidate();
navigate(`/candidato/${randomCandidate.idCandidato}`); navigate(`/candidato/${randomCandidate.idCandidato}`);
} catch (error) { } catch (error) {
console.error('Erro ao buscar candidato aleatório:', error); console.error('Erro ao buscar candidato aleatório:', error);
// You might want to show a toast notification or error message to the user // You might want to show a toast notification or error message to the user
} finally {
setLoading(false);
} }
}; };
return ( return (
<Button <Button
onClick={handleRandomCandidate} onClick={handleRandomCandidate}
className={`flex items-center ${className}`} className={`flex items-center relative overflow-hidden ${className}`}
hasAnimation={hasAnimation} hasAnimation={hasAnimation}
disabled={loading}
> >
<ArrowPathIcon className="h-4 w-4 mr-2" /> <ArrowPathIcon
Candidato aleatório className={`h-4 w-4 transition-transform duration-500 ${loading ? 'animate-spin' : 'mr-2'}`}
style={{ zIndex: 2 }}
/>
<span
className={`inline-block transition-all duration-300 origin-left whitespace-nowrap ${loading ? 'scale-x-0 max-w-0' : 'scale-x-100 max-w-xs'}`}
style={{ zIndex: 1, transitionProperty: 'transform, max-width' }}
>
Candidato aleatório
</span>
</Button> </Button>
); );
}; };

View File

@@ -1,13 +1,13 @@
import React from 'react'; import React from 'react';
interface GlassCardProps { interface WhiteCardProps {
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
fullHeight?: boolean; fullHeight?: boolean;
padding?: string; padding?: string;
} }
const GlassCard: React.FC<GlassCardProps> = ({ const WhiteCard: React.FC<WhiteCardProps> = ({
children, children,
className = '', className = '',
fullHeight = false, fullHeight = false,
@@ -27,4 +27,4 @@ const GlassCard: React.FC<GlassCardProps> = ({
); );
}; };
export default GlassCard; export default WhiteCard;

View File

@@ -1,19 +1,15 @@
/** /**
* Formats a CPF string with masking (123.***.789-10) * Formats a CPF string (might include masking) and masks it for display.
* @param cpf - A CPF string (11 digits without punctuation) * @param cpf - A CPF string (11 digits without punctuation)
* @returns Formatted CPF with masking or the original input if invalid * @returns Formatted CPF with masking or the original input if invalid
*/ */
export function maskCpf(cpf: string): string { export function maskCpf(cpf: string): string {
// Clean input, keeping only digits
const cleanCpf = cpf.replace(/\D/g, '');
// Validate if it's 11 digits // Validate if it's 11 digits
if (cleanCpf.length !== 11) { if (cpf.length !== 11) {
return cpf; return cpf;
} }
// Mask with standard punctuation: 123.456.789-10
// Format with mask: 123.***.789-10 return `${cpf.slice(0, 3)}.${cpf.slice(3, 6)}.${cpf.slice(6, 9)}-${cpf.slice(9)}`;
return `${cleanCpf.slice(0, 3)}.***.${cleanCpf.slice(6, 9)}-${cleanCpf.slice(9)}`;
} }
/** /**