Added UI with station cards, grouping and styling

This commit is contained in:
2026-05-07 23:22:05 +02:00
parent 02392b8c24
commit 02c1a4c58c
9 changed files with 444 additions and 8 deletions
+19
View File
@@ -30,3 +30,22 @@ bun run dev
bun run build
bun run preview
```
## Gruppi di centraline
Le centraline vengono raggruppate visivamente tramite il file
`src/lib/groups.json`. La struttura è un array di gruppi:
```json
[
{
"nome": "Mantova",
"ids": [151, 154, 155, 158, 159, 173, 174, 176, 177, 178, 179, 180]
}
]
```
Ogni gruppo ha un nome e una lista di ID numerici delle centraline. Le
centraline non presenti in nessun gruppo vengono mostrate automaticamente nella
sezione "Altre". L'ordine dei gruppi nel file determina l'ordine di
visualizzazione sulla pagina.
+5 -2
View File
@@ -1,10 +1,13 @@
<!doctype html>
<html lang="en">
<html lang="it">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Download Dati Centraline</title>
<title>Centraline 37100Lab</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="app"></div>
+126 -2
View File
@@ -1,2 +1,126 @@
<main>
</main>
<script>
import { onMount } from 'svelte';
import { fetchStations } from './lib/api.js';
import StationList from './components/StationList.svelte';
let stations = [];
let loading = true;
let error = null;
onMount(async () => {
try {
stations = await fetchStations();
} catch (e) {
error = 'Impossibile caricare le centraline. Controlla la connessione e riprova.';
} finally {
loading = false;
}
});
</script>
<div class="pagina">
<header>
<h1>Centraline</h1>
<p>Scarica i dati storici delle stazioni di monitoraggio ambientale di <a href="http://37100lab.it:8101/" target="_blank" rel="noreferrer">37100Lab</a>.</p>
</header>
<main>
{#if loading}
<p class="stato">Caricamento…</p>
{:else if error}
<p class="stato errore">{error}</p>
{:else}
<StationList {stations} />
{/if}
</main>
<footer>
<p>Fatto da <strong><a href="https://nicolabelluti.me" target="_blank" rel="noreferrer">Nicola Belluti</a></strong> (e <a href="https://claude.com/product/claude-code" target="_blank" rel="noreferrer">Claude Code</a>) col ❤️</p>
<p class="links">
<a href="https://git.nicolabelluti.me/nicolabelluti/download-dati-centraline" target="_blank" rel="noreferrer">Codice sorgente</a>
·
<a href="https://choosealicense.com/licenses/agpl-3.0/" target="_blank" rel="noreferrer">Licenza GNU AGPLv3.0</a>
</p>
</footer>
</div>
<style>
.pagina {
max-width: 1100px;
margin: 0 auto;
padding: 7rem 0.5rem 0;
min-height: 100svh;
display: flex;
flex-direction: column;
}
header {
margin-top: 2rem;
margin-bottom: 7rem;
padding-left: 2rem;
}
main {
flex: 1;
padding: 0 0 5rem;
}
h1 {
margin: 0 0 1rem;
font-size: 3.75rem;
font-weight: 500;
color: var(--text-hi);
letter-spacing: -0.04em;
line-height: 1.1;
}
p {
margin: 0;
font-size: 0.9rem;
color: var(--text-dim);
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.stato {
font-size: 0.875rem;
color: var(--text-dim);
}
.errore {
color: #dc2626;
}
footer {
margin-top: auto;
padding: 2rem 0 4rem;
border-top: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
footer p {
font-size: 0.82rem;
}
footer strong {
font-weight: 600;
}
.links {
display: flex;
gap: 0.6rem;
align-items: center;
color: var(--text-dim);
}
</style>
+23 -4
View File
@@ -2,9 +2,28 @@
box-sizing: border-box;
}
body {
margin: 0;
font-synthesis: none;
text-rendering: optimizeLegibility;
:root {
--bg: #f0f2f5;
--surface: #ffffff;
--border: #dde1e7;
--text: #374151;
--text-dim:#9ca3af;
--text-hi: #111827;
--accent: #2563eb;
--sans: 'Poppins', system-ui, sans-serif;
font-family: var(--sans);
font-size: 14px;
line-height: 1.5;
color: var(--text);
background: var(--bg);
-webkit-font-smoothing: antialiased;
}
body {
margin: 0;
min-height: 100svh;
}
+65
View File
@@ -0,0 +1,65 @@
<script>
import StationRow from './StationRow.svelte';
import groups from '../lib/groups.json';
export let stations = [];
const groupedIds = new Set(groups.flatMap(g => g.ids));
function ordina(stazioni) {
return [...stazioni].sort((a, b) => {
if (a.ended_at === null && b.ended_at !== null) return -1;
if (a.ended_at !== null && b.ended_at === null) return 1;
return new Date(b.created_at) - new Date(a.created_at);
});
}
const sezioni = [
...groups.map(g => ({
nome: g.nome,
stazioni: ordina(stations.filter(s => g.ids.includes(s.pk))),
})).filter(g => g.stazioni.length > 0),
{
nome: 'Altre',
stazioni: ordina(stations.filter(s => !groupedIds.has(s.pk))),
},
].filter(s => s.stazioni.length > 0);
</script>
<div class="contenuto">
{#each sezioni as sezione}
<section>
<h2>{sezione.nome}</h2>
<div class="lista">
{#each sezione.stazioni as station (station.pk)}
<StationRow {station} />
{/each}
</div>
</section>
{/each}
</div>
<style>
.contenuto {
display: flex;
flex-direction: column;
gap: 4rem;
max-width: 820px;
margin: 0 auto;
}
section h2 {
margin: 0 0 0.75rem;
font-size: 0.95rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
}
.lista {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
</style>
+176
View File
@@ -0,0 +1,176 @@
<script>
export let station;
const attiva = station.ended_at === null;
const fmt = d => new Intl.DateTimeFormat('it-IT', {
timeZone: 'Europe/Rome',
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(new Date(d));
const dataCreazione = fmt(station.created_at);
const dataFine = attiva ? null : fmt(station.ended_at);
let tooltipVisibile = false;
</script>
<div class="card">
<div class="sinistra">
<a class="nome" href="http://37100lab.it:8101/campagna/{station.pk}" target="_blank" rel="noreferrer">{station.title}</a>
<span class="id">#{station.pk}</span>
</div>
<div class="destra">
<span class="data">Attivata il {dataCreazione}</span>
<span class="sep">·</span>
<span
class="stato"
class:attiva
class:terminata={!attiva}
on:mouseenter={() => tooltipVisibile = true}
on:mouseleave={() => tooltipVisibile = false}
>
{attiva ? 'Attiva' : 'Terminata'}
{#if !attiva && tooltipVisibile}
<span class="tooltip">Terminata il {dataFine}</span>
{/if}
</span>
<button title="Scarica CSV" disabled>
<svg width="20" height="20" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 1v9M4.5 6.5 8 10l3.5-3.5M2 12h12" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
</div>
<style>
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1.4rem 1.8rem;
display: flex;
align-items: center;
gap: 1.25rem;
}
button {
flex-shrink: 0;
margin-left: 0.75rem;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
border-radius: 8px;
background: transparent;
color: var(--text-dim);
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
button:hover:not(:disabled) {
background: #eff6ff;
border-color: var(--accent);
color: var(--accent);
}
button:disabled {
opacity: 0.3;
cursor: default;
}
.sinistra {
display: flex;
align-items: baseline;
gap: 1.25rem;
flex: 1;
min-width: 0;
}
.nome {
font-size: 1.05rem;
font-weight: 500;
color: var(--text-hi);
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nome:hover {
text-decoration: underline;
}
.id {
font-size: 0.75rem;
color: var(--text-dim);
white-space: nowrap;
flex-shrink: 0;
}
.destra {
display: flex;
align-items: center;
gap: 1rem;
flex-shrink: 0;
}
.sep {
font-size: 0.78rem;
color: var(--text-dim);
opacity: 0.5;
}
.data {
font-size: 0.78rem;
color: var(--text-dim);
white-space: nowrap;
}
.stato {
font-size: 0.78rem;
font-weight: 700;
white-space: nowrap;
}
.attiva {
color: var(--accent);
}
.terminata {
color: var(--text-dim);
}
.stato {
position: relative;
}
.tooltip {
position: absolute;
bottom: calc(100% + 16px);
right: 0;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
font-size: 0.85rem;
font-weight: 400;
white-space: nowrap;
padding: 0.45em 0.9em;
border-radius: 8px;
pointer-events: none;
}
.tooltip::after {
content: '';
position: absolute;
top: 100%;
right: 1rem;
border: 6px solid transparent;
border-top-color: var(--border);
}
</style>
+19
View File
@@ -0,0 +1,19 @@
export async function fetchStations() {
const res = await fetch('/api/campagne_map_data');
if (!res.ok) throw new Error(`Errore HTTP ${res.status}`);
const geojson = await res.json();
return geojson.features
.map(f => ({ ...f.properties, pk: parseInt(f.properties.pk, 10) }))
.sort((a, b) => b.pk - a.pk);
}
export async function fetchDay(id, date) {
try {
const res = await fetch(`/api/get_measurements/${id}?day=${date}`);
if (!res.ok) return [];
const data = await res.json();
return data.misure ?? [];
} catch {
return [];
}
}
+6
View File
@@ -0,0 +1,6 @@
[
{
"nome": "Mantova",
"ids": [151, 154, 155, 158, 159, 173, 174, 176, 177, 178, 179, 180]
}
]
+5
View File
@@ -4,4 +4,9 @@ import { svelte } from '@sveltejs/vite-plugin-svelte'
// https://vite.dev/config/
export default defineConfig({
plugins: [svelte()],
server: {
proxy: {
'/api': 'http://37100lab.it:8101',
},
},
})