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
+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]
}
]