chore: initial Astro project scaffold

This commit is contained in:
2026-07-25 11:19:24 +02:00
commit 566168e06f
20 changed files with 4710 additions and 0 deletions
+63
View File
@@ -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
+69
View File
@@ -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.
+14
View File
@@ -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
},
});
+22
View File
@@ -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"
}
}
+3632
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
allowBuilds:
esbuild: false
sharp: false
+4
View File
@@ -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

+40
View File
@@ -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"
}
}
}
+120
View File
@@ -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>
+132
View File
@@ -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>
+81
View File
@@ -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: '&copy; OpenStreetMap, &copy; 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c] as string));
}
+1
View File
@@ -0,0 +1 @@
/// <reference path="../.astro/types.d.ts" />
+100
View File
@@ -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 &mdash; 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>
+80
View File
@@ -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();
}
}
+94
View File
@@ -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 dajuts a la instal·lació denergia solar fotovoltaica',
resumen:
'Sobre la convocatòria dajuts 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 dEivissa',
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,
},
];
+15
View File
@@ -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
}
+92
View File
@@ -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>
+63
View File
@@ -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>
+78
View File
@@ -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;
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact"
}
}