feat(seo+a11y): JSON-LD/OG, accessible filters/map, 404 and social card
SEO - BaseLayout: canonical, hreflang, author, extended robots, full OG and Twitter tags with default social image. - Add JSON-LD @graph: Organization + WebSite + page-specific type; homepage adds ItemList with one Article per aviso. - Add public/og-default.svg (1200x630) used as fallback social card. - New src/pages/404.astro with noindex meta. - Filters now use fieldset+legend and aria-controls; counter is role=status with initial SSR value; noscript fallback included. - Filter pills taller (min-height 2.25rem) and switch from style.display to the hidden attribute. Accessibility - Map markers become keyboard accessible (role=button, tabindex, aria-label, aria-keyshortcuts); opening a popup moves focus to its first link and returns focus on close. Map container upgraded to role=region with aria-describedby. - Page introduces a textual per-municipio summary as the accessible equivalent of the map. - Focus-visible outline extended to summary and [tabindex]; outline contrast tuned for active filter pills and Leaflet interactive layer. - EntryCard exposes id+aria-labelledby; tema list now has aria-label; stamp color tightened for AA contrast. - BaseLayout: header gains an aria-labelled nav with aria-current; main gets a focus ring when targeted by the skip link. - Markers read their colors from CSS custom properties instead of hardcoded hex.
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
|
||||
<title>Ibiza Environmental Watch</title>
|
||||
<rect width="1200" height="630" fill="#efeae0"/>
|
||||
<rect x="54" y="54" width="1092" height="522" fill="#f8f5ee" stroke="#1f2a24" stroke-width="3"/>
|
||||
<path d="M118 190c54-132 118-184 300-212-91 61-137 136-164 267-45-15-91-26-136-55z" fill="#2f6e51" transform="translate(0 96)"/>
|
||||
<text x="118" y="354" fill="#1f2a24" font-family="Georgia, serif" font-size="62" font-weight="700">Ibiza</text>
|
||||
<text x="310" y="354" fill="#1d4a37" font-family="Georgia, serif" font-size="62" font-style="italic" font-weight="700">Environmental</text>
|
||||
<text x="118" y="425" fill="#1f2a24" font-family="Georgia, serif" font-size="62" font-weight="700">Watch</text>
|
||||
<line x1="118" y1="478" x2="1082" y2="478" stroke="#d7cfbd" stroke-width="3"/>
|
||||
<text x="118" y="530" fill="#4a5751" font-family="Arial, sans-serif" font-size="25">Vigilància ambiental d'Eivissa i Formentera</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1010 B |
@@ -5,6 +5,8 @@ interface Props {
|
||||
aviso: Aviso;
|
||||
}
|
||||
const { aviso } = Astro.props;
|
||||
const entryId = `aviso-${aviso.origen}-${aviso.id}`;
|
||||
const titleId = `${entryId}-title`;
|
||||
|
||||
const fecha = new Date(aviso.fecha).toLocaleDateString('ca-ES', {
|
||||
day: 'numeric',
|
||||
@@ -14,7 +16,9 @@ const fecha = new Date(aviso.fecha).toLocaleDateString('ca-ES', {
|
||||
---
|
||||
|
||||
<article
|
||||
id={entryId}
|
||||
class="entry"
|
||||
aria-labelledby={titleId}
|
||||
data-municipio={aviso.municipio ?? ''}
|
||||
data-temas={aviso.temas.join('|')}
|
||||
data-fuente={aviso.fuente}
|
||||
@@ -27,7 +31,7 @@ const fecha = new Date(aviso.fecha).toLocaleDateString('ca-ES', {
|
||||
)
|
||||
}
|
||||
|
||||
<h2 class="entry__title">
|
||||
<h2 id={titleId} class="entry__title">
|
||||
<a href={aviso.url} target="_blank" rel="noopener noreferrer">{aviso.titulo}<span class="visually-hidden"> (s'obre en una pestanya nova)</span></a>
|
||||
</h2>
|
||||
|
||||
@@ -35,7 +39,7 @@ const fecha = new Date(aviso.fecha).toLocaleDateString('ca-ES', {
|
||||
|
||||
{
|
||||
aviso.temas.length > 0 && (
|
||||
<ul class="entry__temas">
|
||||
<ul class="entry__temas" aria-label="Temes de l'avís">
|
||||
{aviso.temas.map((t) => (
|
||||
<li>{t}</li>
|
||||
))}
|
||||
@@ -81,7 +85,10 @@ const fecha = new Date(aviso.fecha).toLocaleDateString('ca-ES', {
|
||||
}
|
||||
.entry__title a {
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--posidonia);
|
||||
text-decoration-thickness: 0.08em;
|
||||
text-underline-offset: 0.15em;
|
||||
}
|
||||
.entry__title a:hover {
|
||||
color: var(--posidonia-deep);
|
||||
@@ -107,7 +114,7 @@ const fecha = new Date(aviso.fecha).toLocaleDateString('ca-ES', {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--posidonia-deep);
|
||||
background: color-mix(in srgb, var(--posidonia) 12%, transparent);
|
||||
background: var(--posidonia-pale);
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@@ -3,36 +3,41 @@ interface Props {
|
||||
municipios: string[];
|
||||
temas: string[];
|
||||
fuentes: string[];
|
||||
total: number;
|
||||
}
|
||||
const { municipios, temas, fuentes } = Astro.props;
|
||||
const { municipios, temas, fuentes, total } = Astro.props;
|
||||
const initialCount = `${total} avís${total === 1 ? '' : 'os'} ambiental${total === 1 ? '' : 's'}`;
|
||||
---
|
||||
|
||||
<div class="filters" id="filters">
|
||||
<div class="filters__group">
|
||||
<span class="filters__label" id="filter-label-municipi">Municipi</span>
|
||||
<div class="filters__pills" role="group" aria-labelledby="filter-label-municipi" data-filter-type="municipio">
|
||||
<button type="button" class="pill is-active" aria-pressed="true" data-value="">Tots</button>
|
||||
{municipios.map((m) => <button type="button" class="pill" aria-pressed="false" data-value={m}>{m}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="filters" id="filters" aria-describedby="filters-help">
|
||||
<p class="filters__help" id="filters-help">Selecciona una opció de cada grup per limitar els avisos.</p>
|
||||
|
||||
<div class="filters__group">
|
||||
<span class="filters__label" id="filter-label-temes">Tema</span>
|
||||
<div class="filters__pills" role="group" aria-labelledby="filter-label-temes" data-filter-type="tema">
|
||||
<button type="button" class="pill is-active" aria-pressed="true" data-value="">Tots</button>
|
||||
{temas.map((t) => <button type="button" class="pill" aria-pressed="false" data-value={t}>{t}</button>)}
|
||||
<fieldset class="filters__group">
|
||||
<legend class="filters__label">Municipi</legend>
|
||||
<div class="filters__pills" role="group" aria-label="Filtra per municipi" data-filter-type="municipio">
|
||||
<button type="button" class="pill is-active" aria-pressed="true" aria-controls="feed-list" data-value="">Tots</button>
|
||||
{municipios.map((m) => <button type="button" class="pill" aria-pressed="false" aria-controls="feed-list" data-value={m}>{m}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="filters__group">
|
||||
<span class="filters__label" id="filter-label-fonts">Font</span>
|
||||
<div class="filters__pills" role="group" aria-labelledby="filter-label-fonts" data-filter-type="fuente">
|
||||
<button type="button" class="pill is-active" aria-pressed="true" data-value="">Totes</button>
|
||||
{fuentes.map((f) => <button type="button" class="pill" aria-pressed="false" data-value={f}>{f}</button>)}
|
||||
<fieldset class="filters__group">
|
||||
<legend class="filters__label">Tema</legend>
|
||||
<div class="filters__pills" role="group" aria-label="Filtra per tema" data-filter-type="tema">
|
||||
<button type="button" class="pill is-active" aria-pressed="true" aria-controls="feed-list" data-value="">Tots</button>
|
||||
{temas.map((t) => <button type="button" class="pill" aria-pressed="false" aria-controls="feed-list" data-value={t}>{t}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<p class="filters__count" id="filters-count" aria-live="polite"></p>
|
||||
<fieldset class="filters__group">
|
||||
<legend class="filters__label">Font</legend>
|
||||
<div class="filters__pills" role="group" aria-label="Filtra per font" data-filter-type="fuente">
|
||||
<button type="button" class="pill is-active" aria-pressed="true" aria-controls="feed-list" data-value="">Totes</button>
|
||||
{fuentes.map((f) => <button type="button" class="pill" aria-pressed="false" aria-controls="feed-list" data-value={f}>{f}</button>)}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<p class="filters__count" id="filters-count" role="status" aria-live="polite" aria-atomic="true">{initialCount}</p>
|
||||
<noscript><p class="filters__no-script">Activa JavaScript per utilitzar els filtres interactius. Tots els avisos es mostren a continuació.</p></noscript>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -44,11 +49,23 @@ const { municipios, temas, fuentes } = Astro.props;
|
||||
border-radius: var(--radius);
|
||||
background: var(--paper-raised);
|
||||
}
|
||||
.filters__help {
|
||||
margin: 0 0 var(--space-2);
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.filters__group {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
.filters__group + .filters__group {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
.filters__label {
|
||||
display: block;
|
||||
width: 100%;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
@@ -62,6 +79,7 @@ const { municipios, temas, fuentes } = Astro.props;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.pill {
|
||||
min-height: 2.25rem;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
background: var(--paper);
|
||||
@@ -76,11 +94,22 @@ const { municipios, temas, fuentes } = Astro.props;
|
||||
border-color: var(--posidonia-deep);
|
||||
color: var(--paper);
|
||||
}
|
||||
.pill:focus-visible {
|
||||
outline-color: var(--ink);
|
||||
}
|
||||
.pill.is-active:focus-visible {
|
||||
outline-color: var(--paper);
|
||||
}
|
||||
.filters__count {
|
||||
margin: var(--space-1) 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.filters__no-script {
|
||||
margin: var(--space-1) 0 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
@@ -105,7 +134,7 @@ const { municipios, temas, fuentes } = Astro.props;
|
||||
(!state.tema || temas.includes(state.tema)) &&
|
||||
(!state.fuente || fuente === state.fuente);
|
||||
|
||||
entry.style.display = matches ? '' : 'none';
|
||||
entry.hidden = !matches;
|
||||
if (matches) visible += 1;
|
||||
}
|
||||
if (countEl) {
|
||||
|
||||
@@ -5,21 +5,23 @@ import { municipioCoords } from '../lib/fixtures';
|
||||
|
||||
interface Props {
|
||||
avisos: Aviso[];
|
||||
descriptionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
export default function MapView({ avisos, descriptionId }: Props) {
|
||||
const mapEl = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let map: import('leaflet').Map | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
const L = await import('leaflet');
|
||||
if (!mapEl.current) return;
|
||||
if (cancelled || !mapEl.current) return;
|
||||
|
||||
map = L.map(mapEl.current, {
|
||||
center: [38.95, 1.43],
|
||||
@@ -32,6 +34,10 @@ export default function MapView({ avisos }: Props) {
|
||||
maxZoom: 18,
|
||||
}).addTo(map);
|
||||
|
||||
const rootStyles = getComputedStyle(document.documentElement);
|
||||
const markerColor = rootStyles.getPropertyValue('--posidonia-deep').trim() || '#1d4a37';
|
||||
const markerFillColor = rootStyles.getPropertyValue('--posidonia').trim() || '#2f6e51';
|
||||
|
||||
// Agrupar avisos por municipio
|
||||
const porMunicipio = new Map<string, Aviso[]>();
|
||||
for (const a of avisos) {
|
||||
@@ -48,32 +54,74 @@ export default function MapView({ avisos }: Props) {
|
||||
const radius = 8 + Math.min(list.length, 10) * 2;
|
||||
const marker = L.circleMarker(coords, {
|
||||
radius,
|
||||
color: '#1d4a37',
|
||||
color: markerColor,
|
||||
weight: 1,
|
||||
fillColor: '#2f6e51',
|
||||
fillColor: markerFillColor,
|
||||
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>`
|
||||
`<li><a href="${escapeHtml(a.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(a.titulo)}<span class="visually-hidden"> (s'obre en una pestanya nova)</span></a></li>`
|
||||
)
|
||||
.join('');
|
||||
const countLabel = `${list.length} avís${list.length === 1 ? '' : 'os'} ambiental${list.length === 1 ? '' : 's'}`;
|
||||
const markerLabel = `${municipio}: ${countLabel}`;
|
||||
|
||||
let focusPopupLink = false;
|
||||
let restoreMarkerFocus = false;
|
||||
marker.bindPopup(
|
||||
`<strong>${escapeHtml(municipio)}</strong> — ${list.length} avís${list.length === 1 ? '' : 'os'}<ul>${items}</ul>`
|
||||
`<strong>${escapeHtml(municipio)}</strong> — ${countLabel}<ul>${items}</ul>`
|
||||
);
|
||||
marker.on('popupopen', () => {
|
||||
if (!focusPopupLink) return;
|
||||
focusPopupLink = false;
|
||||
marker.getPopup()?.getElement()?.querySelector<HTMLAnchorElement>('a')?.focus();
|
||||
});
|
||||
marker.on('popupclose', () => {
|
||||
if (!restoreMarkerFocus) return;
|
||||
restoreMarkerFocus = false;
|
||||
(marker.getElement() as HTMLElement | undefined)?.focus();
|
||||
});
|
||||
|
||||
marker.on('add', () => {
|
||||
const element = marker.getElement();
|
||||
if (!element) return;
|
||||
element.setAttribute('role', 'button');
|
||||
element.setAttribute('tabindex', '0');
|
||||
element.setAttribute('aria-label', markerLabel);
|
||||
element.setAttribute('aria-keyshortcuts', 'Enter Space');
|
||||
element.addEventListener('keydown', (event) => {
|
||||
const key = (event as KeyboardEvent).key;
|
||||
if (key !== 'Enter' && key !== ' ') return;
|
||||
event.preventDefault();
|
||||
focusPopupLink = true;
|
||||
restoreMarkerFocus = true;
|
||||
marker.openPopup();
|
||||
});
|
||||
});
|
||||
marker.addTo(map);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
map?.remove();
|
||||
};
|
||||
}, [avisos]);
|
||||
|
||||
return <div ref={mapEl} className="map-view" role="img" aria-label="Mapa d'avisos ambientals per municipi" />;
|
||||
return (
|
||||
<div
|
||||
ref={mapEl}
|
||||
className="map-view"
|
||||
role="region"
|
||||
aria-label="Mapa d'avisos ambientals per municipi"
|
||||
aria-describedby={descriptionId}
|
||||
tabIndex={0}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function escapeHtml(s: string) {
|
||||
|
||||
+101
-11
@@ -5,33 +5,88 @@ interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
ogImage?: string;
|
||||
ogImageAlt?: string;
|
||||
canonicalPath?: string;
|
||||
ogType?: 'website' | 'article';
|
||||
pageType?: 'WebPage' | 'CollectionPage' | 'AboutPage' | 'Article';
|
||||
structuredData?: Record<string, unknown> | Record<string, unknown>[];
|
||||
robots?: string;
|
||||
}
|
||||
const {
|
||||
title,
|
||||
description = "Vigilància ambiental d'Eivissa i Formentera: BOIB i premsa local, classificats amb IA local.",
|
||||
ogImage,
|
||||
ogImageAlt = 'Ibiza Environmental Watch',
|
||||
canonicalPath,
|
||||
ogType = 'website',
|
||||
pageType = 'WebPage',
|
||||
structuredData,
|
||||
robots = 'index, follow, max-image-preview:large',
|
||||
} = Astro.props;
|
||||
|
||||
const canonicalUrl = new URL(canonicalPath ?? Astro.url.pathname, Astro.site).href;
|
||||
const siteUrl = (Astro.site ?? new URL(Astro.url.origin)).toString().replace(/\/$/, '');
|
||||
const canonicalUrl = new URL(canonicalPath ?? Astro.url.pathname, siteUrl).href;
|
||||
const socialImageUrl = new URL(ogImage ?? '/og-default.svg', siteUrl).href;
|
||||
const logoUrl = new URL('/favicon.svg', siteUrl).href;
|
||||
const isHome = Astro.url.pathname === '/';
|
||||
const isHuella = Astro.url.pathname === '/huella';
|
||||
const additionalSchemas = structuredData
|
||||
? Array.isArray(structuredData)
|
||||
? structuredData
|
||||
: [structuredData]
|
||||
: [];
|
||||
const jsonLd = JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [
|
||||
{
|
||||
'@type': 'Organization',
|
||||
'@id': `${siteUrl}#organization`,
|
||||
name: 'Ibiza Environmental Watch',
|
||||
url: siteUrl,
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: logoUrl,
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'WebSite',
|
||||
'@id': `${siteUrl}#website`,
|
||||
name: 'Ibiza Environmental Watch',
|
||||
url: siteUrl,
|
||||
description,
|
||||
inLanguage: 'ca-ES',
|
||||
publisher: { '@id': `${siteUrl}#organization` },
|
||||
},
|
||||
{
|
||||
'@type': pageType,
|
||||
'@id': `${canonicalUrl}#webpage`,
|
||||
name: title,
|
||||
url: canonicalUrl,
|
||||
description,
|
||||
inLanguage: 'ca-ES',
|
||||
isPartOf: { '@id': `${siteUrl}#website` },
|
||||
},
|
||||
...additionalSchemas,
|
||||
],
|
||||
}).replace(/</g, '\\u003c');
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="ca">
|
||||
<html lang="ca" dir="ltr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta name="author" content="Ibiza Environmental Watch" />
|
||||
<meta name="robots" content={robots} />
|
||||
<meta name="theme-color" content="#1d4a37" />
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="canonical" href={canonicalUrl} />
|
||||
<link rel="alternate" hreflang="ca" href={canonicalUrl} />
|
||||
<link rel="alternate" hreflang="x-default" href={canonicalUrl} />
|
||||
<link rel="sitemap" type="application/xml" href="/sitemap-index.xml" />
|
||||
<link rel="preconnect" href="https://basemaps.cartocdn.com" crossorigin />
|
||||
<link rel="alternate" type="application/rss+xml" title="Ibiza Environmental Watch" href="/rss.xml" />
|
||||
|
||||
@@ -41,24 +96,35 @@ const isHome = Astro.url.pathname === '/';
|
||||
<meta property="og:type" content={ogType} />
|
||||
<meta property="og:site_name" content="Ibiza Environmental Watch" />
|
||||
<meta property="og:locale" content="ca_ES" />
|
||||
{ogImage && <meta property="og:image" content={new URL(ogImage, Astro.site).href} />}
|
||||
<meta property="og:image" content={socialImageUrl} />
|
||||
<meta property="og:image:alt" content={ogImageAlt} />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
{ogImage && <meta name="twitter:image" content={new URL(ogImage, Astro.site).href} />}
|
||||
<meta name="twitter:image" content={socialImageUrl} />
|
||||
<meta name="twitter:image:alt" content={ogImageAlt} />
|
||||
<script is:inline type="application/ld+json" set:html={jsonLd}></script>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main-content">Saltar al contingut</a>
|
||||
|
||||
<header class="masthead">
|
||||
<div class="masthead__inner">
|
||||
<a class="masthead__title" href="/" aria-current={isHome ? 'page' : undefined}>
|
||||
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 class="masthead__brand">
|
||||
<a class="masthead__title" href="/" aria-label="Ibiza Environmental Watch, inici">
|
||||
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>
|
||||
<nav class="masthead__nav" aria-label="Navegació principal">
|
||||
<a href="/" aria-current={isHome ? 'page' : undefined}>Inici</a>
|
||||
<a href="/huella" aria-current={isHuella ? 'page' : undefined}>Huella d'aquesta pàgina</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -107,6 +173,9 @@ const isHome = Astro.url.pathname === '/';
|
||||
margin: 0 auto;
|
||||
padding: var(--space-3) var(--space-2) var(--space-2);
|
||||
}
|
||||
.masthead__brand {
|
||||
max-width: 42rem;
|
||||
}
|
||||
.masthead__title {
|
||||
display: block;
|
||||
font-family: var(--font-display);
|
||||
@@ -129,6 +198,27 @@ const isHome = Astro.url.pathname === '/';
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.masthead__nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1) var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.masthead__nav a {
|
||||
color: var(--ink);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
.masthead__nav a[aria-current='page'] {
|
||||
color: var(--posidonia-deep);
|
||||
text-decoration-thickness: 0.15em;
|
||||
}
|
||||
|
||||
main:focus {
|
||||
outline: 3px solid var(--posidonia);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
border-top: 1px solid var(--line);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Pàgina no trobada — Ibiza Environmental Watch"
|
||||
description="La pàgina que cerques no existeix."
|
||||
robots="noindex, follow"
|
||||
>
|
||||
<section class="not-found" aria-labelledby="not-found-title">
|
||||
<p class="not-found__code">404</p>
|
||||
<h1 id="not-found-title">Aquesta pàgina no existeix</h1>
|
||||
<p>No hem trobat l'adreça sol·licitada. Torna a l'inici per consultar els avisos ambientals.</p>
|
||||
<a class="not-found__link" href="/">Torna als avisos ambientals</a>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.not-found {
|
||||
max-width: 42rem;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-4) var(--space-2);
|
||||
}
|
||||
.not-found__code {
|
||||
margin: 0;
|
||||
color: var(--stamp);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
h1 {
|
||||
margin: var(--space-1) 0;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
.not-found__link {
|
||||
display: inline-block;
|
||||
margin-top: var(--space-1);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</BaseLayout>
|
||||
@@ -2,7 +2,11 @@
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
---
|
||||
|
||||
<BaseLayout title="Huella — Ibiza Environmental Watch">
|
||||
<BaseLayout
|
||||
title="Huella — Ibiza Environmental Watch"
|
||||
description="Decisions de rendiment, allotjament i sostenibilitat d'Ibiza Environmental Watch."
|
||||
pageType="AboutPage"
|
||||
>
|
||||
<section class="huella">
|
||||
<h1>La huella d'aquesta pàgina</h1>
|
||||
<p class="lead">
|
||||
|
||||
+137
-17
@@ -10,57 +10,177 @@ 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();
|
||||
const avisosPorMunicipio = new Map<string, number>();
|
||||
for (const aviso of avisos) {
|
||||
const municipio = aviso.municipio ?? 'Municipi no especificat';
|
||||
avisosPorMunicipio.set(municipio, (avisosPorMunicipio.get(municipio) ?? 0) + 1);
|
||||
}
|
||||
const resumenMunicipios = [...avisosPorMunicipio.entries()].sort(([a], [b]) => a.localeCompare(b, 'ca'));
|
||||
const itemListSchema = {
|
||||
'@type': 'ItemList',
|
||||
name: 'Avisos ambientals d’Eivissa i Formentera',
|
||||
numberOfItems: avisos.length,
|
||||
itemListElement: avisos.map((aviso, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
item: {
|
||||
'@type': 'Article',
|
||||
headline: aviso.titulo,
|
||||
description: aviso.resumen,
|
||||
url: aviso.url,
|
||||
datePublished: aviso.fecha,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: aviso.fuente,
|
||||
},
|
||||
},
|
||||
})),
|
||||
};
|
||||
---
|
||||
|
||||
<BaseLayout title="Ibiza Environmental Watch — Avisos ambientals d'Eivissa i Formentera">
|
||||
<section class="map-section">
|
||||
<MapView avisos={avisos} client:media="(min-width: 900px)" />
|
||||
<BaseLayout
|
||||
title="Avisos ambientals d'Eivissa i Formentera | Ibiza Environmental Watch"
|
||||
description="Avisos ambientals d'Eivissa i Formentera: informació del BOIB i de la premsa local, filtrada per municipi, tema i font."
|
||||
pageType="CollectionPage"
|
||||
structuredData={itemListSchema}
|
||||
>
|
||||
<div class="home-heading">
|
||||
<h1 id="page-title" class="page-title">Avisos ambientals d'Eivissa i Formentera</h1>
|
||||
<p class="page-intro">
|
||||
Consulta informació del BOIB i de la premsa local sobre el medi ambient de les illes. Filtra els avisos per municipi, tema o font.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section class="map-section" aria-labelledby="map-title">
|
||||
<h2 id="map-title">Mapa d'avisos per municipi</h2>
|
||||
<p id="map-description" class="map-description">
|
||||
Mapa interactiu amb un marcador per municipi. Els marcadors es poden activar amb el teclat; també tens un resum textual a continuació.
|
||||
</p>
|
||||
<MapView avisos={avisos} descriptionId="map-description" client:media="(min-width: 900px)" />
|
||||
</section>
|
||||
|
||||
<section class="feed">
|
||||
<h1 class="feed__title">Avisos ambientals d'Eivissa i Formentera</h1>
|
||||
<Filters municipios={municipios} temas={temas} fuentes={fuentes} />
|
||||
<section class="map-alternative" aria-labelledby="map-alternative-title">
|
||||
<h2 id="map-alternative-title">Resum d'avisos per municipi</h2>
|
||||
<p>El resum textual ofereix la mateixa informació de distribució que el mapa.</p>
|
||||
<ul>
|
||||
{resumenMunicipios.map(([municipio, total]) => (
|
||||
<li><span>{municipio}</span><span>{total} {total === 1 ? 'avís' : 'avisos'}</span></li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="feed" aria-labelledby="page-title">
|
||||
<Filters municipios={municipios} temas={temas} fuentes={fuentes} total={avisos.length} />
|
||||
|
||||
{avisos.length === 0 && (
|
||||
<p class="empty">No hi ha avisos ambientals classificats encara. Torna més tard.</p>
|
||||
)}
|
||||
|
||||
<div class="feed__list">
|
||||
<div class="feed__list" id="feed-list">
|
||||
{avisos.map((aviso) => <EntryCard aviso={aviso} />)}
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.home-heading,
|
||||
.feed,
|
||||
.map-alternative {
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
padding-left: var(--space-2);
|
||||
padding-right: var(--space-2);
|
||||
}
|
||||
|
||||
.home-heading {
|
||||
padding-top: var(--space-3);
|
||||
}
|
||||
|
||||
.page-title,
|
||||
.map-section h2,
|
||||
.map-alternative h2 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: clamp(1.6rem, 3vw, 2rem);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-intro {
|
||||
max-width: 48rem;
|
||||
margin: var(--space-1) 0 0;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.map-section {
|
||||
display: none;
|
||||
max-width: 72rem;
|
||||
margin: var(--space-3) auto 0;
|
||||
padding: 0 var(--space-2);
|
||||
}
|
||||
|
||||
.map-section h2,
|
||||
.map-alternative h2 {
|
||||
font-size: 1.2rem;
|
||||
margin: 0 0 var(--space-1);
|
||||
}
|
||||
|
||||
.map-description {
|
||||
margin: 0 0 var(--space-1);
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@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);
|
||||
.map-alternative {
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
.feed__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 var(--space-2);
|
||||
color: var(--ink);
|
||||
.map-alternative p {
|
||||
margin: 0 0 var(--space-1);
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.map-alternative ul {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||
gap: 0.4rem 1.5rem;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.map-alternative li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-1);
|
||||
padding: 0.35rem 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.map-alternative li span:last-child {
|
||||
color: var(--ink-soft);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.feed {
|
||||
padding-top: var(--space-3);
|
||||
padding-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.empty {
|
||||
|
||||
+10
-2
@@ -17,7 +17,8 @@
|
||||
--line: #d7cfbd;
|
||||
--posidonia: #2f6e51;
|
||||
--posidonia-deep: #1d4a37;
|
||||
--stamp: #a8402f;
|
||||
--posidonia-pale: #dce9df;
|
||||
--stamp: #8a3025;
|
||||
|
||||
--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;
|
||||
@@ -55,11 +56,18 @@ a {
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible {
|
||||
select:focus-visible,
|
||||
summary:focus-visible,
|
||||
[tabindex]:focus-visible {
|
||||
outline: 3px solid var(--posidonia);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.leaflet-container .leaflet-interactive:focus-visible {
|
||||
outline: 3px solid var(--posidonia-deep);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.001ms !important;
|
||||
|
||||
Reference in New Issue
Block a user