Compare commits

...

8 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
25 changed files with 1455 additions and 249 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 ────────────────────────────────────────────────────────

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",

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';
@@ -14,7 +14,19 @@ import MatrixBackground from './components/MatrixBackground';
import './App.css'; import './App.css';
// HomePage component // HomePage component
const HomePage: React.FC = () => ( const HomePage: React.FC = () => {
const location = useLocation();
useEffect(() => {
if (location.hash) {
const element = document.getElementById(location.hash.substring(1));
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
}
}, [location]);
return (
<main className="flex-grow"> <main className="flex-grow">
<HeroSection /> <HeroSection />
<div className="w-32 h-1 bg-gradient-to-r from-indigo-400 to-purple-400 mx-auto mt-12 rounded-full"></div> <div className="w-32 h-1 bg-gradient-to-r from-indigo-400 to-purple-400 mx-auto mt-12 rounded-full"></div>
@@ -22,9 +34,22 @@ const HomePage: 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>
<FeaturesSection /> <FeaturesSection />
</main> </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 />

View File

@@ -2,6 +2,7 @@ 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, OpenCandDatabaseStats, 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
@@ -101,8 +102,28 @@ 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`, { timeout: 90000 }); 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 });
} }
/** /**

View File

@@ -13,10 +13,12 @@ interface IncomeExpenseComponentProps {
const hasIncomeData = (income: CandidateIncome | null) => { const hasIncomeData = (income: CandidateIncome | null) => {
if (!income || income.receitas.length === 0) return false; if (!income || income.receitas.length === 0) return false;
return true;
}; };
const hasExpenseData = (expenses: CandidateExpenses | null) => { const hasExpenseData = (expenses: CandidateExpenses | null) => {
if (!expenses || expenses.despesas.length === 0) return false; if (!expenses || expenses.despesas.length === 0) return false;
return true;
}; };
const IncomeExpenseComponent: React.FC<IncomeExpenseComponentProps> = ({ const IncomeExpenseComponent: React.FC<IncomeExpenseComponentProps> = ({

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

@@ -4,6 +4,10 @@ import { openCandApi } from '../api';
import type { OpenCandDataAvailabilityStats, OpenCandDatabaseStats } 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);
@@ -78,15 +82,6 @@ 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 (API)', 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">
@@ -107,15 +102,15 @@ const DataStatsPage: React.FC = () => {
<FaCloudDownloadAlt className="text-3xl text-green-400" /> <FaCloudDownloadAlt className="text-3xl text-green-400" />
<span className="text-lg font-semibold text-white">Download do Dump do Banco de Dados</span> <span className="text-lg font-semibold text-white">Download do Dump do Banco de Dados</span>
</div> </div>
<a <GradientButton
href="https://drive.google.com/file/d/1cfMItrsAdv8y8YUNp04D33s6pYrRbmDn/view?usp=sharing" href="https://drive.google.com/file/d/1cfMItrsAdv8y8YUNp04D33s6pYrRbmDn/view?usp=sharing"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-2 py-2 px-5 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" className="inline-flex items-center gap-2 py-2 px-5"
> >
<FaGoogleDrive className="text-xl" /> <FaGoogleDrive className="text-xl" />
Google Drive Google Drive
</a> </GradientButton>
</div> </div>
{/* Stats Cards */} {/* Stats Cards */}
@@ -123,7 +118,7 @@ const DataStatsPage: React.FC = () => {
<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>
@@ -146,70 +141,7 @@ 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">
<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>
</div> </div>
{/* Database Tech Stats Section */} {/* Database Tech Stats Section */}
@@ -228,110 +160,9 @@ const DataStatsPage: React.FC = () => {
<div className="p-8 text-red-400">{dbError}</div> <div className="p-8 text-red-400">{dbError}</div>
) : dbStats ? ( ) : dbStats ? (
<div className="p-6 space-y-10"> <div className="p-6 space-y-10">
{/* Tables */} <DbStatsTable title="Tabelas" items={dbStats.tables} showTotal />
<div> <DbStatsTable title="Views Materializadas" items={dbStats.materializedViews} />
<h3 className="text-xl font-semibold text-white mb-2">Tabelas</h3> <DbIndexesStats amount={dbStats.indexes.amount} size={dbStats.indexes.size} />
<div className="overflow-x-auto max-h-112" 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>
{dbStats.tables.map((table) => {
const name = table.name.replace(/^public\./, '');
const sizeMB = table.totalSize / 1024 / 1024;
const sizeDisplay = sizeMB > 1024
? `${(sizeMB / 1024).toFixed(2)} GB`
: `${sizeMB.toFixed(2)} MB`;
return (
<tr key={table.name} className="hover:bg-gray-800/10 transition-all duration-200">
<td className="p-3 text-white">{name}</td>
<td className="p-3 text-gray-300">{sizeDisplay}</td>
<td className="p-3 text-gray-300">{table.entries.toLocaleString()}</td>
</tr>
);
})}
{/* Total row */}
{(() => {
const totalSize = dbStats.tables.reduce((acc, t) => acc + t.totalSize, 0);
const totalEntries = dbStats.tables.reduce((acc, t) => acc + t.entries, 0);
const totalMB = totalSize / 1024 / 1024;
const totalDisplay = totalMB > 1024
? `${(totalMB / 1024).toFixed(2)} GB`
: `${totalMB.toFixed(2)} MB`;
return (
<tr className="font-bold bg-gray-900/30">
<td className="p-3 text-white">Total</td>
<td className="p-3 text-gray-300">{totalDisplay}</td>
<td className="p-3 text-gray-300">{totalEntries.toLocaleString()}</td>
</tr>
);
})()}
</tbody>
</table>
</div>
</div>
{/* Materialized Views */}
<div>
<h3 className="text-xl font-semibold text-white mb-2">Views Materializadas</h3>
<div className="overflow-x-auto max-h-96" style={{maxHeight: '28rem'}}>
<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>
{dbStats.materializedViews.map((view) => {
const name = view.name.replace(/^public\./, '');
const sizeMB = view.totalSize / 1024 / 1024;
const sizeDisplay = sizeMB > 1024
? `${(sizeMB / 1024).toFixed(2)} GB`
: `${sizeMB.toFixed(2)} MB`;
return (
<tr key={view.name} className="hover:bg-gray-800/10 transition-all duration-200">
<td className="p-3 text-white">{name}</td>
<td className="p-3 text-gray-300">{sizeDisplay}</td>
<td className="p-3 text-gray-300">{view.entries.toLocaleString()}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Indexes */}
<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">{dbStats.indexes.amount}</td>
{(() => {
const sizeMB = dbStats.indexes.size / 1024 / 1024;
const sizeDisplay = sizeMB > 1024
? `${(sizeMB / 1024).toFixed(2)} GB`
: `${sizeMB.toFixed(2)} MB`;
return <td className="p-3 text-gray-300">{sizeDisplay}</td>;
})()}
</tr>
</tbody>
</table>
</div>
</div>
</div> </div>
) : null} ) : null}
</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,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('/assets/Congresso_Nacional_hero.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).

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

@@ -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

@@ -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;
@@ -16,29 +16,29 @@ const StatisticsGraphs: React.FC<StatisticsGraphsProps> = ({
const [currentEnrichmentIndex, setCurrentEnrichmentIndex] = React.useState(0); 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>
@@ -46,19 +46,19 @@ 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>
); );
} }
@@ -79,7 +79,7 @@ const StatisticsGraphs: React.FC<StatisticsGraphsProps> = ({
}; };
return ( return (
<GlassCard> <WhiteCard>
<div className="relative"> <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">
@@ -157,12 +157,12 @@ const StatisticsGraphs: React.FC<StatisticsGraphsProps> = ({
</div> </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">
@@ -210,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';
@@ -75,7 +75,7 @@ const StatisticsPage: React.FC = () => {
<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"
> >
@@ -84,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;
}
export interface StatisticsRequestFilters {
partido?: string | null; partido?: string | null;
uf?: string | null; uf?: string | null;
ano?: number | null; ano?: number | null;
cargo?: CargoFilter; 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,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

@@ -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;