chore: initial Astro project scaffold
This commit is contained in:
+63
@@ -0,0 +1,63 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Package-manager caches
|
||||
.pnpm-store/
|
||||
.npm/
|
||||
.yarn/cache/
|
||||
.yarn/unplugged/
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
|
||||
# Astro and build output
|
||||
.astro/
|
||||
dist/
|
||||
.vercel/
|
||||
.netlify/
|
||||
|
||||
# Environment variables and local secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.template
|
||||
|
||||
# Certificates and private keys
|
||||
*.pem
|
||||
*.key
|
||||
*.p12
|
||||
*.pfx
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
# Agent/tool-generated files
|
||||
.agents/
|
||||
.claude/
|
||||
|
||||
# Logs and test output
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
coverage/
|
||||
|
||||
# Temporary and cache files
|
||||
.cache/
|
||||
.tmp/
|
||||
tmp/
|
||||
*.tmp
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# IDE settings
|
||||
.idea/
|
||||
.vscode/
|
||||
*.code-workspace
|
||||
|
||||
# OS metadata
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
@@ -0,0 +1,69 @@
|
||||
# Ibiza Environmental Watch — frontend
|
||||
|
||||
Frontend estático (Astro) para el proyecto de vigilancia ambiental de
|
||||
Eivissa i Formentera. Consume `boib_entradas` y `noticias_prensa`
|
||||
(`es_ambiental = true`) directamente de Postgres, **en build-time**, y
|
||||
publica HTML estático servido por Caddy. No hay servidor Node en producción.
|
||||
|
||||
Contexto completo del proyecto (infraestructura, esquema, decisiones ya
|
||||
tomadas) en el documento de traspaso que se usó para construir esto.
|
||||
|
||||
## Arrancar en local
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Sin `DATABASE_URL` definida, el sitio usa datos de ejemplo
|
||||
(`src/lib/fixtures.ts`) con la misma forma que las tablas reales — sirve
|
||||
para maquetar sin acceso al servidor privado de Hetzner.
|
||||
|
||||
## Build de producción
|
||||
|
||||
```bash
|
||||
DATABASE_URL="postgres://usuario:pass@host-privado:5432/dbname" npm run build
|
||||
```
|
||||
|
||||
El resultado (`dist/`) es estático: se sirve con Caddy, sin necesidad de
|
||||
Node corriendo.
|
||||
|
||||
## Variables de entorno
|
||||
|
||||
| Variable | Uso |
|
||||
|----------------|-------------------------------------------------------------------|
|
||||
| `DATABASE_URL` | Conexión a Postgres, solo se usa durante `npm run build` |
|
||||
|
||||
Como el build corre en la misma red privada de Hetzner (vía Coolify), la
|
||||
conexión no necesita salir a internet.
|
||||
|
||||
## Estructura
|
||||
|
||||
- `src/lib/db.ts` — única consulta a Postgres, una vez por build.
|
||||
- `src/lib/types.ts` — forma unificada de un "aviso" (BOIB o prensa).
|
||||
- `src/components/EntryCard.astro` — tarjeta de un aviso (siempre estática).
|
||||
- `src/components/Filters.astro` — filtro por municipio/tema/fuente, JS
|
||||
vanilla que filtra el DOM ya renderizado (sin re-fetch).
|
||||
- `src/components/MapView.tsx` — mapa Leaflet, componente Preact que **solo
|
||||
se hidrata en escritorio** (`client:media="(min-width: 900px)"`). En
|
||||
móvil ni Leaflet ni Preact se descargan.
|
||||
- `src/pages/huella.astro` — página de huella (pendiente de rellenar con
|
||||
medición real tras el primer despliegue).
|
||||
|
||||
## Pendiente para conectar el rebuild automático
|
||||
|
||||
Añadir un nodo HTTP Request al final del workflow de n8n de clasificación
|
||||
(tras cada lote) que llame al webhook de deploy de Coolify para esta app,
|
||||
para que el sitio se regenere solo sin intervención manual. La URL del
|
||||
webhook la genera Coolify al crear la aplicación (Settings → Webhooks).
|
||||
|
||||
## Pendiente de datos
|
||||
|
||||
- Unificar `'Ibiza'` → `'Eivissa'` en `noticias_prensa.municipio` ya está
|
||||
resuelto en la query (`case when municipio = 'Ibiza' then 'Eivissa' ...`),
|
||||
pero conviene aplicar también el `UPDATE` en la tabla para no depender de
|
||||
ese parche en cada query.
|
||||
- Sumar Diario de Ibiza como fuente cuando estén confirmadas las URLs RSS.
|
||||
- Las coordenadas de `municipioCoords` (en `fixtures.ts`, reutilizadas por
|
||||
el mapa) son aproximadas por municipio — no hay geocodificación por
|
||||
entrada individual, ni falta: el mapa agrupa, no geolocaliza cada aviso.
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
import preact from '@astrojs/preact';
|
||||
|
||||
// Salida estática pura: HTML/JSON servidos por Caddy, sin proceso Node en
|
||||
// producción. Los datos se consultan a Postgres una única vez, en build-time
|
||||
// (ver src/lib/db.ts). El "servidor" es solo el rebuild disparado por n8n.
|
||||
export default defineConfig({
|
||||
output: 'static',
|
||||
site: 'https://watch.itziarzameza.dev', // ajustar al dominio final
|
||||
integrations: [preact()],
|
||||
build: {
|
||||
inlineStylesheets: 'always', // menos peticiones = menos energía por visita
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "ibiza-environmental-watch",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/preact": "^6.0.1",
|
||||
"astro": "^7.1.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"pg": "^8.13.0",
|
||||
"preact": "^10.24.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/leaflet": "^1.9.12",
|
||||
"@types/pg": "^8.11.10"
|
||||
}
|
||||
}
|
||||
Generated
+3632
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
allowBuilds:
|
||||
esbuild: false
|
||||
sharp: false
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" fill="#efeae0"/>
|
||||
<path d="M6 22c4-10 8-14 20-16-6 4-9 9-11 18-3-1-6-1-9-2z" fill="#2f6e51"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 194 B |
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"accessibility": {
|
||||
"source": "addyosmani/web-quality-skills",
|
||||
"sourceType": "autoskills-registry",
|
||||
"computedHash": "bffe3d08cfe92ebad63699f74ce29e35c19850ebfbf474c1463183cfe34d6a09"
|
||||
},
|
||||
"astro": {
|
||||
"source": "astrolicious/agent-skills",
|
||||
"sourceType": "autoskills-registry",
|
||||
"computedHash": "2afd3a02edf4374598ce471dabb1ab5638851dbf7b82d0bb36f1019331eafb54"
|
||||
},
|
||||
"frontend-design": {
|
||||
"source": "anthropics/skills",
|
||||
"sourceType": "autoskills-registry",
|
||||
"computedHash": "82fb11a63fb1e35ee2469516ed02d54695f783115b1540c0e783197af4240a3a"
|
||||
},
|
||||
"nodejs-backend-patterns": {
|
||||
"source": "wshobson/agents",
|
||||
"sourceType": "autoskills-registry",
|
||||
"computedHash": "710a5e6f83c46e8f6c43356df55c143a7375c4414559a581654ce51709138c55"
|
||||
},
|
||||
"nodejs-best-practices": {
|
||||
"source": "sickn33/antigravity-awesome-skills",
|
||||
"sourceType": "autoskills-registry",
|
||||
"computedHash": "7361ab02fb6b09913e3bdd9cf61c629ed6c17de9485e6a781054e5d437ccfc29"
|
||||
},
|
||||
"seo": {
|
||||
"source": "addyosmani/web-quality-skills",
|
||||
"sourceType": "autoskills-registry",
|
||||
"computedHash": "c184da724d1c61ad077f27418ea8e7e88fd54bcdf98165e18be7e4681cbd5e20"
|
||||
},
|
||||
"typescript-advanced-types": {
|
||||
"source": "wshobson/agents",
|
||||
"sourceType": "autoskills-registry",
|
||||
"computedHash": "5ca0e177c6aaaba1889255691224daafdb7d71f317cc70bede1590d3907ded42"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
import type { Aviso } from '../lib/types';
|
||||
|
||||
interface Props {
|
||||
aviso: Aviso;
|
||||
}
|
||||
const { aviso } = Astro.props;
|
||||
|
||||
const fecha = new Date(aviso.fecha).toLocaleDateString('ca-ES', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
---
|
||||
|
||||
<article
|
||||
class="entry"
|
||||
data-municipio={aviso.municipio ?? ''}
|
||||
data-temas={aviso.temas.join('|')}
|
||||
data-fuente={aviso.fuente}
|
||||
>
|
||||
{
|
||||
aviso.numeroEdicte && (
|
||||
<span class="entry__stamp" aria-hidden="true">
|
||||
exp. {aviso.numeroEdicte}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
<h2 class="entry__title">
|
||||
<a href={aviso.url} target="_blank" rel="noopener">{aviso.titulo}</a>
|
||||
</h2>
|
||||
|
||||
<p class="entry__resumen">{aviso.resumen}</p>
|
||||
|
||||
{
|
||||
aviso.temas.length > 0 && (
|
||||
<ul class="entry__temas">
|
||||
{aviso.temas.map((t) => (
|
||||
<li>{t}</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
<p class="entry__meta">
|
||||
<span>{aviso.fuente}</span>
|
||||
{aviso.municipio && <span> · {aviso.municipio}</span>}
|
||||
<span> · <time datetime={aviso.fecha}>{fecha}</time></span>
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.entry {
|
||||
position: relative;
|
||||
padding: var(--space-2);
|
||||
background: var(--paper-raised);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.entry__stamp {
|
||||
display: inline-block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--stamp);
|
||||
border: 1px solid var(--stamp);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.1rem 0.4rem;
|
||||
transform: rotate(-1.5deg);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.entry__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.3;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.entry__title a {
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
.entry__title a:hover {
|
||||
color: var(--posidonia-deep);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.entry__resumen {
|
||||
margin: 0 0 var(--space-1);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.entry__temas {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
padding: 0;
|
||||
margin: 0 0 var(--space-1);
|
||||
}
|
||||
.entry__temas li {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--posidonia-deep);
|
||||
background: color-mix(in srgb, var(--posidonia) 12%, transparent);
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.entry__meta {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,132 @@
|
||||
---
|
||||
interface Props {
|
||||
municipios: string[];
|
||||
temas: string[];
|
||||
fuentes: string[];
|
||||
}
|
||||
const { municipios, temas, fuentes } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="filters" id="filters">
|
||||
<div class="filters__group">
|
||||
<span class="filters__label">Municipi</span>
|
||||
<div class="filters__pills" data-filter-type="municipio">
|
||||
<button class="pill is-active" data-value="">Tots</button>
|
||||
{municipios.map((m) => <button class="pill" data-value={m}>{m}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filters__group">
|
||||
<span class="filters__label">Tema</span>
|
||||
<div class="filters__pills" data-filter-type="tema">
|
||||
<button class="pill is-active" data-value="">Tots</button>
|
||||
{temas.map((t) => <button class="pill" data-value={t}>{t}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filters__group">
|
||||
<span class="filters__label">Font</span>
|
||||
<div class="filters__pills" data-filter-type="fuente">
|
||||
<button class="pill is-active" data-value="">Totes</button>
|
||||
{fuentes.map((f) => <button class="pill" data-value={f}>{f}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="filters__count" id="filters-count" aria-live="polite"></p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.filters {
|
||||
padding: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
border: 1px solid var(--line);
|
||||
border-bottom-width: 3px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--paper-raised);
|
||||
}
|
||||
.filters__group + .filters__group {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
.filters__label {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--ink-soft);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.filters__pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.pill {
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--ink);
|
||||
padding: 0.25rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pill.is-active {
|
||||
background: var(--posidonia-deep);
|
||||
border-color: var(--posidonia-deep);
|
||||
color: var(--paper);
|
||||
}
|
||||
.filters__count {
|
||||
margin: var(--space-1) 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Filtrado puro en el DOM: los avisos ya están renderizados en HTML
|
||||
// estático (build-time). No hay llamadas a red al cambiar un filtro.
|
||||
type FilterState = { municipio: string; tema: string; fuente: string };
|
||||
|
||||
const state: FilterState = { municipio: '', tema: '', fuente: '' };
|
||||
const root = document.getElementById('filters');
|
||||
const countEl = document.getElementById('filters-count');
|
||||
const entries = Array.from(document.querySelectorAll<HTMLElement>('.entry'));
|
||||
|
||||
function apply() {
|
||||
let visible = 0;
|
||||
for (const entry of entries) {
|
||||
const municipio = entry.dataset.municipio ?? '';
|
||||
const temas = (entry.dataset.temas ?? '').split('|');
|
||||
const fuente = entry.dataset.fuente ?? '';
|
||||
|
||||
const matches =
|
||||
(!state.municipio || municipio === state.municipio) &&
|
||||
(!state.tema || temas.includes(state.tema)) &&
|
||||
(!state.fuente || fuente === state.fuente);
|
||||
|
||||
entry.style.display = matches ? '' : 'none';
|
||||
if (matches) visible += 1;
|
||||
}
|
||||
if (countEl) {
|
||||
countEl.textContent = `${visible} avís${visible === 1 ? '' : 'os'} ambiental${visible === 1 ? '' : 's'}`;
|
||||
}
|
||||
}
|
||||
|
||||
root?.addEventListener('click', (e) => {
|
||||
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.pill');
|
||||
if (!btn) return;
|
||||
const group = btn.closest<HTMLElement>('[data-filter-type]');
|
||||
const type = group?.dataset.filterType as keyof FilterState | undefined;
|
||||
if (!type) return;
|
||||
|
||||
for (const sibling of group!.querySelectorAll('.pill')) {
|
||||
sibling.classList.remove('is-active');
|
||||
}
|
||||
btn.classList.add('is-active');
|
||||
state[type] = btn.dataset.value ?? '';
|
||||
apply();
|
||||
});
|
||||
|
||||
apply();
|
||||
</script>
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useRef } from 'preact/hooks';
|
||||
import type { Aviso } from '../lib/types';
|
||||
import { municipioCoords } from '../lib/fixtures';
|
||||
|
||||
interface Props {
|
||||
avisos: Aviso[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Se hidrata solo en escritorio (ver index.astro, client:media). En móvil
|
||||
* este componente ni se descarga: ni Leaflet, ni Preact, ni sus CSS.
|
||||
*/
|
||||
export default function MapView({ avisos }: Props) {
|
||||
const mapEl = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let map: import('leaflet').Map | undefined;
|
||||
|
||||
(async () => {
|
||||
const L = await import('leaflet');
|
||||
await import('leaflet/dist/leaflet.css');
|
||||
if (!mapEl.current) return;
|
||||
|
||||
map = L.map(mapEl.current, {
|
||||
center: [38.95, 1.43],
|
||||
zoom: 11,
|
||||
scrollWheelZoom: false,
|
||||
});
|
||||
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© OpenStreetMap, © CARTO',
|
||||
maxZoom: 18,
|
||||
}).addTo(map);
|
||||
|
||||
// Agrupar avisos por municipio
|
||||
const porMunicipio = new Map<string, Aviso[]>();
|
||||
for (const a of avisos) {
|
||||
if (!a.municipio) continue;
|
||||
const list = porMunicipio.get(a.municipio) ?? [];
|
||||
list.push(a);
|
||||
porMunicipio.set(a.municipio, list);
|
||||
}
|
||||
|
||||
for (const [municipio, list] of porMunicipio) {
|
||||
const coords = municipioCoords[municipio];
|
||||
if (!coords) continue;
|
||||
|
||||
const radius = 8 + Math.min(list.length, 10) * 2;
|
||||
const marker = L.circleMarker(coords, {
|
||||
radius,
|
||||
color: '#1d4a37',
|
||||
weight: 1,
|
||||
fillColor: '#2f6e51',
|
||||
fillOpacity: 0.65,
|
||||
}).addTo(map);
|
||||
|
||||
const items = list
|
||||
.slice(0, 5)
|
||||
.map(
|
||||
(a) =>
|
||||
`<li><a href="${a.url}" target="_blank" rel="noopener">${escapeHtml(a.titulo)}</a></li>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
marker.bindPopup(
|
||||
`<strong>${escapeHtml(municipio)}</strong> — ${list.length} avís${list.length === 1 ? '' : 'os'}<ul>${items}</ul>`
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
map?.remove();
|
||||
};
|
||||
}, [avisos]);
|
||||
|
||||
return <div ref={mapEl} className="map-view" role="img" aria-label="Mapa d'avisos ambientals per municipi" />;
|
||||
}
|
||||
|
||||
function escapeHtml(s: string) {
|
||||
return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string));
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
import '../styles/global.css';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
const { title, description = 'Vigilància ambiental d\'Eivissa i Formentera: BOIB i premsa local, classificats amb IA local.' } = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="ca">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="masthead">
|
||||
<div class="masthead__inner">
|
||||
<a class="masthead__title" href="/">
|
||||
Ibiza <span>Environmental</span> Watch
|
||||
</a>
|
||||
<p class="masthead__subtitle">
|
||||
BOIB i premsa d'Eivissa i Formentera, filtrats i resumits amb IA local — sense servidors de tercers.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="site-footer__inner">
|
||||
<p>
|
||||
Font oficial: <a href="https://www.caib.es/eboibfront" target="_blank" rel="noopener">BOIB</a>.
|
||||
Premsa: Periódico de Ibiza, Nou Diari i Diario de Ibiza.
|
||||
</p>
|
||||
<p>
|
||||
<a href="/huella">Huella d'aquesta pàgina</a> ·
|
||||
Allotjat a Hetzner (Helsinki, energia 100% renovable) ·
|
||||
Codi:
|
||||
<a href="https://forgejo.itziarzameza.dev" target="_blank" rel="noopener">Forgejo</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.masthead {
|
||||
border-bottom: 3px double var(--ink);
|
||||
background: var(--paper-raised);
|
||||
}
|
||||
.masthead__inner {
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-3) var(--space-2) var(--space-2);
|
||||
}
|
||||
.masthead__title {
|
||||
display: block;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.75rem, 4vw, 2.75rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
.masthead__title span {
|
||||
color: var(--posidonia-deep);
|
||||
font-style: italic;
|
||||
}
|
||||
.masthead__subtitle {
|
||||
margin: var(--space-1) 0 0;
|
||||
max-width: 40rem;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
border-top: 1px solid var(--line);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
.site-footer__inner {
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-3) var(--space-2);
|
||||
font-size: 0.85rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.site-footer__inner p {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
.site-footer a {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,80 @@
|
||||
import pg from 'pg';
|
||||
import type { Aviso } from './types';
|
||||
import { fixtureAvisos } from './fixtures';
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
/**
|
||||
* Trae todos los avisos ambientales (es_ambiental = true) de las dos tablas
|
||||
* y los unifica en un solo array, ordenado por fecha descendente.
|
||||
*
|
||||
* Se ejecuta UNA vez, en build-time (frontmatter de Astro), no en cada
|
||||
* visita. Si no hay DATABASE_URL configurada (p. ej. mientras se maqueta el
|
||||
* frontend sin acceso al servidor privado de Hetzner), cae a datos de
|
||||
* ejemplo con la misma forma, para poder trabajar en local.
|
||||
*/
|
||||
export async function getAvisosAmbientales(): Promise<Aviso[]> {
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
|
||||
if (!connectionString) {
|
||||
console.warn(
|
||||
'[db] DATABASE_URL no definida — usando datos de ejemplo (fixtures). ' +
|
||||
'Define DATABASE_URL para construir con datos reales.'
|
||||
);
|
||||
return fixtureAvisos;
|
||||
}
|
||||
|
||||
const pool = new Pool({ connectionString, max: 2 });
|
||||
|
||||
try {
|
||||
const [boib, prensa] = await Promise.all([
|
||||
pool.query(`
|
||||
select id, boib_fecha as fecha, organismo as fuente, titulo,
|
||||
resumen, temas, url_html as url, numero_edicte
|
||||
from boib_entradas
|
||||
where es_ambiental = true
|
||||
order by boib_fecha desc
|
||||
`),
|
||||
pool.query(`
|
||||
select id, coalesce(fecha_publicacion, fecha_ingesta) as fecha,
|
||||
fuente, titulo, resumen_ia as resumen, temas, url,
|
||||
case when municipio = 'Ibiza' then 'Eivissa' else municipio end as municipio
|
||||
from noticias_prensa
|
||||
where es_ambiental = true
|
||||
order by coalesce(fecha_publicacion, fecha_ingesta) desc
|
||||
`),
|
||||
]);
|
||||
|
||||
const avisosBoib: Aviso[] = boib.rows.map((r) => ({
|
||||
id: r.id,
|
||||
origen: 'boib',
|
||||
titulo: r.titulo,
|
||||
resumen: r.resumen ?? '',
|
||||
temas: r.temas ?? [],
|
||||
fuente: r.fuente ?? 'BOIB',
|
||||
municipio: null, // el BOIB no trae municipio estructurado, no forzar
|
||||
url: r.url,
|
||||
fecha: new Date(r.fecha).toISOString(),
|
||||
numeroEdicte: r.numero_edicte,
|
||||
}));
|
||||
|
||||
const avisosPrensa: Aviso[] = prensa.rows.map((r) => ({
|
||||
id: r.id,
|
||||
origen: 'prensa',
|
||||
titulo: r.titulo,
|
||||
resumen: r.resumen ?? '',
|
||||
temas: r.temas ?? [],
|
||||
fuente: r.fuente,
|
||||
municipio: r.municipio ?? null,
|
||||
url: r.url,
|
||||
fecha: new Date(r.fecha).toISOString(),
|
||||
numeroEdicte: null,
|
||||
}));
|
||||
|
||||
return [...avisosBoib, ...avisosPrensa].sort(
|
||||
(a, b) => new Date(b.fecha).getTime() - new Date(a.fecha).getTime()
|
||||
);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Aviso } from './types';
|
||||
|
||||
// Coordenadas aproximadas de los municipios de Eivissa i Formentera,
|
||||
// solo para el mapa de escritorio (agrupación por municipio, no por punto exacto).
|
||||
export const municipioCoords: Record<string, [number, number]> = {
|
||||
'Eivissa': [38.9067, 1.4206],
|
||||
'Sant Josep de sa Talaia': [38.8814, 1.2939],
|
||||
'Sant Antoni de Portmany': [38.9808, 1.3036],
|
||||
'Sant Joan de Labritja': [39.0678, 1.5],
|
||||
'Santa Eulària des Riu': [38.9847, 1.535],
|
||||
'Formentera': [38.6996, 1.4353],
|
||||
};
|
||||
|
||||
export const fixtureAvisos: Aviso[] = [
|
||||
{
|
||||
id: 1,
|
||||
origen: 'boib',
|
||||
titulo:
|
||||
'Informació pública del projecte de regeneració de la posidonia a Talamanca',
|
||||
resumen:
|
||||
'Es sotmet a informació pública el projecte de seguiment i protecció de les praderies de posidonia oceànica a la badia de Talamanca durant els propers dos anys.',
|
||||
temas: ['posidonia', 'litoral', 'biodiversitat'],
|
||||
fuente: 'Conselleria de Medi Ambient',
|
||||
municipio: null,
|
||||
url: 'https://www.caib.es/eboibfront',
|
||||
fecha: '2026-07-18T00:00:00.000Z',
|
||||
numeroEdicte: '2026/12345',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
origen: 'prensa',
|
||||
titulo: 'GEN-GOB alerta del aumento de fondeos ilegales sobre posidonia',
|
||||
resumen:
|
||||
'La asociación ecologista documenta un repunte de fondeos no autorizados en Cala Vedella y pide más vigilancia durante la temporada alta.',
|
||||
temas: ['posidonia', 'fondeos', 'vigilancia'],
|
||||
fuente: 'Diario de Ibiza',
|
||||
municipio: 'Sant Josep de sa Talaia',
|
||||
url: 'https://www.diariodeibiza.es',
|
||||
fecha: '2026-07-20T09:00:00.000Z',
|
||||
numeroEdicte: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
origen: 'prensa',
|
||||
titulo: 'El Consell aprueba restricciones de aforo en Ses Salines',
|
||||
resumen:
|
||||
'El parque natural limitará el número de visitantes diarios en los accesos más frecuentados para reducir la presión sobre las dunas.',
|
||||
temas: ['espacios naturales', 'masificación'],
|
||||
fuente: 'Periódico de Ibiza',
|
||||
municipio: 'Sant Josep de sa Talaia',
|
||||
url: 'https://www.periodicodeibiza.es',
|
||||
fecha: '2026-07-19T11:00:00.000Z',
|
||||
numeroEdicte: null,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
origen: 'boib',
|
||||
titulo: 'Convocatòria d’ajuts a la instal·lació d’energia solar fotovoltaica',
|
||||
resumen:
|
||||
'S’obre la convocatòria d’ajuts destinats a particulars i comunitats de propietaris per a la instal·lació de plaques fotovoltaiques a Eivissa i Formentera.',
|
||||
temas: ['energía', 'ayudas públicas'],
|
||||
fuente: 'Consell d’Eivissa',
|
||||
municipio: null,
|
||||
url: 'https://www.caib.es/eboibfront',
|
||||
fecha: '2026-07-15T00:00:00.000Z',
|
||||
numeroEdicte: '2026/12210',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
origen: 'prensa',
|
||||
titulo: 'Vecinos de Sant Joan piden más control sobre las extracciones de agua',
|
||||
resumen:
|
||||
'Un grupo de agricultores del norte de la isla denuncia el descenso del nivel freático y reclama un control más estricto de los pozos.',
|
||||
temas: ['agua', 'agricultura'],
|
||||
fuente: 'Nou Diari',
|
||||
municipio: 'Sant Joan de Labritja',
|
||||
url: 'https://www.noudiari.es',
|
||||
fecha: '2026-07-14T08:30:00.000Z',
|
||||
numeroEdicte: null,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
origen: 'prensa',
|
||||
titulo: 'Formentera estrena un plan de movilidad para reducir el tráfico en verano',
|
||||
resumen:
|
||||
'El Consell Insular pone en marcha un nuevo sistema de lanzaderas eléctricas para descongestionar Es Pujols y La Savina en agosto.',
|
||||
temas: ['movilidad', 'energía'],
|
||||
fuente: 'Diario de Ibiza',
|
||||
municipio: 'Formentera',
|
||||
url: 'https://www.diariodeibiza.es',
|
||||
fecha: '2026-07-12T07:00:00.000Z',
|
||||
numeroEdicte: null,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,15 @@
|
||||
export type Origen = 'boib' | 'prensa';
|
||||
|
||||
/** Forma unificada que consume el frontend, venga de boib_entradas o de noticias_prensa. */
|
||||
export interface Aviso {
|
||||
id: number;
|
||||
origen: Origen;
|
||||
titulo: string;
|
||||
resumen: string; // resumen (BOIB) o resumen_ia (prensa) — nunca el cuerpo original
|
||||
temas: string[];
|
||||
fuente: string; // organismo (BOIB) o fuente (prensa)
|
||||
municipio: string | null;
|
||||
url: string;
|
||||
fecha: string; // ISO — boib_fecha o fecha_publicacion/fecha_ingesta
|
||||
numeroEdicte: string | null; // solo BOIB, es el "sello" visual distintivo
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
---
|
||||
|
||||
<BaseLayout title="Huella — Ibiza Environmental Watch">
|
||||
<section class="huella">
|
||||
<h1>La huella d'aquesta pàgina</h1>
|
||||
<p class="lead">
|
||||
Aquesta secció es completarà amb dades reals un cop el lloc estigui desplegat
|
||||
(pes per pàgina, estimació de CO2 amb <a href="https://www.websitecarbon.com/" target="_blank" rel="noopener">Website Carbon</a>
|
||||
i <a href="https://ecograder.com/" target="_blank" rel="noopener">EcoGrader</a>). Mentrestant, aquestes són les
|
||||
decisions ja preses i per què.
|
||||
</p>
|
||||
|
||||
<dl>
|
||||
<dt>Sortida 100% estàtica</dt>
|
||||
<dd>
|
||||
Astro genera HTML en build-time. No hi ha cap procés Node ni cap API corrent 24/7 en
|
||||
producció — només fitxers estàtics servits per Caddy.
|
||||
</dd>
|
||||
|
||||
<dt>Zero tipografies web</dt>
|
||||
<dd>
|
||||
Tota la tipografia és de sistema (Iowan Old Style / Palatino / Georgia per als títols,
|
||||
la pila del sistema per al cos). Cero peticions de fonts.
|
||||
</dd>
|
||||
|
||||
<dt>JavaScript condicional segons dispositiu</dt>
|
||||
<dd>
|
||||
El mapa (Leaflet + Preact) només es descarrega en pantalles d'escriptori
|
||||
(<code>client:media="(min-width: 900px)"</code>). En mòbil, la vista és una llista
|
||||
filtrable amb JavaScript mínim i sense dependències.
|
||||
</dd>
|
||||
|
||||
<dt>Sense trackers</dt>
|
||||
<dd>
|
||||
No hi ha analítica de tercers. Si en el futur cal mesurar visites, s'utilitzarà
|
||||
Plausible o Umami autoallotjats, mai Google Analytics.
|
||||
</dd>
|
||||
|
||||
<dt>Allotjament</dt>
|
||||
<dd>
|
||||
Hetzner Cloud, centre de dades de Hèlsinki (energia 100% renovable segons el
|
||||
<a href="https://www.thegreenwebfoundation.org/" target="_blank" rel="noopener">Green Web Foundation directory</a>).
|
||||
</dd>
|
||||
|
||||
<dt>Reconstrucció automàtica, no contínua</dt>
|
||||
<dd>
|
||||
El lloc es regenera només quan n8n confirma un nou lot de classificació (webhook de
|
||||
deploy a Coolify), no en cada canvi ni amb un cron freqüent.
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<p class="compromiso">
|
||||
<strong>Compromís imperfecte:</strong> els tiles del mapa venen de CARTO (CDN extern), no
|
||||
autoallotjats. És l'excepció actual perquè autoallotjar tiles vectorials de tot
|
||||
Eivissa i Formentera no compensa encara el cost de manteniment per aquest projecte. Es
|
||||
documenta aquí en comptes d'amagar-ho.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.huella {
|
||||
max-width: 42rem;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-3) var(--space-2) var(--space-4);
|
||||
}
|
||||
h1 {
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
.lead {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
dt {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--posidonia-deep);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
dd {
|
||||
margin: 0.35rem 0 0;
|
||||
}
|
||||
.compromiso {
|
||||
margin-top: var(--space-3);
|
||||
padding: var(--space-2);
|
||||
border-left: 3px solid var(--stamp);
|
||||
background: var(--paper-raised);
|
||||
}
|
||||
</style>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import EntryCard from '../components/EntryCard.astro';
|
||||
import Filters from '../components/Filters.astro';
|
||||
import MapView from '../components/MapView.tsx';
|
||||
import { getAvisosAmbientales } from '../lib/db';
|
||||
|
||||
const avisos = await getAvisosAmbientales();
|
||||
|
||||
const municipios = [...new Set(avisos.map((a) => a.municipio).filter(Boolean))].sort() as string[];
|
||||
const temas = [...new Set(avisos.flatMap((a) => a.temas))].sort();
|
||||
const fuentes = [...new Set(avisos.map((a) => a.fuente))].sort();
|
||||
---
|
||||
|
||||
<BaseLayout title="Ibiza Environmental Watch — Avisos ambientals d'Eivissa i Formentera">
|
||||
<section class="map-section">
|
||||
<MapView avisos={avisos} client:media="(min-width: 900px)" />
|
||||
</section>
|
||||
|
||||
<section class="feed">
|
||||
<Filters municipios={municipios} temas={temas} fuentes={fuentes} />
|
||||
|
||||
{avisos.length === 0 && (
|
||||
<p class="empty">No hi ha avisos ambientals classificats encara. Torna més tard.</p>
|
||||
)}
|
||||
|
||||
<div class="feed__list">
|
||||
{avisos.map((aviso) => <EntryCard aviso={aviso} />)}
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.map-section {
|
||||
display: none;
|
||||
max-width: 72rem;
|
||||
margin: var(--space-3) auto 0;
|
||||
padding: 0 var(--space-2);
|
||||
}
|
||||
@media (min-width: 900px) {
|
||||
.map-section {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
.map-section :global(.map-view) {
|
||||
height: 420px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.feed {
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-3) var(--space-2) var(--space-4);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--ink-soft);
|
||||
padding: var(--space-2);
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
Sistema de diseño — Ibiza Environmental Watch
|
||||
|
||||
Decisión deliberada: cero tipografías web. Solo pilas de fuentes de
|
||||
sistema. No es una limitación técnica, es una decisión de green software:
|
||||
la fuente más ligera es la que no se descarga. El aspecto "de gaceta
|
||||
oficial" se consigue con una serie serif del sistema (Iowan/Palatino/
|
||||
Georgia), no con una webfont importada.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Color — piedra caliza, tinta de pino, verde posidonia, sello de lacre */
|
||||
--paper: #efeae0;
|
||||
--paper-raised: #f8f5ee;
|
||||
--ink: #1f2a24;
|
||||
--ink-soft: #4a5751;
|
||||
--line: #d7cfbd;
|
||||
--posidonia: #2f6e51;
|
||||
--posidonia-deep: #1d4a37;
|
||||
--stamp: #a8402f;
|
||||
|
||||
--font-display: 'Iowan Old Style', 'Palatino Linotype', 'URW Palladio L', Georgia, serif;
|
||||
--font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: ui-monospace, 'SF Mono', 'Cascadia Code', 'Consolas', 'Liberation Mono', monospace;
|
||||
|
||||
--radius: 2px; /* casi sin redondeo: aspecto de documento, no de app */
|
||||
--space-1: 0.5rem;
|
||||
--space-2: 1rem;
|
||||
--space-3: 1.75rem;
|
||||
--space-4: 3rem;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-body);
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--posidonia-deep);
|
||||
}
|
||||
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible {
|
||||
outline: 3px solid var(--posidonia);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user