1
0

Recovered from backup

This commit is contained in:
Jose
2025-02-26 19:42:35 +01:00
commit cf9d5e71fe
43 changed files with 5778 additions and 0 deletions

38
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,38 @@
import js from '@eslint/js'
import globals from 'globals'
import react from 'eslint-plugin-react'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
export default [
{ ignores: ['dist'] },
{
files: ['**/*.{js,jsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
settings: { react: { version: '18.3' } },
plugins: {
react,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
...reactHooks.configs.recommended.rules,
'react/jsx-no-target-blank': 'off',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
]

23
frontend/index.html Normal file
View File

@@ -0,0 +1,23 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta property="og:title" content="ContaminUS" />
<meta property="og:description" content="Midiendo la calidad del aire y las calles en Sevilla 🌿🚛" />
<meta property="og:image" content="https://contaminus.miarma.net/logo.png" />
<meta property="og:url" content="https://contaminus.miarma.net/" />
<meta property="og:type" content="website" />
<meta property="og:locale" content="es_ES" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="ContaminUS" />
<meta name="twitter:description" content="Midiendo la calidad del aire y las calles en Sevilla 🌿🚛" />
<meta name="twitter:image" content="https://contaminus.miarma.net/logo.png" />
<link rel="shortcut icon" href="/images/favicon.ico" type="image/x-icon">
<title>ContaminUS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

4690
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
frontend/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "mi-app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview",
"deploy": "vite build && scp -r ./dist/* jomaa@192.168.1.200:/var/www/contaminus/"
},
"dependencies": {
"bootstrap": "^5.3.3",
"chart.js": "^4.4.8",
"leaflet": "^1.9.4",
"react": "^19.0.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.0.0",
"react-leaflet": "^5.0.0"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.19.0",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.18",
"globals": "^15.14.0",
"vite": "^6.1.0"
}
}

View File

@@ -0,0 +1,75 @@
{
"userConfig": {
"city": [
37.38283,
-5.97317
]
},
"appConfig": {
"historyChartConfig": {
"timeLabels": [
"08:00",
"09:00",
"10:00",
"11:00",
"12:00",
"13:00",
"14:00"
],
"chartOptionsDark": {
"responsive": true,
"maintainAspectRatio": false,
"scales": {
"x": {
"grid": {
"color": "rgba(255, 255, 255, 0.1)"
},
"ticks": {
"color": "#E0E0E0"
}
},
"y": {
"grid": {
"color": "rgba(255, 255, 255, 0.1)"
},
"ticks": {
"color": "#E0E0E0"
}
}
},
"plugins": {
"legend": {
"display": false
}
}
},
"chartOptionsLight": {
"responsive": true,
"maintainAspectRatio": false,
"scales": {
"x": {
"grid": {
"color": "rgba(0, 0, 0, 0.1)"
},
"ticks": {
"color": "#333"
}
},
"y": {
"grid": {
"color": "rgba(0, 0, 0, 0.1)"
},
"ticks": {
"color": "#333"
}
}
},
"plugins": {
"legend": {
"display": false
}
}
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

View File

@@ -0,0 +1,20 @@
import '../css/App.css'
import 'leaflet/dist/leaflet.css'
import 'bootstrap/dist/css/bootstrap.min.css'
import 'bootstrap/dist/js/bootstrap.bundle.min.js'
import { ThemeProvider } from '../contexts/ThemeContext.jsx'
import Home from '../pages/Home.jsx'
const App = () => {
return (
<>
<ThemeProvider>
<Home />
</ThemeProvider>
</>
);
}
export default App;

View File

@@ -0,0 +1,55 @@
import PropTypes from "prop-types";
import { useState, useEffect, useRef } from "react";
import "../css/Card.css";
import { useTheme } from "../contexts/ThemeContext";
const Card = ({ title, status, children, styleMode, className }) => {
const cardRef = useRef(null);
const [shortTitle, setShortTitle] = useState(title);
const { theme } = useTheme();
useEffect(() => {
const checkSize = () => {
if (cardRef.current) {
const width = cardRef.current.offsetWidth;
if (width < 300 && title.length > 15) {
setShortTitle(title.slice(0, 10) + ".");
} else {
setShortTitle(title);
}
}
};
checkSize();
window.addEventListener("resize", checkSize);
return () => window.removeEventListener("resize", checkSize);
}, [title]);
return (
<div
ref={cardRef}
className={styleMode === "override" ? `${className}` :
`col-xl-3 col-sm-6 d-flex flex-column align-items-center p-3 card-container ${className}`}
>
<div className={`card p-3 w-100 ${theme}`}>
<h3 className="text-center">{shortTitle}</h3>
<div className="card-content">{children}</div>
{status ? <span className="status text-center mt-2">{status}</span> : null}
</div>
</div>
);
};
Card.propTypes = {
title: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
styleMode: PropTypes.oneOf(["override", ""]), // Nueva prop opcional
className: PropTypes.string, // Nueva prop opcional
};
Card.defaultProps = {
styleMode: "",
};
export default Card;

View File

@@ -0,0 +1,27 @@
import Card from "./Card.jsx";
import PropTypes from "prop-types";
const CardContainer = ({ cards, className }) => {
return (
<div className={`row justify-content-center g-0 ${className}`}>
{cards.map((card, index) => (
<Card key={index} title={card.title} status={card.status} styleMode={card.styleMode} className={card.className}>
<p className="card-text text-center">{card.content}</p>
</Card>
))}
</div>
);
};
CardContainer.propTypes = {
cards: PropTypes.arrayOf(
PropTypes.shape({
title: PropTypes.string.isRequired,
content: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
})
).isRequired,
className: PropTypes.string,
};
export default CardContainer;

View File

@@ -0,0 +1,15 @@
import PropTypes from 'prop-types';
const Dashboard = (props) => {
return (
<main className='container justify-content-center'>
{props.children}
</main>
);
}
Dashboard.propTypes = {
children: PropTypes.node
}
export default Dashboard;

View File

@@ -0,0 +1,22 @@
import PropTypes from 'prop-types';
import '../css/Header.css';
import { useTheme } from "../contexts/ThemeContext";
const Header = (props) => {
const { theme } = useTheme();
return (
<header className={`justify-content-center text-center mb-4 ${theme}`}>
<h1>{props.title}</h1>
<p className='subtitle'>{props.subtitle}</p>
</header>
);
}
Header.propTypes = {
title: PropTypes.string.isRequired,
subtitle: PropTypes.string
}
export default Header;

View File

@@ -0,0 +1,83 @@
import { Line } from "react-chartjs-2";
import { Chart as ChartJS, LineElement, PointElement, LinearScale, CategoryScale, Filler } from "chart.js";
import CardContainer from "./CardContainer";
import "../css/HistoryCharts.css";
import PropTypes from "prop-types";
import { useTheme } from "../contexts/ThemeContext.jsx";
import { DataProvider, useData } from "../contexts/DataContext.jsx";
import { ConfigProvider, useConfig } from "../contexts/ConfigContext.jsx";
ChartJS.register(LineElement, PointElement, LinearScale, CategoryScale, Filler);
const HistoryCharts = () => {
return (
<DataProvider apiUrl="https://contaminus.miarma.net/api/v1/sensors">
<ConfigProvider>
<HistoryChartsContent />
</ConfigProvider>
</DataProvider>
);
};
const HistoryChartsContent = () => {
const { config } = useConfig();
const { data, loading } = useData();
const { theme } = useTheme();
const optionsDark = config?.appConfig?.historyChartConfig?.chartOptionsDark ?? {};
const optionsLight = config?.appConfig?.historyChartConfig?.chartOptionsLight ?? {};
const options = theme === "dark" ? optionsDark : optionsLight;
const timeLabels = config?.appConfig?.historyChartConfig?.timeLabels ?? [];
if (loading) return <p>Cargando datos...</p>;
const temperatureData = [];
const humidityData = [];
const pollutionLevels = [];
data?.forEach(sensor => {
if (sensor.value != null) {
if (sensor.sensor_type === "MQ-135") {
pollutionLevels.push(sensor.value);
} else if (sensor.sensor_type === "DHT-11") {
temperatureData.push(sensor.value);
humidityData.push(sensor.value);
}
}
});
const historyData = [
{ title: "🌡️ Temperatura", data: temperatureData.length ? temperatureData : [0], borderColor: "#00FF85", backgroundColor: "rgba(0, 255, 133, 0.2)" },
{ title: "💧 Humedad", data: humidityData.length ? humidityData : [0], borderColor: "#00D4FF", backgroundColor: "rgba(0, 212, 255, 0.2)" },
{ title: "☁️ Contaminación", data: pollutionLevels.length ? pollutionLevels : [0], borderColor: "#FFA500", backgroundColor: "rgba(255, 165, 0, 0.2)" }
];
return (
<CardContainer
cards={historyData.map(({ title, data, borderColor, backgroundColor }) => ({
title,
content: (
<Line
data={{
labels: timeLabels,
datasets: [{ data, borderColor, backgroundColor, fill: true, tension: 0.4 }]
}}
options={options}
/>
),
styleMode: "override",
className: "col-lg-4 col-xxs-12 d-flex flex-column align-items-center p-3 card-container"
}))}
className=""
/>
);
};
HistoryChartsContent.propTypes = {
options: PropTypes.object,
timeLabels: PropTypes.array,
data: PropTypes.array
};
export default HistoryCharts;

View File

@@ -0,0 +1,86 @@
import { MapContainer, TileLayer, Circle, Popup } from 'react-leaflet';
import { ConfigProvider } from '../contexts/ConfigContext.jsx';
import { useConfig } from '../contexts/ConfigContext.jsx';
import { DataProvider } from '../contexts/DataContext.jsx';
import { useData } from '../contexts/DataContext.jsx';
const PollutionCircles = ({ data }) => {
return data.map(({ lat, lng, level }, index) => {
const baseColor = level < 20 ? '#00FF85' : level < 60 ? '#FFA500' : '#FF0000';
const steps = 4;
const maxRadius = 400;
const stepSize = maxRadius / steps;
return (
<div key={index}>
{[...Array(steps)].map((_, i) => {
const radius = stepSize * (i + 1);
const opacity = 0.6 * ((i + 1) / steps);
return (
<Circle
key={`${index}-${i}`}
center={[lat, lng]}
pathOptions={{ color: baseColor, fillColor: baseColor, fillOpacity: opacity, weight: 1 }}
radius={radius}
/>
);
})}
<Circle
center={[lat, lng]}
pathOptions={{ color: baseColor, fillColor: baseColor, fillOpacity: 0.8, weight: 2 }}
radius={50}
>
<Popup>Contaminación: {level} µg/</Popup>
</Circle>
</div>
);
});
};
const PollutionMap = () => {
return (
<DataProvider apiUrl="https://contaminus.miarma.net/api/v1/sensors">
<ConfigProvider>
<PollutionMapContent />
</ConfigProvider>
</DataProvider>
);
};
const PollutionMapContent = () => {
const { config } = useConfig();
const SEVILLA = config?.userConfig.city;
const { data, loading } = useData();
if (loading) return <p>Cargando datos...</p>;
const pollutionData = data.map((sensor) => ({
lat: sensor.lat,
lng: sensor.lon,
level: sensor.value
}));
return (
<div className='p-3'>
<MapContainer center={SEVILLA} zoom={13} scrollWheelZoom={false} style={mapStyles}>
<TileLayer
attribution='&copy; Contribuidores de <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<PollutionCircles data={pollutionData} />
</MapContainer>
</div>
);
}
const mapStyles = {
height: '500px',
width: '100%',
borderRadius: '20px'
};
export default PollutionMap;

View File

@@ -0,0 +1,48 @@
import PropTypes from 'prop-types';
import CardContainer from './CardContainer';
import { DataProvider } from '../contexts/DataContext';
import { useData } from '../contexts/DataContext';
const SummaryCards = () => {
return (
<DataProvider apiUrl="https://contaminus.miarma.net/api/v1/sensors?_sort=timestamp&_order=desc">
<SummaryCardsContent />
</DataProvider>
);
}
const SummaryCardsContent = () => {
const { data } = useData();
const CardsData = [
{ id: 1, title: "🌡️ Temperatura", content: "N/A", status: "Esperando datos..." },
{ id: 2, title: "💧 Humedad", content: "N/A", status: "Esperando datos..." },
{ id: 3, title: "☁️ Contaminación", content: "N/A", status: "Esperando datos..." },
{ id: 4, title: "🛤️ Carretera", content: "N/A", status: "Esperando datos..." }
];
if (data) {
data.forEach((sensor) => {
if (sensor.sensor_type === "MQ-135") {
CardsData[2].content = `${sensor.value} µg/m³`;
CardsData[2].status = sensor.value > 100 ? "Alta contaminación 😷" : "Aire moderado 🌤️";
} else if (sensor.sensor_type === "DHT-11") {
CardsData[1].content = `${sensor.humidity}%`;
CardsData[1].status = sensor.humidity > 70 ? "Humedad alta 🌧️" : "Nivel normal 🌤️";
CardsData[0].content = `${sensor.temperature}°C`;
CardsData[0].status = sensor.temperature > 30 ? "Calor intenso ☀️" : "Clima agradable 🌤️";
}
});
}
return (
<CardContainer cards={CardsData} />
);
}
SummaryCards.propTypes = {
data: PropTypes.array
};
export default SummaryCards;

View File

@@ -0,0 +1,12 @@
import { useTheme } from "../contexts/ThemeContext.jsx";
import "../css/ThemeButton.css";
export default function ThemeButton() {
const { theme, toggleTheme } = useTheme();
return (
<button className="theme-toggle" onClick={toggleTheme}>
{theme === "dark" ? "☀️" : "🌙"}
</button>
);
}

View File

@@ -0,0 +1,39 @@
import { createContext, useContext, useState, useEffect } from "react";
import PropTypes from "prop-types";
const ConfigContext = createContext();
export const ConfigProvider = ({ children }) => {
const [config, setConfig] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchConfig = async () => {
try {
const response = await fetch("/config/settings.json");
if (!response.ok) throw new Error("Error al cargar settings.json");
const json = await response.json();
setConfig(json);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchConfig();
}, []);
return (
<ConfigContext.Provider value={{ config, loading, error }}>
{children}
</ConfigContext.Provider>
);
};
ConfigProvider.propTypes = {
children: PropTypes.node.isRequired,
};
export const useConfig = () => useContext(ConfigContext);

View File

@@ -0,0 +1,40 @@
import { createContext, useContext, useState, useEffect } from "react";
import PropTypes from "prop-types";
const DataContext = createContext();
export const DataProvider = ({ children, apiUrl }) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch(apiUrl);
if (!response.ok) throw new Error("Error al obtener datos");
const result = await response.json();
setData(result);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchData();
}, [apiUrl]);
return (
<DataContext.Provider value={{ data, loading, error }}>
{children}
</DataContext.Provider>
);
};
DataProvider.propTypes = {
children: PropTypes.node.isRequired,
apiUrl: PropTypes.string.isRequired,
};
export const useData = () => useContext(DataContext);

View File

@@ -0,0 +1,34 @@
import { createContext, useContext, useEffect, useState } from "react";
import PropTypes from "prop-types";
const ThemeContext = createContext();
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState(() => {
return localStorage.getItem("theme") || "light";
});
useEffect(() => {
document.body.classList.remove("light", "dark");
document.body.classList.add(theme);
localStorage.setItem("theme", theme);
}, [theme]);
const toggleTheme = () => {
setTheme(prevTheme => (prevTheme === "light" ? "dark" : "light"));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
ThemeProvider.propTypes = {
children: PropTypes.node.isRequired,
};
export function useTheme() {
return useContext(ThemeContext);
}

15
frontend/src/css/App.css Normal file
View File

@@ -0,0 +1,15 @@
body {
font-family: 'Poppins', sans-serif;
padding: 20px;
min-height: 100vh;
}
body.light {
background: white;
color: black;
}
body.dark {
background: linear-gradient(135deg, var(--gradient-primary), var(--gradient-secondary));
color: white;
}

56
frontend/src/css/Card.css Normal file
View File

@@ -0,0 +1,56 @@
.card {
border-radius: 20px;
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3);
transition: transform 0.3s ease, box-shadow 0.3s ease;
border: 2px solid var(--primary-color);
}
.card.light {
background: linear-gradient(145deg, #eeeeee, #dadada);
}
.card.light > div.card-content > p.card-text {
color: black;
}
.card.light > span.status {
background: #E0E0E0;
}
.card.dark {
background: linear-gradient(145deg, var(--card-gradient-primary), var(--card-gradient-secondary));
}
.card.dark > div.card-content > p.card-text {
color: white;
}
.card.dark > span.status {
background: #505050;
}
.card:hover {
transform: translateY(-8px) scale(1.02);
box-shadow: 0 10px 20px var(--box-shadow);
}
.card>h3 {
font-size: 1.3em;
color: var(--primary-color);
text-transform: uppercase;
letter-spacing: 1px;
}
p.card-text {
font-size: 2.2em;
font-weight: 600;
}
.card>span.status {
font-size: 0.9em;
color: #A0A0A0;
padding: 5px 10px;
background: var(--card-background);
border-radius: 20px;
display: inline-block;
}

View File

@@ -0,0 +1,40 @@
header > h1 {
font-size: 2.8em;
font-weight: 600;
letter-spacing: 1px;
font-family: 'Times New Roman', Times, serif;
font-stretch: condensed;
}
header.light > h1 {
color: black;
}
header.dark > h1 {
color: white;
}
header.light > p.subtitle {
color: #606060;
}
header.dark > p.subtitle {
color: #B0B0B0;
}
header > h1::after {
font-size: 1.2em;
content: 'US';
color: var(--primary-color);
}
header > .subtitle {
font-size: 1.2em;
color: #B0B0B0;
animation: fadeIn 2s ease-in-out;
}
@keyframes fadeIn {
0% { opacity: 0; }
100% { opacity: 1; }
}

View File

0
frontend/src/css/Map.css Normal file
View File

View File

@@ -0,0 +1,22 @@
.theme-toggle {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
border: none;
border-radius: 50%;
width: 50px;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--primary-color);
color: white;
cursor: pointer;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: background-color 0.3s, transform 0.3s;
}
.theme-toggle:hover {
background-color: var(--secondary-color);
}

View File

@@ -0,0 +1,54 @@
:root {
--primary-color: #be0f2e;
--secondary-color: #a8223a;
--text-shadow: #be0f2e80;
--box-shadow: #be0f2e33;
--gradient-primary: #1A1A1A;
--gradient-secondary: #2A2A2A;
--card-background: #be0f2e1a;
--card-gradient-primary: #252525;
--card-gradient-secondary: #353535;
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/poppins/v22/pxiEyp8kv8JHgFVrJJnecmNE.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/poppins/v22/pxiEyp8kv8JHgFVrJJfecg.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(https://fonts.gstatic.com/s/poppins/v22/pxiByp8kv8JHgFVrLEj6Z1JlFc-K.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(https://fonts.gstatic.com/s/poppins/v22/pxiByp8kv8JHgFVrLEj6Z1xlFQ.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

10
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './css/index.css'
import App from './components/App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,23 @@
import Header from '../components/Header.jsx'
import Dashboard from '../components/Dashboard.jsx'
import PollutionMap from '../components/PollutionMap.jsx'
import HistoryCharts from '../components/HistoryCharts.jsx'
import ThemeButton from '../components/ThemeButton.jsx'
import SummaryCards from '../components/SummaryCards.jsx'
const Home = () => {
return (
<>
<Header title='Contamin' subtitle='Midiendo la calidad del aire y las calles en Sevilla 🌿🚛' />
<Dashboard>
<SummaryCards />
<PollutionMap />
{/* */}
<HistoryCharts />
</Dashboard>
<ThemeButton />
</>
)
}
export default Home;

20
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks: {
"react-vendors": ["react", "react-dom"],
"leaflet": ["leaflet"],
"chartjs": ["chart.js"]
}
}
},
outDir: 'dist',
},
publicDir: 'public',
})