Add html/weather.ejs
This commit is contained in:
parent
950c5e64bf
commit
256112ec03
421
html/weather.ejs
Normal file
421
html/weather.ejs
Normal file
@ -0,0 +1,421 @@
|
||||
<!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" />
|
||||
<style>
|
||||
:root{--bg:#0b0f16;--fg:#eef3ff;--muted:#a7b4cc;--card:#0f1624;--ring:#ffffff22;--accent:#0ea5e9;--good:#7ee787;--bad:#f97373;--warn:#fbbf24;--br:16px}
|
||||
@media (prefers-color-scheme:light){:root{--bg:#fbfbfe;--fg:#0b1020;--muted:#5b6b87;--card:#ffffff;--ring:#0b102018}}
|
||||
*{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}
|
||||
|
||||
/* 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,.35),rgba(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}
|
||||
/* removed brand text on purpose */
|
||||
.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,.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,.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,.03));font-weight:700;text-decoration:none;color:inherit}
|
||||
.search{display:flex;gap:8px;position:relative;flex:1;min-width:220px}
|
||||
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}
|
||||
|
||||
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:900;letter-spacing:-1px}
|
||||
.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:700;text-transform:uppercase;letter-spacing:.1em}
|
||||
.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,.02));border-radius:12px;padding:10px;text-align:center}
|
||||
.hour .t{font-weight:800}
|
||||
.day{display:grid;grid-template-columns:1.2fr 2fr .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,.02));border-radius:12px;border:1px solid var(--ring)}
|
||||
footer{opacity:.8;text-align:center;padding:18px;color:var(--muted)}
|
||||
.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}
|
||||
.suggest button{display:block;width:100%;text-align:left;padding:10px 12px;border:0;border-bottom:1px solid var(--ring);background:none;color:inherit}
|
||||
.suggest button:last-child{border-bottom:0}
|
||||
.hidden{display:none}
|
||||
@media (min-width:780px){.current{grid-column:span 7}.panel{grid-column:span 5}}
|
||||
|
||||
/* SSR (no-JS) block */
|
||||
.ssr{display:none}
|
||||
.no-js .ssr{display:block}
|
||||
.no-js main{display:none}
|
||||
</style>
|
||||
<% /* Add a no-JS class if you detect on server (e.g., via UA or query). Default leaves it blank. */ %>
|
||||
</head>
|
||||
<body class="<%= ssr && ssr.forceNoJS ? 'no-js' : '' %>">
|
||||
|
||||
<!-- NAVBAR (brand text removed, location chip compact) -->
|
||||
<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" 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>
|
||||
<div class="search" role="search" aria-label="Search location">
|
||||
<input id="q" type="search" placeholder="Search city, place, or paste lat,lon" autocomplete="off" />
|
||||
<button id="btnSearch" title="Search">Search</button>
|
||||
<button id="btnGeo" title="Use my location">📍</button>
|
||||
<div id="suggest" class="suggest" role="listbox" aria-label="Suggestions"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ===== Server-Side Render (shown if no JS) ===== -->
|
||||
<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>
|
||||
|
||||
<!-- ===== JS APP (kept intact) ===== -->
|
||||
<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>
|
||||
|
||||
<footer>
|
||||
<div>Data: Open-Meteo & OpenStreetMap/Nominatim. Your settings are saved locally.</div>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<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||'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=`https://nominatim.openstreetmap.org/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";
|
||||
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('https://api.open-meteo.com/v1/forecast');
|
||||
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()});
|
||||
}
|
||||
|
||||
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=`https://nominatim.openstreetmap.org/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(', '));
|
||||
}
|
||||
|
||||
$('#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;
|
||||
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}
|
||||
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 setupPWA(){
|
||||
const manifest={name:'PokeWeather',short_name:'PokeWeather',start_url:'./',display:'standalone',background_color:'#0b0f16',theme_color:'#0ea5e9',icons:[{src:'data:image/svg+xml;utf8,'+encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#00d2ff"/><stop offset="1" stop-color="#3a7bd5"/></linearGradient></defs><rect rx="28" width="128" height="128" fill="url(#g)"/><text x="50%" y="58%" text-anchor="middle" font-size="72" font-weight="900" fill="#fff">☁️</text></svg>`),sizes:'128x128',type:'image/svg+xml'}]};
|
||||
const blob=new Blob([JSON.stringify(manifest)],{type:'application/json'}); const url=URL.createObjectURL(blob);
|
||||
const link=document.createElement('link'); link.rel='manifest'; link.href=url; document.head.appendChild(link);
|
||||
|
||||
if('serviceWorker' in navigator){
|
||||
const swSrc=`self.addEventListener('install',e=>{e.waitUntil(caches.open('pokeweather-v1').then(c=>c.addAll(['./'])))});self.addEventListener('fetch',e=>{const u=new URL(e.request.url);if(u.origin===location.origin){e.respondWith(caches.match(e.request).then(r=>r||fetch(e.request)))} })`;
|
||||
const swURL=URL.createObjectURL(new Blob([swSrc],{type:'application/javascript'}));
|
||||
navigator.serviceWorker.register(swURL);
|
||||
window.addEventListener('beforeinstallprompt',(e)=>{e.preventDefault();window.deferredPrompt=e;$('#btnInstall').classList.remove('hidden')});
|
||||
$('#btnInstall').addEventListener('click',async()=>{const p=window.deferredPrompt;if(!p) return;p.prompt();await p.userChoice;$('#btnInstall').classList.add('hidden')});
|
||||
}
|
||||
})();
|
||||
|
||||
(function init(){
|
||||
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"; 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>
|
||||
Loading…
x
Reference in New Issue
Block a user