feat: initialize microservice architecture with auth, api, realtime, copernicus, ml, and console modules
This commit is contained in:
2
console/.dockerignore
Normal file
2
console/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
13
console/Dockerfile
Normal file
13
console/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
|
||||
COPY src ./src
|
||||
|
||||
EXPOSE 3004
|
||||
|
||||
CMD ["node", "src/index.js"]
|
||||
1127
console/package-lock.json
generated
Normal file
1127
console/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
console/package.json
Normal file
19
console/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "meb-console-service",
|
||||
"version": "1.4.0",
|
||||
"description": "Console service for meb",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js",
|
||||
"generate-docs": "node -e \"console.log(JSON.stringify(require('./swagger-specs-logic')))\" > openapi.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"cookie-parser": "^1.4.7",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"ioredis": "^5.10.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"nunjucks": "^3.2.4"
|
||||
}
|
||||
}
|
||||
92
console/src/index.js
Normal file
92
console/src/index.js
Normal file
@@ -0,0 +1,92 @@
|
||||
const express = require('express');
|
||||
const nunjucks = require('nunjucks');
|
||||
const path = require('path');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const parser = require('cookie-parser');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT;
|
||||
|
||||
const version = process.env.VERSION;
|
||||
const vBuild = process.env.VERSION_BUILD;
|
||||
const vState = process.env.VERSION_STATE;
|
||||
|
||||
app.use(express.json());
|
||||
app.use(parser());
|
||||
|
||||
// Set up static files serving
|
||||
const staticFolder = path.join(__dirname, 'static');
|
||||
app.use('/static', express.static(staticFolder));
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
res.redirect(301, "/dashboard")
|
||||
});
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: "ok",
|
||||
service: "console",
|
||||
version: version,
|
||||
build_number: vBuild,
|
||||
version_state: vState
|
||||
});
|
||||
});
|
||||
|
||||
const pagesFolder = path.join(__dirname, 'pages');
|
||||
nunjucks.configure(pagesFolder, {
|
||||
autoescape: true,
|
||||
express: app,
|
||||
noCache: true,
|
||||
watch: false
|
||||
})
|
||||
app.set('views', pagesFolder);
|
||||
app.set('view engine', 'html');
|
||||
|
||||
const renderPage = (page, extra = {}) => (req, res) => {
|
||||
res.render(page, {current_path: req.path, ...extra})
|
||||
}
|
||||
|
||||
// Middleware di autenticazione per le pagine
|
||||
app.use((req, res, next) => {
|
||||
if (req.path === '/health' || req.path.startsWith('/static')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const authBase = process.env.AUTH_LOGIN_URL || 'http://localhost:3006/login';
|
||||
|
||||
// Costruisci l'URL di redirect-back: protocollo + host + path originale
|
||||
const proto = req.protocol;
|
||||
const host = req.get('host');
|
||||
const redirectBack = `${proto}://${host}${req.originalUrl}`;
|
||||
const loginUrl = `${authBase}?redirect=${encodeURIComponent(redirectBack)}`;
|
||||
|
||||
const token = req.cookies && req.cookies.auth_token;
|
||||
|
||||
if (!token) {
|
||||
return res.redirect(loginUrl);
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
req.user = payload;
|
||||
next();
|
||||
} catch (err) {
|
||||
const clearOptions = { httpOnly: true, sameSite: 'lax' };
|
||||
if (process.env.COOKIE_DOMAIN) {
|
||||
clearOptions.domain = process.env.COOKIE_DOMAIN;
|
||||
}
|
||||
res.clearCookie('auth_token', clearOptions);
|
||||
return res.redirect(loginUrl);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/dashboard', renderPage('dashboard'));
|
||||
app.get('/live', renderPage('live', {
|
||||
realtimeUrl: process.env.REALTIME_URL || 'http://localhost:3002',
|
||||
realtimeWsUrl: process.env.REALTIME_WS_URL || 'ws://localhost:3002'
|
||||
}));
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Started on port ${PORT}`);
|
||||
});
|
||||
55
console/src/pages/dashboard.html
Normal file
55
console/src/pages/dashboard.html
Normal file
@@ -0,0 +1,55 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<head>
|
||||
<link rel="stylesheet" href="/static/styles/style.css">
|
||||
<link rel="stylesheet" href="/static/styles/dashboard.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="contnent">
|
||||
<div class="header">
|
||||
<h1></h1>
|
||||
|
||||
<div class="profile">
|
||||
<p id="username">username</p>
|
||||
<button>Impostazioni</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-panel">
|
||||
<div class="icon">🚤</div>
|
||||
<h3>Benvenuto nella MEB Console</h3>
|
||||
<p>Usa uno dei tool a disposizione per visualizzare i dati dalla barca in tempo reale, creare nuovi dataset condivisi e </p>
|
||||
</div>
|
||||
|
||||
<section class="grid">
|
||||
|
||||
<a class="card standalone" href="/live" title="Live">
|
||||
<div>
|
||||
<h3>Live</h3>
|
||||
<p>Visualizza i dati dei sensori sulla barca in tempo reale.</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="/forecasts" title="Previsioni">
|
||||
<div>
|
||||
<h3>Previsioni</h3>
|
||||
<p>Visualizza le condizioni meteo attuali e le previsioni future.</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="/forecasts" title="Previsioni">
|
||||
<div>
|
||||
<h3>Previsioni</h3>
|
||||
<p>Visualizza le condizioni meteo attuali e le previsioni future.</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
|
||||
<script>
|
||||
|
||||
</script>
|
||||
707
console/src/pages/live.html
Normal file
707
console/src/pages/live.html
Normal file
@@ -0,0 +1,707 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<head>
|
||||
<link rel="stylesheet" href="/static/styles/live.css">
|
||||
<link rel="stylesheet" href="/static/styles/style.css">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<style>
|
||||
.map-container { display: none; }
|
||||
.expanded-chart-container { display: none; }
|
||||
.comparison-sidebar { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<!-- Session Picker Popup -->
|
||||
<div class="session-overlay" id="sessionOverlay">
|
||||
<div class="session-popup">
|
||||
<h2>Sessioni Attive</h2>
|
||||
<p class="popup-subtitle">Seleziona un sensore per visualizzare i dati in tempo reale</p>
|
||||
<div class="session-list" id="sessionList">
|
||||
<div class="session-loading">Caricamento sessioni...</div>
|
||||
</div>
|
||||
<button id="refreshSessionsBtn">Aggiorna</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="content" id="mainContent" style="display: none;">
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>Live</h1>
|
||||
<p class="last-update" id="lastUpdateText">In attesa di dati...</p>
|
||||
</div>
|
||||
<div class="profile">
|
||||
<p id="sensorName">Sensore</p>
|
||||
<button id="changeSessionBtn">Cambia sessione</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-panel" style="margin-bottom:20px;">
|
||||
<h3 id="sessionInfoTitle"></h3>
|
||||
<p id="sessionInfoDesc">Visualizza i dati dei sensori sulla barca in tempo reale.</p>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Layout (Main + Sidebar) -->
|
||||
<div class="dashboard-layout">
|
||||
<div class="main-column">
|
||||
<!-- Sticky Area (Mappa + Expanded Chart) -->
|
||||
<div class="sticky-area">
|
||||
<!-- Mappa -->
|
||||
<div class="map-container" id="mapContainer">
|
||||
<div id="liveMap"></div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded Chart (Mostrato cliccando Focus/Lente) -->
|
||||
<div class="expanded-chart-container" id="expandedChartContainer">
|
||||
<div class="expanded-chart-header">
|
||||
<h3 id="expChartTitle">Grafico Dettagliato</h3>
|
||||
<button class="close-expanded-btn" id="closeExpChartBtn">×</button>
|
||||
</div>
|
||||
<div class="expanded-chart-body">
|
||||
<canvas id="expandedChartCanvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Griglia Card (Ibride Dato + Minigrafico) -->
|
||||
<div class="grid" id="dataGrid"></div>
|
||||
</div>
|
||||
|
||||
<!-- Confronto Sidebar -->
|
||||
<div class="comparison-sidebar" id="compSidebar">
|
||||
<div class="comp-header">
|
||||
<h3>Modalità Confronto</h3>
|
||||
<button class="comp-close" id="compCloseBtn">×</button>
|
||||
</div>
|
||||
<div class="comp-list" id="compLabelsList">
|
||||
<!-- Selezionati finiscono qui come bottoncini (pill) -->
|
||||
<div style="font-size: 0.8rem; color: #94a3b8; padding: 10px;">Clicca sulle card per aggiungere dati al confronto.</div>
|
||||
</div>
|
||||
<div class="comp-chart-area">
|
||||
<canvas id="compChartCanvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Bottom Bar -->
|
||||
<div class="bottom-bar" id="bottomBar" style="display: none;">
|
||||
|
||||
<div class="search-field">
|
||||
<svg width="16" height="16" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M11.0835 11.0834L8.57516 8.57504M9.91683 5.25004C9.91683 7.82737 7.82749 9.91671 5.25016 9.91671C2.67283 9.91671 0.583496 7.82737 0.583496 5.25004C0.583496 2.67271 2.67283 0.583374 5.25016 0.583374C7.82749 0.583374 9.91683 2.67271 9.91683 5.25004Z"
|
||||
stroke="var(--text-secondary)" stroke-width="1.16667" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<input type="text" placeholder="Cerca" id="searchInput">
|
||||
</div>
|
||||
|
||||
<div class="filter" id="categoryFilter">
|
||||
<button class="active" data-cat="all">Tutto</button>
|
||||
<button data-cat="weather">Meteo</button>
|
||||
<button data-cat="navigation">Navigazione</button>
|
||||
<button data-cat="engine">Motore</button>
|
||||
</div>
|
||||
|
||||
<!-- Chart fill toggle (line vs area) -->
|
||||
<div class="filter boxed" id="chartFillToggle">
|
||||
<button class="active" data-fill="false" title="Solo linea">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M0.583496 0.583374V9.91671C0.583496 10.2261 0.706412 10.5229 0.925205 10.7417C1.144 10.9605 1.44074 11.0834 1.75016 11.0834H11.0835M9.91683 4.08337L7.00016 7.00004L4.66683 4.66671L2.91683 6.41671"
|
||||
stroke="currentColor" stroke-width="1.16667" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<button data-fill="true" title="Area colorata">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M0.583496 0.583374V9.91671C0.583496 10.2261 0.706412 10.5229 0.925205 10.7417C1.144 10.9605 1.44074 11.0834 1.75016 11.0834H11.0835M2.91683 5.37079C2.91685 5.29358 2.94747 5.21954 3.002 5.16487L4.16866 3.99821C4.19576 3.97105 4.22794 3.9495 4.26338 3.93479C4.29881 3.92009 4.3368 3.91252 4.37516 3.91252C4.41353 3.91252 4.45151 3.92009 4.48695 3.93479C4.52238 3.9495 4.55457 3.97105 4.58166 3.99821L6.502 5.91854C6.52909 5.9457 6.56128 5.96725 6.59671 5.98196C6.63214 5.99666 6.67013 6.00423 6.7085 6.00423C6.74686 6.00423 6.78485 5.99666 6.82028 5.98196C6.85572 5.96725 6.8879 5.9457 6.915 5.91854L9.41866 3.41487C9.45942 3.37401 9.51138 3.34616 9.56798 3.33485C9.62457 3.32353 9.68324 3.32926 9.73658 3.35131C9.78991 3.37335 9.83551 3.41073 9.8676 3.4587C9.89968 3.50667 9.91682 3.56308 9.91683 3.62079V8.16671C9.91683 8.32142 9.85537 8.46979 9.74598 8.57919C9.63658 8.68858 9.48821 8.75004 9.3335 8.75004H3.50016C3.34545 8.75004 3.19708 8.68858 3.08768 8.57919C2.97829 8.46979 2.91683 8.32142 2.91683 8.16671V5.37079Z"
|
||||
stroke="currentColor" stroke-width="1.16667" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Comparison toggle -->
|
||||
<div class="filter boxed" id="compToggleWrap">
|
||||
<button id="compToggleBtn" title="Mostra/nascondi confronto">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 12L5 4L9 9L15 2" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
<path d="M1 14L5 8L9 11L15 5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"
|
||||
stroke-linejoin="round" opacity="0.4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Map toggle -->
|
||||
<div class="filter boxed" id="mapToggleWrap">
|
||||
<button id="mapToggleBtn" title="Mostra/nascondi mappa">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 3.5L5.5 1.5L10.5 3.5L15 1.5V12.5L10.5 14.5L5.5 12.5L1 14.5V3.5Z"
|
||||
stroke="currentColor" stroke-width="1.2" stroke-linejoin="round" />
|
||||
<path d="M5.5 1.5V12.5M10.5 3.5V14.5" stroke="currentColor" stroke-width="1.2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bar-sep"></div>
|
||||
|
||||
<button id="downloadBtn" title="Scarica CSV">Scarica</button>
|
||||
</div>
|
||||
|
||||
<div class="map-bar" id="mapSecondaryBar">
|
||||
<div class="filter" id="mapZoomSection">
|
||||
<button class="active" data-zoom="1">1x</button>
|
||||
<button data-zoom="5">5x</button>
|
||||
<button data-zoom="10">10x</button>
|
||||
<button data-zoom="50">50x</button>
|
||||
</div>
|
||||
|
||||
<div class="bar-sep"></div>
|
||||
|
||||
<div class="map-toggles-group" id="mapLayersSection">
|
||||
<div class="bb-toggle on" data-layer="heading" title="Heading barca">
|
||||
<div class="toggle-pip"></div>
|
||||
<span>HDG</span>
|
||||
</div>
|
||||
<div class="bb-toggle on" data-layer="wind" title="Direzione vento">
|
||||
<div class="toggle-pip"></div>
|
||||
<span>Vento</span>
|
||||
</div>
|
||||
<div class="bb-toggle on" data-layer="waves" title="Direzione onde">
|
||||
<div class="toggle-pip"></div>
|
||||
<span>Onde</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
<script>
|
||||
// --- Config ---
|
||||
const REALTIME_URL = '{{ realtimeUrl }}';
|
||||
const REALTIME_WS_URL = '{{ realtimeWsUrl }}';
|
||||
|
||||
// --- State ---
|
||||
let ws = null;
|
||||
let currentSensorId = null;
|
||||
let sessionStartTime = null;
|
||||
let lastDataTime = 0;
|
||||
let lastUpdateInterval = null;
|
||||
|
||||
let liveData = {};
|
||||
let miniCharts = {}; // key -> Chart
|
||||
let expChart = null; // Ingrandimento chart
|
||||
let compChart = null; // Confronto chart
|
||||
let expActiveField = null;
|
||||
|
||||
let leafletMap = null;
|
||||
let boatMarker = null;
|
||||
let headingLayer = null;
|
||||
let windLayer = null;
|
||||
let wavesLayer = null;
|
||||
|
||||
let mapActive = false;
|
||||
let compActive = false;
|
||||
let selectedForComp = new Set();
|
||||
|
||||
let activeCategory = 'all';
|
||||
let searchQuery = '';
|
||||
|
||||
const TICK_COLOR = '#94a3b8';
|
||||
const GRID_COLOR = 'rgba(148, 163, 184, 0.08)';
|
||||
|
||||
let chartFill = false;
|
||||
|
||||
const CHART_COLORS = [
|
||||
'rgba(59, 130, 246, 1)', // blue
|
||||
'rgba(16, 185, 129, 1)', // green
|
||||
'rgba(245, 158, 11, 1)', // amber
|
||||
'rgba(239, 68, 68, 1)', // red
|
||||
'rgba(139, 92, 246, 1)', // purple
|
||||
'rgba(236, 72, 153, 1)', // pink
|
||||
];
|
||||
let colorIdx = 0;
|
||||
function getColorForField(key) {
|
||||
if(!liveData[key].color) {
|
||||
liveData[key].color = CHART_COLORS[colorIdx % CHART_COLORS.length];
|
||||
colorIdx++;
|
||||
}
|
||||
return liveData[key].color;
|
||||
}
|
||||
|
||||
const FIELD_DEFS = {
|
||||
temp: { name: 'Temperatura', unit: '°C', category: 'weather' },
|
||||
hum: { name: 'Umidita', unit: '%', category: 'weather' },
|
||||
pres: { name: 'Pressione', unit: 'hPa', category: 'weather' },
|
||||
wSpd: { name: 'Velocita Vento', unit: 'km/h', category: 'weather' },
|
||||
wDir: { name: 'Direzione Vento', unit: '°', category: 'weather' },
|
||||
gust: { name: 'Raffiche', unit: 'km/h', category: 'weather' },
|
||||
rain: { name: 'Pioggia', unit: 'mm', category: 'weather' },
|
||||
prec: { name: 'Precipitazioni', unit: 'mm', category: 'weather' },
|
||||
lat: { name: 'Latitudine', unit: '°', category: 'navigation' },
|
||||
lon: { name: 'Longitudine', unit: '°', category: 'navigation' },
|
||||
hdg: { name: 'Heading', unit: '°', category: 'navigation' },
|
||||
sog: { name: 'Velocita SOG', unit: 'kn', category: 'navigation' },
|
||||
cog: { name: 'Rotta COG', unit: '°', category: 'navigation' },
|
||||
depth: { name: 'Profondita', unit: 'm', category: 'navigation' },
|
||||
engTemp: { name: 'Temp. Motore', unit: '°C', category: 'engine' },
|
||||
wvH: { name: 'Altezza Onde', unit: 'm', category: 'weather' },
|
||||
wvP: { name: 'Periodo Onde', unit: 's', category: 'weather' },
|
||||
wvD: { name: 'Direzione Onde', unit: '°', category: 'weather' },
|
||||
curD: { name: 'Dir. Corrente', unit: '°', category: 'weather' },
|
||||
curV: { name: 'Vel. Corrente', unit: 'm/s', category: 'weather' },
|
||||
fTemp: { name: 'Prev. Temperatura', unit: '°C', category: 'weather' },
|
||||
fWSpd: { name: 'Prev. Vento', unit: 'km/h', category: 'weather' }
|
||||
};
|
||||
const MEASUREMENT_CATEGORY = { weather: 'weather', navigation: 'navigation', engine: 'engine' };
|
||||
const ALWAYS_FILL_BOTTOM_FIELDS = ['lat', 'lon'];
|
||||
|
||||
async function loadSessions() {
|
||||
document.getElementById('sessionList').innerHTML = '<div class="session-loading">Caricamento...</div>';
|
||||
try {
|
||||
const res = await fetch(`${REALTIME_URL}/sessions`);
|
||||
const sessions = await res.json();
|
||||
const entries = Object.entries(sessions);
|
||||
|
||||
if (entries.length === 0) {
|
||||
document.getElementById('sessionList').innerHTML = '<div class="session-empty">Nessun sensore connesso</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('sessionList').innerHTML = '';
|
||||
for (const [sId, rawMeta] of entries) {
|
||||
const meta = typeof rawMeta === 'string' ? JSON.parse(rawMeta) : rawMeta;
|
||||
const item = document.createElement('div');
|
||||
item.className = 'session-item';
|
||||
const connTime = meta.connectedAt ? new Date(meta.connectedAt * 1000).toLocaleTimeString('it-IT') : '—';
|
||||
item.innerHTML = `<div class="session-item-info"><strong>${meta.name || sId}</strong><span class="session-item-id">${sId}</span></div><div class="session-item-meta"><span class="session-item-time">Connesso: ${connTime}</span><div class="session-item-dot"></div></div>`;
|
||||
item.onclick = () => selectSession(sId, meta);
|
||||
document.getElementById('sessionList').appendChild(item);
|
||||
}
|
||||
} catch (err) { }
|
||||
}
|
||||
|
||||
function selectSession(sId, meta) {
|
||||
currentSensorId = sId;
|
||||
sessionStartTime = meta.connectedAt ? meta.connectedAt * 1000 : Date.now();
|
||||
document.getElementById('sessionOverlay').style.display = 'none';
|
||||
document.getElementById('mainContent').style.display = '';
|
||||
document.getElementById('bottomBar').style.display = '';
|
||||
document.getElementById('sensorName').textContent = meta.name || sId;
|
||||
document.getElementById('sessionInfoTitle').textContent = `Sensore: ${meta.name || sId}`;
|
||||
liveData = {};
|
||||
Object.values(miniCharts).forEach(c => c.destroy());
|
||||
miniCharts = {};
|
||||
if(expChart) { expChart.destroy(); expChart = null; }
|
||||
if(compChart) { compChart.destroy(); compChart = null; }
|
||||
document.getElementById('dataGrid').innerHTML = '';
|
||||
selectedForComp.clear();
|
||||
lastDataTime = 0;
|
||||
|
||||
clearInterval(lastUpdateInterval);
|
||||
lastUpdateInterval = setInterval(updateLastUpdateText, 1000);
|
||||
|
||||
connectWebSocket(sId);
|
||||
}
|
||||
|
||||
function updateLastUpdateText() {
|
||||
if(!lastDataTime) return;
|
||||
const diff = Math.floor((Date.now() - lastDataTime)/1000);
|
||||
const txt = diff === 0 ? "Ultimo aggiornamento, adesso" : `Ultimo aggiornamento, ${diff} secondi fa`;
|
||||
document.getElementById('lastUpdateText').textContent = txt;
|
||||
}
|
||||
|
||||
function connectWebSocket(sId) {
|
||||
if (ws) { ws.send(JSON.stringify({ action: 'unwatch' })); ws.close(); }
|
||||
ws = new WebSocket(`${REALTIME_WS_URL}/live`);
|
||||
ws.onopen = () => ws.send(JSON.stringify({ action: 'watch', sensorId: sId }));
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.timestamp && msg.measurement && msg.fields) {
|
||||
lastDataTime = Date.now();
|
||||
updateLastUpdateText();
|
||||
handleSensorData(msg);
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
if (currentSensorId === sId) setTimeout(() => connectWebSocket(sId), 2000);
|
||||
};
|
||||
}
|
||||
|
||||
function handleSensorData(msg) {
|
||||
const { timestamp, measurement, fields } = msg;
|
||||
const t = new Date(timestamp);
|
||||
const cat = MEASUREMENT_CATEGORY[measurement] || measurement;
|
||||
|
||||
let redrawExpChart = false;
|
||||
let redrawCompChart = false;
|
||||
|
||||
for (const [k, v] of Object.entries(fields)) {
|
||||
if (v == null) continue;
|
||||
const key = `${measurement}:${k}`;
|
||||
const def = FIELD_DEFS[k] || { name: k, unit: '', category: cat };
|
||||
|
||||
if (!liveData[key]) {
|
||||
liveData[key] = { value: v, history: [], measurement, category: def.category || cat, def, k, color: null };
|
||||
getColorForField(key);
|
||||
createHybCard(key, def, v);
|
||||
}
|
||||
|
||||
liveData[key].value = v;
|
||||
liveData[key].history.push({ t, v: typeof v === 'number' ? v : null });
|
||||
if (liveData[key].history.length > 200) liveData[key].history.shift();
|
||||
|
||||
updateHybCard(key, v);
|
||||
|
||||
if (expActiveField === key) redrawExpChart = true;
|
||||
if (compActive && selectedForComp.has(key)) redrawCompChart = true;
|
||||
}
|
||||
|
||||
if (redrawExpChart) updateExpandedChart();
|
||||
if (redrawCompChart) updateCompChart();
|
||||
|
||||
if (measurement === 'logs' && fields.lat && fields.lon) updateMap(fields.lat, fields.lon, fields.hdg, fields.wDir, fields.wvD);
|
||||
}
|
||||
|
||||
function createHybCard(key, def, val) {
|
||||
if(typeof val !== 'number') return;
|
||||
const grid = document.getElementById('dataGrid');
|
||||
const card = document.createElement('div');
|
||||
card.className = 'data-card';
|
||||
card.dataset.key = key;
|
||||
card.dataset.category = def.category;
|
||||
card.dataset.name = def.name.toLowerCase();
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="card-top">
|
||||
<div class="card-title-group">
|
||||
<div class="card-info">
|
||||
<h4>${def.name}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button class="card-action-btn enlarge-btn" title="Focus">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-values">
|
||||
<span class="card-main-val">${val.toFixed(1)}</span>
|
||||
<span class="card-unit">${def.unit}</span>
|
||||
</div>
|
||||
<div class="card-mini-chart">
|
||||
<canvas id="mini-${key}"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
card.querySelector('.enlarge-btn').onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
openExpandedChart(key);
|
||||
};
|
||||
|
||||
card.onclick = () => {
|
||||
if(compActive) toggleCompItem(key);
|
||||
};
|
||||
|
||||
grid.appendChild(card);
|
||||
|
||||
const ctx = document.getElementById(`mini-${key}`).getContext('2d');
|
||||
const col = liveData[key].color;
|
||||
const bgColor = col.replace(', 1)', ', 0.15)');
|
||||
const fillRule = chartFill ? (ALWAYS_FILL_BOTTOM_FIELDS.includes(liveData[key].k) ? 'start' : true) : false;
|
||||
miniCharts[key] = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: { labels: [], datasets: [{ data: [], borderColor: col, backgroundColor: bgColor, fill: fillRule, tension: 0.3, pointRadius: 0, borderWidth: 2 }] },
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false, animation: false, resizeDelay: 100,
|
||||
plugins: { legend: { display:false }, tooltip: { enabled:false } },
|
||||
scales: { x: { type: 'category', display:false }, y: { display:false, ticks: { maxTicksLimit: 5 } } }
|
||||
}
|
||||
});
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function updateHybCard(key, val) {
|
||||
const card = document.querySelector(`.data-card[data-key="${key}"]`);
|
||||
if(!card) return;
|
||||
card.querySelector('.card-main-val').textContent = val.toFixed(1);
|
||||
|
||||
const h = liveData[key].history;
|
||||
|
||||
const chart = miniCharts[key];
|
||||
if(chart && (!compActive || !selectedForComp.has(key))) {
|
||||
chart.data.labels = h.map(x=>'');
|
||||
chart.data.datasets[0].data = h.map(x=>x.v);
|
||||
chart.update('none');
|
||||
}
|
||||
}
|
||||
|
||||
function openExpandedChart(key) {
|
||||
expActiveField = key;
|
||||
document.getElementById('expandedChartContainer').style.display = 'flex';
|
||||
document.getElementById('expChartTitle').textContent = `Dettaglio: ${liveData[key].def.name}`;
|
||||
|
||||
if(!expChart) {
|
||||
const ctx = document.getElementById('expandedChartCanvas').getContext('2d');
|
||||
const fillRule = chartFill ? (ALWAYS_FILL_BOTTOM_FIELDS.includes(liveData[expActiveField].k) ? 'start' : true) : false;
|
||||
expChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: { labels: [], datasets: [{ label:'', data: [], borderColor: 'rgba(59, 130, 246, 1)', tension:0.3, fill: fillRule, backgroundColor:'rgba(59, 130, 246, 0.15)', pointRadius:0, borderWidth: 2 }] },
|
||||
options: {
|
||||
responsive:true, maintainAspectRatio:false, animation:false, resizeDelay: 100, interaction: { intersect: false, mode: 'index' },
|
||||
scales: {
|
||||
x: { type: 'category', ticks:{ maxTicksLimit: 6, color: TICK_COLOR, font:{size:10} }, grid:{display:false} },
|
||||
y: { ticks:{ color: TICK_COLOR, font:{size:10}, maxTicksLimit: 5 }, grid:{color:GRID_COLOR} }
|
||||
},
|
||||
plugins:{ legend:{display:false}, tooltip: { callbacks: { label: (tipCtx) => `${tipCtx.parsed.y?.toFixed(2)}` } } }
|
||||
}
|
||||
});
|
||||
}
|
||||
updateExpandedChart();
|
||||
}
|
||||
|
||||
function updateExpandedChart() {
|
||||
if(!expChart || !expActiveField) return;
|
||||
const dt = liveData[expActiveField];
|
||||
expChart.data.datasets[0].borderColor = dt.color;
|
||||
expChart.data.datasets[0].backgroundColor = dt.color.replace(', 1)', ', 0.15)');
|
||||
expChart.data.datasets[0].fill = chartFill ? (ALWAYS_FILL_BOTTOM_FIELDS.includes(dt.k) ? 'start' : true) : false;
|
||||
expChart.data.labels = dt.history.map(h => h.t.toLocaleTimeString('it-IT',{hour:'2-digit',minute:'2-digit',second:'2-digit'}));
|
||||
expChart.data.datasets[0].data = dt.history.map(h => h.v);
|
||||
expChart.update('none');
|
||||
}
|
||||
|
||||
document.getElementById('closeExpChartBtn').onclick = () => {
|
||||
document.getElementById('expandedChartContainer').style.display = 'none';
|
||||
expActiveField = null;
|
||||
};
|
||||
|
||||
function toggleCompItem(key) {
|
||||
if(selectedForComp.has(key)) selectedForComp.delete(key);
|
||||
else selectedForComp.add(key);
|
||||
renderCompSidebar();
|
||||
}
|
||||
|
||||
function renderCompSidebar() {
|
||||
const labelsDiv = document.getElementById('compLabelsList');
|
||||
labelsDiv.innerHTML = '';
|
||||
|
||||
document.querySelectorAll('.data-card').forEach(c => {
|
||||
const k = c.dataset.key;
|
||||
if(selectedForComp.has(k)) c.classList.add('selected-for-comp');
|
||||
else c.classList.remove('selected-for-comp');
|
||||
});
|
||||
|
||||
if(selectedForComp.size === 0) {
|
||||
labelsDiv.innerHTML = '<div style="font-size:0.8rem;color:#94a3b8;padding:10px;">Clicca sulle card per aggiungere dati al confronto.</div>';
|
||||
} else {
|
||||
selectedForComp.forEach(k => {
|
||||
const dt = liveData[k];
|
||||
const p = document.createElement('div');
|
||||
p.className = 'comp-pill';
|
||||
p.innerHTML = `<div class="color-dot" style="background:${dt.color}"></div><span>${dt.def.name}</span><button>×</button>`;
|
||||
p.querySelector('button').onclick = () => toggleCompItem(k);
|
||||
labelsDiv.appendChild(p);
|
||||
});
|
||||
}
|
||||
initOrUpdateCompChart();
|
||||
}
|
||||
|
||||
function initOrUpdateCompChart() {
|
||||
if(!compChart) {
|
||||
const ctx = document.getElementById('compChartCanvas').getContext('2d');
|
||||
compChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: { labels:[], datasets:[] },
|
||||
options: {
|
||||
responsive:true, maintainAspectRatio:false, animation:false, resizeDelay: 100,
|
||||
interaction: { intersect: false, mode: 'index' },
|
||||
scales: {
|
||||
x: { type: 'category', ticks:{maxTicksLimit:6, color: TICK_COLOR, font:{size:10}}, grid:{display:false} },
|
||||
y: { ticks:{color: TICK_COLOR, font:{size:10}, maxTicksLimit: 5}, grid:{color:GRID_COLOR} }
|
||||
},
|
||||
plugins:{ legend:{display:false} }
|
||||
}
|
||||
});
|
||||
}
|
||||
updateCompChart();
|
||||
}
|
||||
|
||||
function updateCompChart() {
|
||||
if(!compChart) return;
|
||||
const datasets = [];
|
||||
let longestHistory = [];
|
||||
|
||||
selectedForComp.forEach(k => {
|
||||
const dt = liveData[k];
|
||||
if(dt.history.length > longestHistory.length) longestHistory = dt.history;
|
||||
datasets.push({
|
||||
label: dt.def.name,
|
||||
data: dt.history.map(h=>h.v),
|
||||
borderColor: dt.color,
|
||||
borderWidth: 2, tension:0.3, pointRadius:0, fill:false
|
||||
});
|
||||
});
|
||||
|
||||
compChart.data.labels = longestHistory.map(h=>h.t.toLocaleTimeString('it-IT',{hour:'2-digit',minute:'2-digit',second:'2-digit'}));
|
||||
compChart.data.datasets = datasets;
|
||||
compChart.update('none');
|
||||
}
|
||||
|
||||
document.getElementById('compToggleBtn').onclick = () => {
|
||||
compActive = !compActive;
|
||||
document.getElementById('compToggleBtn').style.background = compActive ? "var(--accent-color)" : "";
|
||||
document.getElementById('compToggleBtn').style.color = compActive ? "#fff" : "";
|
||||
if(compActive) {
|
||||
document.getElementById('compSidebar').style.display = 'flex';
|
||||
renderCompSidebar();
|
||||
} else {
|
||||
document.getElementById('compSidebar').style.display = 'none';
|
||||
selectedForComp.clear();
|
||||
document.querySelectorAll('.data-card').forEach(c=>c.classList.remove('selected-for-comp'));
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('compCloseBtn').onclick = () => {
|
||||
document.getElementById('compToggleBtn').click();
|
||||
};
|
||||
|
||||
document.querySelectorAll('#chartFillToggle button').forEach(btn => {
|
||||
btn.onclick = () => {
|
||||
document.querySelectorAll('#chartFillToggle button').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
chartFill = btn.dataset.fill === 'true';
|
||||
Object.entries(miniCharts).forEach(([k, chart]) => {
|
||||
chart.data.datasets[0].fill = chartFill ? (ALWAYS_FILL_BOTTOM_FIELDS.includes(liveData[k].k) ? 'start' : true) : false;
|
||||
chart.update('none');
|
||||
});
|
||||
if(expChart && expActiveField) {
|
||||
expChart.data.datasets[0].fill = chartFill ? (ALWAYS_FILL_BOTTOM_FIELDS.includes(liveData[expActiveField].k) ? 'start' : true) : false;
|
||||
expChart.update('none');
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
document.getElementById('mapToggleBtn').onclick = () => {
|
||||
mapActive = !mapActive;
|
||||
document.getElementById('mapToggleBtn').style.background = mapActive ? "var(--accent-color)" : "";
|
||||
document.getElementById('mapToggleBtn').style.color = mapActive ? "#fff" : "";
|
||||
document.getElementById('mapContainer').style.display = mapActive ? 'block' : 'none';
|
||||
if(mapActive) {
|
||||
document.getElementById('mapSecondaryBar').classList.add('visible');
|
||||
if(!leafletMap) {
|
||||
leafletMap = L.map('liveMap').setView([42.0, 12.5], 10);
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { maxZoom: 19 }).addTo(leafletMap);
|
||||
boatMarker = L.circleMarker([42.0, 12.5], { radius:8, fillColor:'#3b82f6', color:'#fff', weight:2, fillOpacity:0.9 }).addTo(leafletMap);
|
||||
headingLayer = L.layerGroup().addTo(leafletMap);
|
||||
windLayer = L.layerGroup().addTo(leafletMap);
|
||||
wavesLayer = L.layerGroup().addTo(leafletMap);
|
||||
}
|
||||
setTimeout(()=> leafletMap.invalidateSize(), 200);
|
||||
} else {
|
||||
document.getElementById('mapSecondaryBar').classList.remove('visible');
|
||||
}
|
||||
};
|
||||
|
||||
function updateMap(lat, lon, hdg, wDir, wvD) {
|
||||
if(!leafletMap || !mapActive) return;
|
||||
const p = [lat, lon];
|
||||
boatMarker.setLatLng(p);
|
||||
leafletMap.setView(p, leafletMap.getZoom());
|
||||
headingLayer.clearLayers(); windLayer.clearLayers(); wavesLayer.clearLayers();
|
||||
const len = 0.005*(leafletMap.getZoom()>12?1:3);
|
||||
if(hdg!=null) { const r = hdg*Math.PI/180; L.polyline([p,[lat+len*Math.cos(r),lon+len*Math.sin(r)]],{color:'#3b82f6'}).addTo(headingLayer); }
|
||||
if(wDir!=null) { const r = wDir*Math.PI/180; L.polyline([p,[lat+len*Math.cos(r),lon+len*Math.sin(r)]],{color:'#10b981'}).addTo(windLayer); }
|
||||
if(wvD!=null) { const r = wvD*Math.PI/180; L.polyline([p,[lat+len*Math.cos(r),lon+len*Math.sin(r)]],{color:'#f59e0b'}).addTo(wavesLayer); }
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
document.querySelectorAll('.data-card').forEach(c => {
|
||||
const mCat = activeCategory === 'all' || c.dataset.category === activeCategory;
|
||||
const mStr = !searchQuery || c.dataset.name.includes(searchQuery);
|
||||
c.style.display = (mCat && mStr) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
document.getElementById('searchInput').oninput = e => { searchQuery = e.target.value.toLowerCase(); applyFilters(); };
|
||||
document.querySelectorAll('#categoryFilter button').forEach(b => {
|
||||
b.onclick = () => { document.querySelectorAll('#categoryFilter button').forEach(x=>x.classList.remove('active')); b.classList.add('active'); activeCategory = b.dataset.cat; applyFilters(); };
|
||||
});
|
||||
document.querySelectorAll('#mapZoomSection button').forEach(b => {
|
||||
b.onclick = () => { document.querySelectorAll('#mapZoomSection button').forEach(x=>x.classList.remove('active')); b.classList.add('active'); const zm = {1:14, 5:12, 10:10, 50:7}[b.dataset.zoom]; if(leafletMap) leafletMap.setZoom(zm); };
|
||||
});
|
||||
document.querySelectorAll('.bb-toggle').forEach(b => {
|
||||
b.onclick = () => {
|
||||
b.classList.toggle('on'); const id = b.dataset.layer; const on = b.classList.contains('on');
|
||||
if(!leafletMap) return;
|
||||
if(id==='heading') on?leafletMap.addLayer(headingLayer):leafletMap.removeLayer(headingLayer);
|
||||
if(id==='wind') on?leafletMap.addLayer(windLayer):leafletMap.removeLayer(windLayer);
|
||||
if(id==='waves') on?leafletMap.addLayer(wavesLayer):leafletMap.removeLayer(wavesLayer);
|
||||
};
|
||||
});
|
||||
document.addEventListener('DOMContentLoaded', () => loadSessions());
|
||||
|
||||
document.getElementById('downloadBtn').onclick = async () => {
|
||||
if (!currentSensorId || !sessionStartTime) return;
|
||||
try {
|
||||
const btn = document.getElementById('downloadBtn');
|
||||
const oldText = btn.textContent;
|
||||
btn.textContent = '...';
|
||||
|
||||
await fetch(`${REALTIME_URL}/sessions/${currentSensorId}/flush`, { method: 'POST' });
|
||||
const csvUrl = `${REALTIME_URL}/sessions/${currentSensorId}/csv?from=${sessionStartTime}`;
|
||||
const res = await fetch(csvUrl);
|
||||
const blob = await res.blob();
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `session_${currentSensorId}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
const sizeStr = blob.size < 1024 ? `${blob.size} B` : (blob.size < 1024*1024 ? `${(blob.size/1024).toFixed(1)} KB` : `${(blob.size/(1024*1024)).toFixed(1)} MB`);
|
||||
const text = await blob.text();
|
||||
const rows = Math.max(0, text.split('\n').length - 2);
|
||||
const deltaS = Math.floor((Date.now() - sessionStartTime)/1000);
|
||||
const timeStr = deltaS < 60 ? `${deltaS} secondi` : (deltaS < 3600 ? `${Math.floor(deltaS/60)} minuti` : `${(deltaS/3600).toFixed(1)} ore`);
|
||||
|
||||
showToast(`Scarico sessione avviata ${timeStr} fa, ${rows} dati, ${sizeStr}`);
|
||||
btn.textContent = oldText;
|
||||
} catch (err) {
|
||||
showToast('Errore durante il download');
|
||||
document.getElementById('downloadBtn').textContent = 'Scarica';
|
||||
}
|
||||
};
|
||||
|
||||
function showToast(msg) {
|
||||
let t = document.getElementById('dl-toast');
|
||||
if(!t) {
|
||||
t = document.createElement('div');
|
||||
t.id = 'dl-toast';
|
||||
t.style = 'position:fixed; bottom: 84px; right: 24px; background: rgba(255, 255, 255, 0.9); padding: 16px 20px; border-radius: var(--radius-lg); border: 1px solid var(--header-border); box-shadow: var(--shadow-md); color: var(--text-primary); font-size: 14px; font-weight: 600; z-index: 9999; backdrop-filter: blur(10px); transform: translateY(100px); opacity: 0; transition: all 0.4s cubic-bezier(0.8, 0, 0.2, 1); pointer-events: none;';
|
||||
document.body.appendChild(t);
|
||||
}
|
||||
t.textContent = msg;
|
||||
t.style.transform = 'translateY(0)';
|
||||
t.style.opacity = '1';
|
||||
setTimeout(() => {
|
||||
t.style.transform = 'translateY(100px)';
|
||||
t.style.opacity = '0';
|
||||
}, 4500);
|
||||
}
|
||||
|
||||
</script>
|
||||
26
console/src/static/styles/dashboard.css
Normal file
26
console/src/static/styles/dashboard.css
Normal file
@@ -0,0 +1,26 @@
|
||||
.content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
||||
.card[title="Live"] {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.card[title="Live"]::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background: linear-gradient(135deg, #ff00d0, #0026ff);
|
||||
filter: blur(40px);
|
||||
opacity: 0;
|
||||
transition: opacity 0.7s ease;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.card[title="Live"]:hover::before {
|
||||
opacity: 0.2;
|
||||
}
|
||||
669
console/src/static/styles/live.css
Normal file
669
console/src/static/styles/live.css
Normal file
@@ -0,0 +1,669 @@
|
||||
/* === Session Overlay Popup === */
|
||||
.session-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.session-popup {
|
||||
background: var(--header-bg, #fff);
|
||||
border: 1px solid var(--header-border);
|
||||
border-radius: 24px;
|
||||
padding: 32px;
|
||||
width: 420px;
|
||||
max-width: 90vw;
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--shadow-lg, 0 20px 60px rgba(0,0,0,0.3));
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.session-popup h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.popup-subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.session-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.session-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--header-border);
|
||||
background: var(--surface, #f8fafc);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.session-item:hover {
|
||||
border-color: var(--accent-light, #bfdbfe);
|
||||
box-shadow: 0 0 0 2px var(--accent-light, #bfdbfe);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.session-item-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.session-item-info strong {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.session-item-id {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.session-item-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.session-item-time {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.session-item-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #10b981;
|
||||
box-shadow: 0 0 8px #10b981;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-loading,
|
||||
.session-empty,
|
||||
.session-error {
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.session-error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* === Top Info (Last Update) === */
|
||||
.last-update {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* === Layout Wrappers === */
|
||||
.dashboard-layout {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 24px;
|
||||
margin: 0 30px 20px;
|
||||
padding-bottom: 140px; /* Evita che la bottom bar copra i dati */
|
||||
}
|
||||
|
||||
.main-column {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* === Sticky Map & Expanded Chart === */
|
||||
.sticky-area {
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(226, 232, 240, 0.6);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.04);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.map-container #liveMap {
|
||||
width: 100%;
|
||||
height: 300px; /* Ridotto un po' per far spazio */
|
||||
}
|
||||
|
||||
.expanded-chart-container {
|
||||
background: var(--surface, #f8fafc);
|
||||
border: 1px solid rgba(226, 232, 240, 0.6);
|
||||
border-radius: 24px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.04);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.expanded-chart-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.expanded-chart-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.close-expanded-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.close-expanded-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.expanded-chart-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.expanded-chart-body canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* === Card ibrida (Dati + Minigrafico) === */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.data-card {
|
||||
background: var(--surface, #ffffff);
|
||||
border: 1px solid rgba(226, 232, 240, 0.6);
|
||||
border-radius: 24px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.03);
|
||||
position: relative;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.data-card:hover {
|
||||
box-shadow: 0 12px 32px rgba(0,0,0,0.06);
|
||||
border-color: var(--accent-light);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card-info {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.card-info h4 {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-info span {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.data-card:hover .card-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card-action-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.card-action-btn:hover {
|
||||
background: #f1f5f9;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 0 6px 6px;
|
||||
}
|
||||
|
||||
.card-values {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.card-main-val {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.card-unit {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.card-mini-chart {
|
||||
flex: 1;
|
||||
height: 50px;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-mini-chart canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Modalità Confronto disabilita il minigrafico se selezionato, ma lascio a JS questo controllo visivo */
|
||||
|
||||
/* === Sidebar Confronto === */
|
||||
.comparison-sidebar {
|
||||
width: 400px;
|
||||
background: var(--surface);
|
||||
border: 1px solid rgba(226, 232, 240, 0.6);
|
||||
border-radius: 24px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.04);
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
height: calc(100vh - 120px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.comp-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--header-border);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.comp-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.comp-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.comp-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.comp-pill {
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: #f1f5f9;
|
||||
border: 1px solid var(--header-border);
|
||||
}
|
||||
|
||||
.comp-pill .color-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.comp-pill button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.comp-chart-area {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.comp-chart-area canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Quando la modalita confronto è attiva le card cambiano aspetto al click? Lo gestisce JS (es. classe .selected-for-comp) */
|
||||
.data-card.selected-for-comp {
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 1px var(--accent-color);
|
||||
}
|
||||
.data-card.selected-for-comp .card-mini-chart {
|
||||
opacity: 0.2; /* Nasconde o sbiadisce il mini chart per far capire che è in confronto */
|
||||
}
|
||||
|
||||
/* === Bottom Bar & secondary ... same as before mostly === */
|
||||
.bottom-bar {
|
||||
position: fixed;
|
||||
bottom: 32px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
width: max-content;
|
||||
max-width: 95vw;
|
||||
height: 56px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 16px;
|
||||
font-size: 15px;
|
||||
user-select: none;
|
||||
background-color: rgba(255, 255, 255, 0.75);
|
||||
backdrop-filter: blur(24px) saturate(1.5);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
border-radius: 28px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.08), 0 4px 12px rgba(0, 0, 0, 0.02);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.bottom-bar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bottom-bar .search-field {
|
||||
height: 38px;
|
||||
width: 200px;
|
||||
padding-right: 10px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.8);
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: background 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bottom-bar .search-field:focus-within {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-color: var(--accent-light, #bfdbfe);
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.bottom-bar .search-field input {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
padding: 5px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bottom-bar .filter {
|
||||
display: flex;
|
||||
height: 38px;
|
||||
padding: 0 4px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.8);
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.bottom-bar .filter.boxed button {
|
||||
display: flex;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bottom-bar .filter button {
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bottom-bar .filter button.active {
|
||||
background: var(--accent-color);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.bottom-bar > button#downloadBtn {
|
||||
display: flex;
|
||||
padding: 6px 16px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 12px;
|
||||
background: var(--surface, #f8fafc);
|
||||
border: 1px solid var(--header-border);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bar-sep {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background-color: var(--header-border);
|
||||
margin: 0 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.map-bar {
|
||||
position: fixed;
|
||||
bottom: 6rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
display: flex;
|
||||
width: max-content;
|
||||
height: 48px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 16px;
|
||||
background-color: rgba(255, 255, 255, 0.75);
|
||||
backdrop-filter: blur(24px) saturate(1.5);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
border-radius: 24px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.06);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.map-bar.visible {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
.map-bar .filter {
|
||||
display: flex;
|
||||
height: 36px;
|
||||
padding: 0 4px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--header-border);
|
||||
background: var(--surface, #f8fafc);
|
||||
}
|
||||
|
||||
.map-bar .filter button {
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.map-bar .filter button.active {
|
||||
background: var(--accent-color);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.map-toggles-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bb-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: var(--surface, #f8fafc);
|
||||
border: 1px solid var(--header-border);
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bb-toggle.on {
|
||||
border-color: var(--accent-border, #bfdbfe);
|
||||
background: var(--accent-light, #eff6ff);
|
||||
color: var(--accent-color, #2563eb);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.dashboard-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
.comparison-sidebar {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
201
console/src/static/styles/style.css
Normal file
201
console/src/static/styles/style.css
Normal file
@@ -0,0 +1,201 @@
|
||||
:root {
|
||||
--accent-color: #2563eb;
|
||||
--accent-hover: #1d4ed8;
|
||||
--accent-light: #eff6ff;
|
||||
--accent-border: #bfdbfe;
|
||||
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #4755698f;
|
||||
--text-tertiary: #94a3b8c0;
|
||||
|
||||
--surface: #f8fafc;
|
||||
|
||||
--header-bg: rgba(255, 255, 255, 0.85);
|
||||
/* For Glassmorphism */
|
||||
--header-border: #e2e8f0;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Normal';
|
||||
src: url('../font/Quicksand-VariableFont_wght.ttf');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Bold';
|
||||
src: url('../font/Quicksand-VariableFont_wght.ttf');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Normal', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 10px 24px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--header-border);
|
||||
background-color: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-family: 'Bold', inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.5s cubic-bezier(0.8, 0, 0.2, 1);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: var(--accent-light);
|
||||
color: var(--accent-color);
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
|
||||
button.prominent {
|
||||
background-color: var(--accent-color);
|
||||
color: #ffffff;
|
||||
border: 1px solid transparent;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
button.prominent:hover {
|
||||
background-color: var(--accent-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
|
||||
button.prominent:active {
|
||||
transform: translateY(1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
|
||||
/* INFO PANEL */
|
||||
|
||||
.info-panel {
|
||||
display: block;
|
||||
text-align: center;
|
||||
align-content: center;
|
||||
padding: 60px 20px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.info-panel h3 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.info-panel p {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.info-panel .icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 20px;
|
||||
align-self: center;
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/* GRID & CARD ITEMS */
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1.5rem;
|
||||
margin-inline: 30px;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--header-border);
|
||||
border-radius: 20px;
|
||||
text-decoration: none;
|
||||
color: var(--text-primary);
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.card p {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.4;
|
||||
font-size: 0.8rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 30px #bfdbfe30;
|
||||
}
|
||||
|
||||
.card.standalone {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/* HEADER */
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
background-color: var(--header-bg);
|
||||
border-bottom: 1px solid var(--header-border);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
|
||||
.header h1 {
|
||||
color: var(--text-primary);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.header .profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header .profile p {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
padding-inline: 5px;
|
||||
}
|
||||
Reference in New Issue
Block a user