Recovered from backup
This commit is contained in:
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal 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
11
README.md
Normal 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
1
backend/.env
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DB_URL = "mysql+aiomysql://root:root@localhost:3306/DAD"
|
||||||
1
backend/config.py
Normal file
1
backend/config.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
API_PREFIX = "/api/v1"
|
||||||
7
backend/db.py
Normal file
7
backend/db.py
Normal 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)
|
||||||
BIN
backend/endpoints/__pycache__/sensors.cpython-312.pyc
Normal file
BIN
backend/endpoints/__pycache__/sensors.cpython-312.pyc
Normal file
Binary file not shown.
67
backend/endpoints/sensors.py
Normal file
67
backend/endpoints/sensors.py
Normal 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
36
backend/main.py
Normal 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"])
|
||||||
12
backend/models/schemas/SensorModel.py
Normal file
12
backend/models/schemas/SensorModel.py
Normal 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
|
||||||
BIN
backend/models/schemas/__pycache__/SensorModel.cpython-312.pyc
Normal file
BIN
backend/models/schemas/__pycache__/SensorModel.cpython-312.pyc
Normal file
Binary file not shown.
14
backend/models/sql/Sensor.py
Normal file
14
backend/models/sql/Sensor.py
Normal 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
|
||||||
|
)
|
||||||
BIN
backend/models/sql/__pycache__/Sensor.cpython-312.pyc
Normal file
BIN
backend/models/sql/__pycache__/Sensor.cpython-312.pyc
Normal file
Binary file not shown.
7
backend/requirements.txt
Normal file
7
backend/requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
uvicorn
|
||||||
|
fastapi
|
||||||
|
contextlib2
|
||||||
|
dotenv
|
||||||
|
pydantic
|
||||||
|
sqlalchemy
|
||||||
|
db
|
||||||
14
backend/run.py
Normal file
14
backend/run.py
Normal 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
38
frontend/eslint.config.js
Normal 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
23
frontend/index.html
Normal 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
4690
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
frontend/package.json
Normal file
34
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
75
frontend/public/config/settings.json
Normal file
75
frontend/public/config/settings.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
frontend/public/images/favicon.ico
Normal file
BIN
frontend/public/images/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
BIN
frontend/public/images/logo.png
Normal file
BIN
frontend/public/images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 412 KiB |
20
frontend/src/components/App.jsx
Normal file
20
frontend/src/components/App.jsx
Normal 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;
|
||||||
55
frontend/src/components/Card.jsx
Normal file
55
frontend/src/components/Card.jsx
Normal 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;
|
||||||
27
frontend/src/components/CardContainer.jsx
Normal file
27
frontend/src/components/CardContainer.jsx
Normal 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;
|
||||||
15
frontend/src/components/Dashboard.jsx
Normal file
15
frontend/src/components/Dashboard.jsx
Normal 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;
|
||||||
22
frontend/src/components/Header.jsx
Normal file
22
frontend/src/components/Header.jsx
Normal 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;
|
||||||
83
frontend/src/components/HistoryCharts.jsx
Normal file
83
frontend/src/components/HistoryCharts.jsx
Normal 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;
|
||||||
86
frontend/src/components/PollutionMap.jsx
Normal file
86
frontend/src/components/PollutionMap.jsx
Normal 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/m³</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='© 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;
|
||||||
48
frontend/src/components/SummaryCards.jsx
Normal file
48
frontend/src/components/SummaryCards.jsx
Normal 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;
|
||||||
12
frontend/src/components/ThemeButton.jsx
Normal file
12
frontend/src/components/ThemeButton.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
frontend/src/contexts/ConfigContext.jsx
Normal file
39
frontend/src/contexts/ConfigContext.jsx
Normal 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);
|
||||||
40
frontend/src/contexts/DataContext.jsx
Normal file
40
frontend/src/contexts/DataContext.jsx
Normal 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);
|
||||||
34
frontend/src/contexts/ThemeContext.jsx
Normal file
34
frontend/src/contexts/ThemeContext.jsx
Normal 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
15
frontend/src/css/App.css
Normal 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
56
frontend/src/css/Card.css
Normal 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;
|
||||||
|
}
|
||||||
40
frontend/src/css/Header.css
Normal file
40
frontend/src/css/Header.css
Normal 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; }
|
||||||
|
}
|
||||||
0
frontend/src/css/HistoryCharts.css
Normal file
0
frontend/src/css/HistoryCharts.css
Normal file
0
frontend/src/css/Map.css
Normal file
0
frontend/src/css/Map.css
Normal file
22
frontend/src/css/ThemeButton.css
Normal file
22
frontend/src/css/ThemeButton.css
Normal 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);
|
||||||
|
}
|
||||||
54
frontend/src/css/index.css
Normal file
54
frontend/src/css/index.css
Normal 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
10
frontend/src/main.jsx
Normal 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>,
|
||||||
|
)
|
||||||
23
frontend/src/pages/Home.jsx
Normal file
23
frontend/src/pages/Home.jsx
Normal 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
20
frontend/vite.config.js
Normal 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',
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user