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

27
.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# Logs
frontend/logs
frontend/*.log
frontend/npm-debug.log*
frontend/yarn-debug.log*
frontend/yarn-error.log*
frontend/pnpm-debug.log*
frontend/lerna-debug.log*
frontend/node_modules
frontend/dist
frontend/dist-ssr
frontend/*.local
# Editor directories and files
frontend/.vscode/*
frontend/!.vscode/extensions.json
frontend/.idea
frontend/.DS_Store
frontend/*.suo
frontend/*.ntvs*
frontend/*.njsproj
frontend/*.sln
frontend/*.sw?
# Do not commit _pycache_ directories
backend/__pycache__/

11
README.md Normal file
View File

@@ -0,0 +1,11 @@
# Para lanzar la API en local
1. `pip install -r requirements.txt`
2. `uvicorn main:app --reload --port <puerto que quieras>`
# Para lanzar la web en modo desarrollador
1. `npm install`
2. `npm run dev`
# Para transpilar la web de React a HTML/CSS/JS Vanilla
`npm run build` <br>
La encontraréis en `dist/`

1
backend/.env Normal file
View File

@@ -0,0 +1 @@
DB_URL = "mysql+aiomysql://root:root@localhost:3306/DAD"

1
backend/config.py Normal file
View File

@@ -0,0 +1 @@
API_PREFIX = "/api/v1"

7
backend/db.py Normal file
View File

@@ -0,0 +1,7 @@
from databases import Database
import os
from dotenv import load_dotenv
load_dotenv()
DB_URL = os.getenv("DB_URL")
database = Database(DB_URL)

Binary file not shown.

View File

@@ -0,0 +1,67 @@
from fastapi import APIRouter, Body, HTTPException, Query
from fastapi import APIRouter, Body, HTTPException, Query
from typing import Optional
from pydantic import BaseModel
from sqlalchemy import select, insert
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from models.sql.Sensor import sensor_mq_data
from db import database
from models.schemas.SensorModel import SensorModel
router = APIRouter()
@router.get("")
async def get_all(
_sort: Optional[str] = Query(None, alias="_sort", description="Campo por el cual ordenar los resultados"),
_order: Optional[str] = Query("asc", description="Orden de los resultados, 'asc' o 'desc'"),
_limit: Optional[int] = Query(100, ge=1, description="Número máximo de resultados a mostrar"),
):
query = select(sensor_mq_data)
if _sort:
if _order == "desc":
query = query.order_by(getattr(sensor_mq_data.c, _sort).desc())
else:
query = query.order_by(getattr(sensor_mq_data.c, _sort))
if _limit:
query = query.limit(_limit)
async with database.transaction():
result = await database.fetch_all(query)
return result
@router.get("/sensor")
async def get_by_params(
id: Optional[int] = Query(None, alias="id", description="ID del sensor a buscar"),
sensor_type: Optional[str] = Query(None, alias="sensor_type", description="Tipo de sensor"),
min_value: Optional[float] = Query(None, alias="min_value", description="Valor mínimo del sensor"),
max_value: Optional[float] = Query(None, alias="max_value", description="Valor máximo del sensor"),
min_temp: Optional[float] = Query(None, alias="min_temp", description="Temperatura mínima"),
max_temp: Optional[float] = Query(None, alias="max_temp", description="Temperatura máxima"),
min_humidity: Optional[float] = Query(None, alias="min_humidity", description="Humedad mínima"),
max_humidity: Optional[float] = Query(None, alias="max_humidity", description="Humedad máxima"),
):
query = select(sensor_mq_data)
if id is not None:
query = query.where(sensor_mq_data.c.id == id)
if sensor_type is not None:
query = query.where(sensor_mq_data.c.sensor_type == sensor_type)
if min_value is not None:
query = query.where(sensor_mq_data.c.value >= min_value)
if max_value is not None:
query = query.where(sensor_mq_data.c.value <= max_value)
if min_temp is not None:
query = query.where(sensor_mq_data.c.temperature >= min_temp)
if max_temp is not None:
query = query.where(sensor_mq_data.c.temperature <= max_temp)
if min_humidity is not None:
query = query.where(sensor_mq_data.c.humidity >= min_humidity)
if max_humidity is not None:
query = query.where(sensor_mq_data.c.humidity <= max_humidity)
async with database.transaction():
result = await database.fetch_all(query)
return result

36
backend/main.py Normal file
View File

@@ -0,0 +1,36 @@
from fastapi import FastAPI
from contextlib import asynccontextmanager
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from config import API_PREFIX
from fastapi.openapi.utils import get_openapi
from endpoints import sensors
from db import database
@asynccontextmanager
async def lifespan(app: FastAPI):
await database.connect() # Conecta la base de datos al iniciar
yield
await database.disconnect() # Desconecta al finalizar
app = FastAPI(
title="Mi API de Calidad del Aire",
description="API para obtener mediciones de calidad del aire en Sevilla",
version="1.0.0",
docs_url=f"{API_PREFIX}/swagger", # Cambia la URL de Swagger UI
lifespan=lifespan,
openapi_url="/dad/openapi.json"
)
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
@app.get(f"{API_PREFIX}/")
async def root():
return {"message": "Has llegado a la raíz de la API"}
@app.get(f"{API_PREFIX}/openapi.json", include_in_schema=False)
async def get_openapi_json():
return JSONResponse(content=get_openapi(title="Mi API de Calidad del Aire", version="1.0.0", routes=app.routes))
app.include_router(sensors.router, prefix=f"{API_PREFIX}/sensors", tags=["sensors"])

View File

@@ -0,0 +1,12 @@
from pydantic import BaseModel
from datetime import datetime
from typing import Optional
class SensorModel(BaseModel):
id: Optional[int] # pq el modelo se usará también para INSERT
sensor_type: str
lat: float
lon: float
value: float
timestamp: Optional[datetime] = None

View File

@@ -0,0 +1,14 @@
from sqlalchemy import Column, Integer, String, Float, TIMESTAMP, Table, MetaData, func
metadata = MetaData()
sensor_mq_data = Table(
"sensor_mq_data",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("sensor_type", String(50), nullable=False), # Tipo de sensor (MQ-135, etc.)
Column("lat", Float, nullable=False), # Latitud
Column("lon", Float, nullable=False), # Longitud
Column("value", Float, nullable=False), # Valor leído del sensor
Column("timestamp", TIMESTAMP, server_default=func.current_timestamp()) # Fecha automática
)

Binary file not shown.

7
backend/requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
uvicorn
fastapi
contextlib2
dotenv
pydantic
sqlalchemy
db

14
backend/run.py Normal file
View File

@@ -0,0 +1,14 @@
import subprocess
def run_api():
try:
subprocess.run(
['uvicorn', 'main:app', '--reload', '--port', '9091'],
check=True,
cwd='./'
)
except subprocess.CalledProcessError as e:
print(f"Error al iniciar API DAD: {e.stderr}")
if __name__ == "__main__":
run_api()

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',
})