// aero-shell.jsx — Site chrome: top nav, observation ticker, footer, // hash-based router. Exposes window.AeroShell, useRoute(). const { useState, useEffect, useMemo, useRef } = React; // ───────────────────────────────────────────────────────────────────── // Tokens — site-wide design system, neutral warm cream chrome. // Page-level palettes (atmospheric, weather-driven) sit INSIDE this. // ───────────────────────────────────────────────────────────────────── const AERO = { paper: '#f3eee2', // canvas paperDeep: '#e8e1cf', ink: '#1b1a17', inkDim: '#4b4940', inkSoft: '#7e7a6a', hair: 'rgba(27,26,23,0.14)', line: 'rgba(27,26,23,0.32)', accent: '#3a4f6b', warm: '#b86a2c', danger: '#a8463a', ok: '#5e7c4a', }; // Inject base CSS once. (function () { if (document.getElementById('aero-base')) return; const s = document.createElement('style'); s.id = 'aero-base'; s.textContent = ` html, body { background: ${AERO.paper}; color: ${AERO.ink}; margin: 0; padding: 0; } body { font-family: 'Geist', -apple-system, system-ui, sans-serif; -webkit-font-smoothing: antialiased; font-feature-settings: 'ss01'; } *::-webkit-scrollbar { width: 6px; height: 6px; } *::-webkit-scrollbar-track { background: transparent; } *::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.18); border-radius: 3px; } a { color: inherit; text-decoration: none; } .aero-mono { font-family: 'JetBrains Mono', ui-monospace, monospace; } .aero-serif { font-family: 'Instrument Serif', 'Iowan Old Style', serif; } .aero-eyebrow { font-family: 'JetBrains Mono', monospace; font-size: 11px; letter-spacing: 1.6px; text-transform: uppercase; color: ${AERO.inkSoft}; } .aero-link { transition: color .15s; } .aero-link:hover { color: ${AERO.warm}; } /* selection */ ::selection { background: ${AERO.warm}; color: ${AERO.paper}; } `; document.head.appendChild(s); })(); // ───────────────────────────────────────────────────────────────────── // Hash router — #/home, #/city/smr, #/radar, #/alerts, #/api // ───────────────────────────────────────────────────────────────────── function parseHash(h) { const raw = (h || "").replace(/^#\/?/, ""); if (!raw) return { name: "home", params: {} }; const [name, ...rest] = raw.split("/"); return { name, params: { id: rest[0] } }; } function useRoute() { const [route, setRoute] = useState(() => parseHash(window.location.hash)); useEffect(() => { const onChange = () => setRoute(parseHash(window.location.hash)); window.addEventListener("hashchange", onChange); return () => window.removeEventListener("hashchange", onChange); }, []); return route; } function navigate(path) { window.location.hash = path; } // ───────────────────────────────────────────────────────────────────── // Logo wordmark — bespoke, draws an isobar through the AEROGRAMA letters // ───────────────────────────────────────────────────────────────────── function Wordmark({ size = 22, color = AERO.ink }) { return (
{/* concentric isobars */} AEROGRAMA
); } // ───────────────────────────────────────────────────────────────────── // Top navigation // ───────────────────────────────────────────────────────────────────── function TopNav({ lang, setLang }) { const route = useRoute(); const items = lang === "pt" ? [ ["home", "Início"], ["city/smr", "Previsão"], ["radar", "Radar"], ["alerts", "Alertas"], ["api", "API"], ] : [ ["home", "Home"], ["city/smr", "Forecast"], ["radar", "Radar"], ["alerts", "Alerts"], ["api", "API"], ]; const active = (slug) => { const base = slug.split("/")[0]; return route.name === base || (base === "home" && (route.name === "" || !route.name)); }; return (
{lang === "pt" ? "Acessar API" : "Get API"}
); } // ───────────────────────────────────────────────────────────────────── // Live observation ticker — slim strip under the nav, current conditions // at featured cities, scrolls horizontally on hover (decorative only). // ───────────────────────────────────────────────────────────────────── function ObservationTicker({ lang }) { const t = window.WX.T[lang]; const defaultItems = [ { city: "Santa Maria", temp: "14.4°", pt: "Garoa intensa", en: "Dense drizzle" }, { city: "Porto Alegre", temp: "15.1°", pt: "Chuva fraca", en: "Light rain" }, { city: "São Paulo", temp: "19.8°", pt: "Nublado", en: "Overcast" }, { city: "Rio de Janeiro", temp: "24.6°", pt: "Parc. nublado", en: "Partly cloudy" }, { city: "Brasília", temp: "21.2°", pt: "Quase limpo", en: "Mainly clear" }, { city: "Manaus", temp: "28.4°", pt: "Chuva", en: "Rain" }, { city: "Recife", temp: "27.0°", pt: "Parc. nublado", en: "Partly cloudy" }, { city: "Lisboa", temp: "16.8°", pt: "Quase limpo", en: "Mainly clear" }, { city: "London", temp: "9.3°", pt: "Neblina", en: "Fog" }, { city: "Tokyo", temp: "22.1°", pt: "Limpo", en: "Clear" }, ]; const [items, setItems] = useState(defaultItems); useEffect(() => { let alive = true; const fetchTicker = async () => { try { const toFetch = window.WX.CITIES.slice(0, 10); // Fallback to default cities if CITIES array is short const results = await Promise.all( toFetch.map(c => window.WX.fetchWeather(c.lat, c.lon, 1).catch(() => null)) ); if (!alive) return; const newItems = results.map((res, i) => { if (!res || !res.current_complete) return defaultItems[i]; const cur = res.current_complete; return { city: toFetch[i].name, temp: cur.temperature_2m.toFixed(1) + "°", pt: cur.weathercode_desc.pt, en: cur.weathercode_desc.en }; }); setItems(newItems); } catch (e) { console.error(e); } }; fetchTicker(); return () => { alive = false; }; }, []); return (
● {lang === "pt" ? "AO VIVO" : "LIVE"}
{items.map((it, i) => ( {it.city} {it.temp} {lang === "pt" ? it.pt : it.en} ))}
{lang === "pt" ? "Atualizado" : "Updated"} {new Date().toLocaleTimeString(lang === "pt" ? "pt-BR" : "en-US", { hour: '2-digit', minute: '2-digit' })} GMT-3
); } // ───────────────────────────────────────────────────────────────────── // Footer // ───────────────────────────────────────────────────────────────────── function Footer({ lang }) { const cols = lang === "pt" ? [ ["Produto", ["Início", "Previsão", "Radar", "Alertas", "Estações"]], ["Plataforma", ["API REST", "Documentação", "Status", "Limites", "Webhooks"]], ["Dados", ["Fontes & licenças", "Modelos numéricos", "Estações INMET", "Imagens GOES-16", "Histórico"]], ["Empresa", ["Sobre", "Equipe", "Contato", "Imprensa", "Carreiras"]], ] : [ ["Product", ["Home", "Forecast", "Radar", "Alerts", "Stations"]], ["Platform", ["REST API", "Documentation", "Status", "Limits", "Webhooks"]], ["Data", ["Sources & licenses", "Numerical models", "INMET stations", "GOES-16 imagery", "Archive"]], ["Company", ["About", "Team", "Contact", "Press", "Careers"]], ]; return ( ); } // ───────────────────────────────────────────────────────────────────── // Page Shell — wraps the active page with chrome // ───────────────────────────────────────────────────────────────────── function AeroShell({ children, lang, setLang }) { return (
{children}
); } // ───────────────────────────────────────────────────────────────────── // Common building blocks // ───────────────────────────────────────────────────────────────────── function Container({ children, style = {} }) { return (
{children}
); } function SectionEyebrow({ children, hint }) { return (
{children}
{hint &&
{hint}
}
); } function TinyChart({ values, width, height, color, fill, smooth = true, strokeWidth = 1.5 }) { // re-uses Sparkline from weather-vis.jsx return ( ); } function CookieConsent({ lang }) { const [show, setShow] = useState(false); useEffect(() => { if (!localStorage.getItem('aerograma_consent')) { setShow(true); } }, []); const handleAccept = () => { localStorage.setItem('aerograma_consent', 'granted'); setShow(false); window.dispatchEvent(new Event('aerograma_consent_granted')); }; const handleDecline = () => { localStorage.setItem('aerograma_consent', 'denied'); setShow(false); }; if (!show) return null; return (
{lang === 'pt' ? "Usamos cookies e localização para personalizar a previsão do tempo para a sua região." : "We use cookies and location to personalize the weather forecast for your area."}
); } Object.assign(window, { AERO, AeroShell, TopNav, ObservationTicker, Footer, Wordmark, Container, SectionEyebrow, TinyChart, CookieConsent, useRoute, navigate, });