poke/html/weather.ejs
2025-09-28 17:06:42 +02:00

806 lines
28 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!--
This Source Code Form is subject to the terms of the GNU General Public License:
Copyright (C) 2021-2025 PokeWeather Of Poke Project, a part of The Poke Project Initiative (initiative.poketube.fun)
source code :https://codeberg.org/ashley/poke
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see https://www.gnu.org/licenses/.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
<title>PokeWeather</title>
<meta name="theme-color" content="#0ea5e9" />
<meta name="color-scheme" content="dark light" />
<meta content="PokeWeather" property="og:title">
<meta content="View Weather info :3c" property="twitter:description">
<meta content="/static/poke-weather.png" property="og:image" />
<meta content="summary_large_image" name="twitter:card">
<style>
:root {
--bg: #0b0f16;
--fg: #eef3ff;
--muted: #a7b4cc;
--card: #0f1624;
--ring: #ffffff22;
--accent: #0ea5e9;
--good: #7ee787;
--bad: #f97373;
--warn: #fbbf24;
--br: 16px;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
background: radial-gradient(1200px 800px at 20% 10%, #0c1528, var(--bg));
color: var(--fg);
font: 15px/1.4 ui-sans-serif, system-ui, Segoe UI, Roboto, Inter, Arial;
}
@font-face {
src: url("https://p.poketube.fun/https://cdn.glitch.global/43b6691a-c8db-41d4-921c-8cf6aa0d9108/robotoflex.ttf?v=16683434286881");
font-family: "PokeTube Flex";
font-style: normal;
font-stretch: 1% 800%;
font-display: swap;
}
/* Improved, stable navbar (no weird pill stretching) */
header {
position: sticky;
top: 0;
z-index: 40;
}
.navwrap {
backdrop-filter: saturate(1.4) blur(10px);
background: linear-gradient(180deg, rgba(0, 0, 0, 0.35), rgba(0, 0, 0, 0.15));
border-bottom: 1px solid var(--ring);
}
.wrap {
max-width: 1100px;
margin: 0 auto;
padding: 12px 14px;
}
.top {
display: flex;
align-items: center;
gap: 12px;
justify-content: space-between;
min-height: 56px;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
}
.brand .logo {
width: 36px;
height: 36px;
border-radius: 12px;
background: linear-gradient(135deg, #00d2ff, #3a7bd5);
display: grid;
place-items: center;
box-shadow: 0 6px 20px #00d2ff44;
flex: 0 0 36px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--ring);
border-radius: 999px;
padding: 6px 10px;
background: linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.03));
white-space: nowrap;
max-width: 58vw;
overflow: hidden;
text-overflow: ellipsis;
}
.controls {
display: flex;
gap: 8px;
flex-wrap: nowrap;
}
button,
.btn {
border: 1px solid var(--ring);
background: linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.03));
color: var(--fg);
padding: 10px 12px;
border-radius: 12px;
cursor: pointer;
white-space: nowrap;
}
button:active {
transform: translateY(1px);
}
.subnav {
border-top: 1px solid var(--ring);
}
.subrow {
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
padding-top: 8px;
}
.tabs {
display: flex;
gap: 8px;
}
.tab {
padding: 8px 12px;
border: 1px solid var(--ring);
border-radius: 999px;
background: linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.03));
font-weight: 700;
text-decoration: none;
color: inherit;
}
/* Search (works with & without JS) */
.search {
display: flex;
gap: 8px;
position: relative;
flex: 1;
min-width: 220px;
}
.search input[type="search"] {
flex: 1;
min-width: 140px;
border: 1px solid var(--ring);
background: var(--card);
color: var(--fg);
padding: 12px 14px;
border-radius: 12px;
outline: none;
}
.search button[type="submit"] {
padding: 10px 12px;
}
.search .suggest {
position: absolute;
background: var(--card);
border: 1px solid var(--ring);
border-radius: 12px;
margin-top: 4px;
overflow: hidden;
display: none;
z-index: 30;
max-height: 300px;
overflow: auto;
top: 100%;
left: 0;
right: auto;
}
.search .suggest button {
display: block;
width: 100%;
text-align: left;
padding: 10px 12px;
border: 0;
border-bottom: 1px solid var(--ring);
background: none;
color: inherit;
}
.search .suggest button:last-child {
border-bottom: 0;
}
main {
max-width: 1100px;
margin: 14px auto;
padding: 0 14px;
display: grid;
gap: 14px;
}
.grid {
display: grid;
gap: 14px;
grid-template-columns: repeat(12, 1fr);
}
.card {
background: var(--card);
border: 1px solid var(--ring);
border-radius: var(--br);
padding: 14px;
}
.current {
grid-column: span 12;
display: grid;
gap: 12px;
}
.curr-top {
display: flex;
gap: 14px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
.temp {
font-size: 52px;
font-weight: 700;
letter-spacing: -1px;
font-family: poketube flex;
font-stretch: ultra-expanded;
}
.desc {
color: var(--muted);
font-weight: 600;
}
.details {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.stat {
border: 1px dashed var(--ring);
border-radius: 12px;
padding: 10px;
}
.stat h4 {
margin: 0 0 6px 0;
font-size: 12px;
color: var(--muted);
font-weight: 700;
}
.stat div {
font-weight: 800;
}
.row {
display: flex;
gap: 10px;
align-items: center;
}
.bigicon {
font-size: 42px;
}
.section-title {
font-size: 14px;
color: var(--muted);
margin: 0 0 8px 4px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
font-family: "poketube flex", sans-serif;
font-stretch: ultra-expanded;
}
.scrollx {
display: flex;
gap: 10px;
overflow: auto;
scroll-snap-type: x mandatory;
padding-bottom: 4px;
}
.hour {
min-width: 84px;
scroll-snap-align: start;
border: 1px solid var(--ring);
background: linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.02));
border-radius: 12px;
padding: 10px;
text-align: center;
}
.hour .t {
font-weight: 800;
}
.day {
display: grid;
grid-template-columns: 1.2fr 2fr 0.8fr;
gap: 10px;
align-items: center;
border: 1px solid var(--ring);
border-radius: 12px;
padding: 10px;
}
.day .hi {
font-weight: 800;
}
.day .lo {
color: var(--muted);
}
.panel {
grid-column: span 12;
}
canvas {
width: 100%;
height: 120px;
background: linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.02));
border-radius: 12px;
border: 1px solid var(--ring);
}
footer {
opacity: 0.8;
text-align: center;
padding: 18px;
color: var(--muted);
}
.hidden {
display: none;
}
@media (min-width: 780px) {
.current {
grid-column: span 7;
}
.panel {
grid-column: span 5;
}
}
/* Default: show JS UI; hide SSR block */
.ssr {
display: none;
}
main {
display: grid;
}
</style>
<!-- When JS is disabled/blocked, show SSR block and hide main -->
<noscript>
<style>
.ssr{display:block !important;}
main{display:none !important;}
</style>
</noscript>
</head>
<body class="<%= ssr && ssr.forceNoJS ? 'no-js' : '' %>">
<!-- NAVBAR -->
<header>
<div class="navwrap">
<div class="wrap top">
<div class="brand">
<div class="logo" aria-hidden="true"><span>☁️</span></div>
<span class="chip" id="locChip"><%= (ssr && ssr.name) || '—' %></span>
</div>
<div class="controls">
<button id="btnUnits" title="Toggle °C/°F">°C</button>
<button id="btnTheme" style="display:none" title="Toggle theme">🌗</button>
<button id="btnInstall" class="hidden" title="Install">⬇️ Install</button>
<a class="btn" href="#" id="btnShare" title="Share">🔗 Share</a>
</div>
</div>
<div class="wrap subrow subnav">
<div class="tabs" role="tablist" aria-label="Sections">
<a class="tab" href="#now">Now</a>
<a class="tab" href="#hourly">Hourly</a>
<a class="tab" href="#daily">7-Day</a>
</div>
<form class="search" role="search" aria-label="Search location" action="/weather" method="GET" id="searchForm">
<input id="q" name="q" type="search" placeholder="Search city, place, or paste lat,lon" autocomplete="off" />
<input type="hidden" name="units" id="unitsHidden" value="<%= (ssr && ssr.units) || 'metric' %>"/>
<button id="btnSearch" type="submit" title="Search">Search</button>
<button id="btnGeo" type="button" title="Use my location">📍</button>
<div id="suggest" class="suggest" role="listbox" aria-label="Suggestions"></div>
</form>
</div>
</div>
</header>
<!-- ===== Server-Side Render ===== -->
<section class="ssr">
<div class="wrap" style="padding-top:14px">
<article class="card" aria-live="polite">
<h3 class="section-title">Now — <%= (ssr && ssr.name) || 'Unknown' %></h3>
<% if (ssr && ssr.current) { %>
<div class="row" style="justify-content:space-between;flex-wrap:wrap">
<div class="row">
<div class="bigicon"><%= ssr.icon %></div>
<div>
<div class="temp"><%= Math.round(ssr.current.temperature_2m) %>°</div>
<div class="desc"><%= ssr.desc %></div>
</div>
</div>
<div class="row" style="gap:12px">
<div class="stat"><h4>Feels like</h4><div><%= Math.round(ssr.current.apparent_temperature) %>°</div></div>
<div class="stat"><h4>Wind</h4><div><%= Math.round(ssr.current.wind_speed_10m) %> <%= ssr.windUnit %></div></div>
<div class="stat"><h4>Humidity</h4><div><%= Math.round(ssr.current.relative_humidity_2m) %>%</div></div>
<div class="stat"><h4>Pressure</h4><div><%= Math.round(ssr.current.pressure_msl) %> hPa</div></div>
</div>
</div>
<div class="details" style="margin-top:10px">
<div class="stat"><h4>Sunrise</h4><div><%= ssr.sunriseLocal %></div></div>
<div class="stat"><h4>Sunset</h4><div><%= ssr.sunsetLocal %></div></div>
<div class="stat"><h4>UV max</h4><div><%= ssr.daily.uv_index_max?.[0] ?? '—' %></div></div>
<div class="stat"><h4>Precip prob (next hr)</h4><div><%= ssr.popNext ?? '—' %>%</div></div>
</div>
<% } else { %>
<p style="color:var(--muted)">No data to show.</p>
<% } %>
</article>
<article class="card" style="margin-top:14px">
<h3 class="section-title">7-day forecast</h3>
<% if (ssr && ssr.daily) { %>
<div class="grid" style="grid-template-columns:repeat(1,1fr);gap:10px">
<% for (let i=0;i<ssr.daily.time.length;i++){ %>
<div class="day">
<div class="row" style="gap:10px">
<div style="font-size:22px"><%= ssr.dailyIcons[i] %></div>
<div><%= ssr.dailyLabels[i] %></div>
</div>
<div class="row">
<div class="hi"><%= Math.round(ssr.daily.temperature_2m_max[i]) %>°</div>
<div class="lo"><span style="color:var(--muted)"><%= Math.round(ssr.daily.temperature_2m_min[i]) %>°</span></div>
</div>
<div style="text-align:right;color:var(--muted)"><%= ssr.dailyTexts[i] %></div>
</div>
<% } %>
</div>
<% } %>
</article>
</div>
</section>
<main>
<section id="now" class="grid">
<article class="card current" aria-live="polite">
<div class="curr-top">
<div class="row">
<div class="bigicon" id="currIcon">—</div>
<div>
<div class="temp" id="currTemp">—</div>
<div class="desc" id="currDesc">—</div>
</div>
</div>
<div class="row" style="gap:12px">
<div class="stat"><h4>Feels like</h4><div id="currFeels">—</div></div>
<div class="stat"><h4>Wind</h4><div id="currWind">—</div></div>
<div class="stat"><h4>Humidity</h4><div id="currHum">—</div></div>
<div class="stat"><h4>Pressure</h4><div id="currPress">—</div></div>
</div>
</div>
<div class="details">
<div class="stat"><h4>Sunrise</h4><div id="sunrise">—</div></div>
<div class="stat"><h4>Sunset</h4><div id="sunset">—</div></div>
<div class="stat"><h4>UV max</h4><div id="uv">—</div></div>
<div class="stat"><h4>Precip prob (next hr)</h4><div id="pop">—</div></div>
</div>
</article>
<aside id="hourly" class="panel card">
<h3 class="section-title">Next 24 hours</h3>
<div id="hours" class="scrollx" aria-label="Hourly forecast"></div>
<canvas id="chart" height="120" aria-label="Temperature chart" role="img"></canvas>
</aside>
</section>
<section id="daily" class="card">
<h3 class="section-title">7-day forecast</h3>
<div id="days" class="grid" style="grid-template-columns:repeat(1,1fr);gap:10px"></div>
</section>
</main>
<footer>
<div><a href="/privacy" style="text-decoration: none;">privacy policy </a> <a href="https://open-meteo.com/">Weather data by Open-Meteo.com</a></div>
</footer>
<script>
<!--//--><![CDATA[//><!--
/**
* @licstart The following is the entire license notice for the JavaScript
* code in this page.
*
* Copyright (C) 2021-2025 PokeWeather Of Poke Project, a part of The Poke Project Initiative (initiative.poketube.fun)
* ( source code: https://codeberg.org/ashley/poke)
*
* The JavaScript code in this page is free software: you can redistribute
* it and/or modify it under the terms of the GNU General Public License
* (GNU GPL) as published by the Free Software Foundation, either version 3
* of the License, or (at your option) any later version. The code is
* distributed WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU GPL
* for more details.
*
* As additional permission under GNU GPL version 3 section 7, you may
* distribute non-source (e.g., minimized or compacted) forms of that code
* without the copy of the GNU GPL normally required by section 4, provided
* you include this license notice and a URL through which recipients can
* access the Corresponding Source.
*
* @licend The above is the entire license notice for the JavaScript code
* in this page.
*/
//--><!]]>
</script>
<script>
/* Expose server data to the client + tell it were on the SSR route */
window.__SSR__ = <%- JSON.stringify(ssr || {}) %>;
window.__SSR_ROUTE__ = "/weather";
</script>
<script>
const $=s=>document.querySelector(s);const $$=s=>[...document.querySelectorAll(s)];
const units=JSON.parse(localStorage.getItem('pokeweather:units')||'{}');
let state={lat:null,lon:null,name:null,units:units.units||'<%= (ssr && ssr.units) || "metric" %>'};
const btnUnits=$('#btnUnits'); btnUnits.textContent=state.units==='metric'?"°C":"°F";
const weatherText=(c)=>{
if([0].includes(c))return"Clear sky";
if([1].includes(c))return"Mostly clear";
if([2].includes(c))return"Partly cloudy";
if([3].includes(c))return"Overcast";
if([45,48].includes(c))return"Fog";
if([51,53,55].includes(c))return"Drizzle";
if([56,57].includes(c))return"Freezing drizzle";
if([61,63,65].includes(c))return"Rain";
if([66,67].includes(c))return"Freezing rain";
if([71,73,75].includes(c))return"Snow";
if([77].includes(c))return"Snow grains";
if([80,81,82].includes(c))return"Showers";
if([85,86].includes(c))return"Snow showers";
if([95].includes(c))return"Thunderstorm";
if([96,99].includes(c))return"Storm & hail";return"—"}
const weatherIcon=(c,isDay)=>{
if(c===0)return isDay?"☀️":"🌙";
if([1,2].includes(c))return isDay?"🌤️":"☁️";
if([3].includes(c))return"☁️";
if([45,48].includes(c))return"🌫️";
if([51,53,55,80,81,82].includes(c))return"🌦️";
if([61,63,65].includes(c))return"🌧️";
if([66,67].includes(c))return"🌧️❄️";
if([71,73,75,77,85,86].includes(c))return"❄️";
if([95,96,99].includes(c))return"⛈️";return"☁️";
};
const fmt=(n,u='')=>n==null?'—':`${Math.round(n)}${u}`;
const fmtTime=(s)=>new Date(s).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
const fmtDay=(s)=>new Date(s).toLocaleDateString([], {weekday:'short',month:'short',day:'numeric'});
async function searchPlaces(q){
if(!q) return [];
const url = `/api/nominatim/search?format=json&limit=5&addressdetails=1&q=${encodeURIComponent(q)}`;
const r=await fetch(url,{headers:{'Accept-Language':navigator.language}});
if(!r.ok) return [];
return r.json();
}
function applyUnits(){
btnUnits.textContent=state.units==='metric'?"°C":"°F";
// Keep SSR search form in sync so non-JS round-trips preserve units
const uHidden=document.getElementById('unitsHidden');
if(uHidden) uHidden.value = state.units === 'metric' ? 'metric' : 'imperial';
if(state.lat&&state.lon) loadWeather(state.lat,state.lon,state.name);
localStorage.setItem('pokeweather:units',JSON.stringify({units:state.units}))
}
async function loadWeather(lat,lon,name){
state.lat=+lat;state.lon=+lon;state.name=name||state.name||`${lat.toFixed(3)},${lon.toFixed(3)}`;
$('#locChip').textContent=state.name;
const tu=state.units==='metric'?"celsius":"fahrenheit";
const wu=state.units==='metric'?"kmh":"mph";
const url = new URL("/api/weather", location.origin);
url.search={}.toString();
url.searchParams.set('latitude',lat);url.searchParams.set('longitude',lon);
url.searchParams.set('current','temperature_2m,relative_humidity_2m,apparent_temperature,is_day,precipitation,weather_code,wind_speed_10m,wind_direction_10m,pressure_msl');
url.searchParams.set('hourly','temperature_2m,apparent_temperature,precipitation_probability,precipitation,weather_code,wind_speed_10m');
url.searchParams.set('daily','weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset,uv_index_max');
url.searchParams.set('timezone','auto');url.searchParams.set('forecast_days','7');
url.searchParams.set('temperature_unit',tu);url.searchParams.set('windspeed_unit',wu);
$('.current').classList.add('loading');
try{
const res=await fetch(url.toString());
if(!res.ok) throw new Error('Weather error');
const data=await res.json();
localStorage.setItem('pokeweather:last',JSON.stringify({when:Date.now(),state,data}));
render(data);
}catch(e){
const cached=JSON.parse(localStorage.getItem('pokeweather:last')||'null');
if(cached){render(cached.data);}
else alert('Could not load weather.');
}finally{$('.current').classList.remove('loading')}
}
function render(d){
const c=d.current; const daily=d.daily; const hourly=d.hourly;
$('#currIcon').textContent=weatherIcon(c.weather_code,c.is_day);
$('#currTemp').textContent=fmt(c.temperature_2m,'°');
$('#currDesc').textContent=weatherText(c.weather_code);
$('#currFeels').textContent=fmt(c.apparent_temperature,'°');
$('#currWind').textContent=`${fmt(c.wind_speed_10m)} ${state.units==='metric'?'km/h':'mph'} ↗ ${c.wind_direction_10m??'—'}°`;
$('#currHum').textContent=fmt(c.relative_humidity_2m,'%');
$('#currPress').textContent=fmt(c.pressure_msl,' hPa');
$('#sunrise').textContent=fmtTime(daily.sunrise[0]);
$('#sunset').textContent=fmtTime(daily.sunset[0]);
$('#uv').textContent=(daily.uv_index_max?.[0]??'—');
const nowIndex=hourly.time.findIndex(t=>Date.parse(t)>Date.now());
const popNext=hourly.precipitation_probability?.[nowIndex]??hourly.precipitation_probability?.[0]??null;
$('#pop').textContent=popNext==null?'—':popNext+"%";
// hourly tiles
const H=$('#hours'); H.innerHTML='';
const start=nowIndex>0?nowIndex-1:0; const end=Math.min(start+24,hourly.time.length);
for(let i=start;i<end;i++){
const el=document.createElement('div');
el.className='hour';
const t=new Date(hourly.time[i]).toLocaleTimeString([], {hour:'2-digit'});
el.innerHTML=`<div>${t}</div><div style="font-size:22px">${weatherIcon(hourly.weather_code[i],1)}</div><div class="t">${fmt(hourly.temperature_2m[i],'°')}</div><div style="color:var(--muted);font-size:12px">${(hourly.precipitation_probability?.[i]??0)}%</div>`;
H.appendChild(el);
}
// chart
drawChart($('#chart'), hourly.time.slice(start,end).map(t=>new Date(t)), hourly.temperature_2m.slice(start,end));
// 7-day
const D=$('#days'); D.innerHTML='';
for(let i=0;i<daily.time.length;i++){
const row=document.createElement('div'); row.className='day';
const dt=fmtDay(daily.time[i]);
const hi=fmt(daily.temperature_2m_max[i],'°');
const lo=fmt(daily.temperature_2m_min[i],'°');
row.innerHTML=`<div class="row" style="gap:10px"><div style="font-size:22px">${weatherIcon(daily.weather_code[i],1)}</div><div>${dt}</div></div><div class="row"><div class="hi">${hi}</div><div class="lo">${lo}</div></div><div style="text-align:right;color:var(--muted)">${weatherText(daily.weather_code[i])}</div>`;
D.appendChild(row);
}
}
function drawChart(canvas, xs, ys){
const ctx=canvas.getContext('2d');
const w=canvas.width=canvas.clientWidth*2; const h=canvas.height=canvas.clientHeight*2; ctx.scale(2,2);
const min=Math.min(...ys), max=Math.max(...ys), pad=8; const xstep=(canvas.clientWidth-2*pad)/(ys.length-1);
const ymap=v=>{if(max===min) return canvas.clientHeight/2; const t=(v-min)/(max-min); return canvas.clientHeight-pad - t*(canvas.clientHeight-2*pad)};
ctx.clearRect(0,0,canvas.clientWidth,canvas.clientHeight);
ctx.lineWidth=2; ctx.strokeStyle=getComputedStyle(document.documentElement).getPropertyValue('--accent');
ctx.beginPath(); ctx.moveTo(pad, ymap(ys[0]));
for(let i=1;i<ys.length;i++){ctx.lineTo(pad+i*xstep, ymap(ys[i]));}
ctx.stroke();
ctx.fillStyle=getComputedStyle(document.documentElement).getPropertyValue('--accent');
ys.forEach((v,i)=>{ctx.beginPath();ctx.arc(pad+i*xstep, ymap(v), 2.5, 0, Math.PI*2);ctx.fill()});
}
// Keep units in sync for SSR form and client
btnUnits.addEventListener('click',()=>{
state.units=state.units==='metric'?'imperial':'metric';
applyUnits();
});
$('#btnTheme').addEventListener('click',()=>{
const dark=document.documentElement.style.getPropertyValue('--bg')==='#0b0f16';
if(dark){document.documentElement.style.setProperty('--bg','#fbfbfe');document.documentElement.style.setProperty('--fg','#0b1020');document.documentElement.style.setProperty('--muted','#5b6b87');document.documentElement.style.setProperty('--card','#ffffff');document.documentElement.style.setProperty('--ring','#0b102018');}
else{document.documentElement.style.setProperty('--bg','#0b0f16');document.documentElement.style.setProperty('--fg','#eef3ff');document.documentElement.style.setProperty('--muted','#a7b4cc');document.documentElement.style.setProperty('--card','#0f1624');document.documentElement.style.setProperty('--ring','#ffffff22');}
});
$('#btnGeo').addEventListener('click',()=>{
if(!navigator.geolocation){alert('Geolocation not supported');return}
navigator.geolocation.getCurrentPosition(p=>{
reverseName(p.coords.latitude,p.coords.longitude).then(n=>loadWeather(p.coords.latitude,p.coords.longitude,n));
},e=>alert('Location denied'))
});
function reverseName(lat,lon){
const u=`/api/nominatim/reverse?format=jsonv2&lat=${lat}&lon=${lon}`;
return fetch(u).then(r=>r.ok?r.json():null).then(j=>j?.display_name?.split(',').slice(0,2).join(', ')||`${lat.toFixed(3)},${lon.toFixed(3)}`);
}
const suggest=$('#suggest');
$('#q').addEventListener('input', debounce(async(e)=>{
const v=e.target.value.trim(); if(!v){suggest.style.display='none'; return}
const list=await searchPlaces(v); suggest.innerHTML='';
list.forEach(item=>{
const b=document.createElement('button'); b.type='button';
const label=item.display_name.split(',').slice(0,3).join(', ');
b.textContent=label; b.addEventListener('click',()=>{selectPlace(item)});
suggest.appendChild(b);
});
if(list.length){const r=$('#q').getBoundingClientRect(); suggest.style.minWidth=r.width+'px'; suggest.style.display='block'} else suggest.style.display='none';
},300));
function selectPlace(item){
suggest.style.display='none';
$('#q').value=item.display_name.split(',').slice(0,2).join(', ');
loadWeather(item.lat,item.lon,item.display_name.split(',').slice(0,2).join(', '));
}
// Intercept form submit in JS mode and route smartly.
const form = document.getElementById('searchForm');
form.addEventListener('submit',(e)=>{
e.preventDefault();
goSearch();
});
$('#btnSearch').addEventListener('click',()=>goSearch());
$('#q').addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();goSearch()}});
function goSearch(){
const v=$('#q').value.trim();
if(!v) return;
// If user typed "lat,lon", stay fully client-side
const latlon=v.match(/^\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*$/);
if(latlon){
const lat=+latlon[1], lon=+latlon[2];
reverseName(lat,lon).then(n=>loadWeather(lat,lon,n));
return;
}
// Prefer SSR round-trip when available
if (window.__SSR_ROUTE__) {
const units = (state.units==='metric') ? 'metric' : 'imperial';
const url = new URL(window.__SSR_ROUTE__, location.origin);
url.searchParams.set('q', v);
url.searchParams.set('units', units);
location.href = url.toString();
return;
}
// Fallback: pure client search (for static build)
searchPlaces(v).then(list=>{
if(list[0]) selectPlace(list[0]);
else alert('No results');
});
}
$('#btnShare').addEventListener('click',async e=>{
e.preventDefault();
const url=new URL(location.href);
if(state.lat&&state.lon){url.searchParams.set('lat',state.lat);url.searchParams.set('lon',state.lon);url.searchParams.set('name',state.name)}
try{
if(navigator.share){
await navigator.share({title:'PokeWeather',text:`${state.name||'Weather'}: ${$('#currTemp').textContent} ${$('#currDesc').textContent}`,url:url.toString()});
}else{
await navigator.clipboard.writeText(url.toString());alert('Link copied!')
}
}catch{}
});
function debounce(fn,ms){let t;return (...a)=>{clearTimeout(t);t=setTimeout(()=>fn(...a),ms)}}
(function init(){
// Sync hidden units field at boot
const uHidden=document.getElementById('unitsHidden');
if(uHidden) uHidden.value = state.units;
// If server already gave us a place, hydrate immediately so UI & search are “awake”
if (window.__SSR__ && typeof window.__SSR__.lat === "number" && typeof window.__SSR__.lon === "number") {
try {
localStorage.setItem('pokeweather:last', JSON.stringify({
when: Date.now(),
state: { lat: window.__SSR__.lat, lon: window.__SSR__.lon, name: window.__SSR__.name, units: state.units },
data: { current: window.__SSR__.current || {}, daily: window.__SSR__.daily || {}, hourly: window.__SSR__.hourly || {} }
}));
} catch {}
loadWeather(window.__SSR__.lat, window.__SSR__.lon, window.__SSR__.name);
return;
}
const sp=new URLSearchParams(location.search);
const lat=sp.get('lat'), lon=sp.get('lon'), name=sp.get('name');
if(lat&&lon){loadWeather(+lat,+lon,name);return}
const cached=JSON.parse(localStorage.getItem('pokeweather:last')||'null');
if(cached){state.units=cached.state?.units||state.units; btnUnits.textContent=state.units==='metric'?"°C":"°F"; if(uHidden) uHidden.value=state.units; loadWeather(cached.state.lat,cached.state.lon,cached.state.name); return}
if(navigator.geolocation){navigator.geolocation.getCurrentPosition(
p=>{reverseName(p.coords.latitude,p.coords.longitude).then(n=>loadWeather(p.coords.latitude,p.coords.longitude,n))},
()=>{}
)}
})();
</script>
</body>
</html>