initial commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
@@ -0,0 +1,20 @@
|
||||
# Flask Weather Website
|
||||
|
||||
Small Flask app that geocodes a location and shows current conditions plus a 5-day forecast using Open-Meteo.
|
||||
|
||||
## API
|
||||
|
||||
- Forecast: https://api.open-meteo.com/v1/forecast
|
||||
- Geocoding: https://geocoding-api.open-meteo.com/v1/search
|
||||
- No API key required for basic, non-commercial usage.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
python3 -m pip install -r requirements.txt
|
||||
python3 app.py
|
||||
```
|
||||
|
||||
Open http://127.0.0.1:5000.
|
||||
@@ -0,0 +1,330 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from flask import Flask, render_template, request
|
||||
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
DEFAULT_LOCATION = "Seattle"
|
||||
GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
|
||||
FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
|
||||
USER_AGENT = "SETI-Flask-Weather-Demo/1.0"
|
||||
|
||||
CURRENT_FIELDS = [
|
||||
"temperature_2m",
|
||||
"relative_humidity_2m",
|
||||
"apparent_temperature",
|
||||
"is_day",
|
||||
"precipitation",
|
||||
"weather_code",
|
||||
"cloud_cover",
|
||||
"wind_speed_10m",
|
||||
"wind_direction_10m",
|
||||
"wind_gusts_10m",
|
||||
]
|
||||
|
||||
DAILY_FIELDS = [
|
||||
"weather_code",
|
||||
"temperature_2m_max",
|
||||
"temperature_2m_min",
|
||||
"precipitation_probability_max",
|
||||
"precipitation_sum",
|
||||
"wind_speed_10m_max",
|
||||
]
|
||||
|
||||
WEATHER_CODES = {
|
||||
0: "Clear sky",
|
||||
1: "Mainly clear",
|
||||
2: "Partly cloudy",
|
||||
3: "Overcast",
|
||||
45: "Fog",
|
||||
48: "Depositing rime fog",
|
||||
51: "Light drizzle",
|
||||
53: "Drizzle",
|
||||
55: "Heavy drizzle",
|
||||
56: "Light freezing drizzle",
|
||||
57: "Freezing drizzle",
|
||||
61: "Light rain",
|
||||
63: "Rain",
|
||||
65: "Heavy rain",
|
||||
66: "Light freezing rain",
|
||||
67: "Freezing rain",
|
||||
71: "Light snow",
|
||||
73: "Snow",
|
||||
75: "Heavy snow",
|
||||
77: "Snow grains",
|
||||
80: "Light rain showers",
|
||||
81: "Rain showers",
|
||||
82: "Heavy rain showers",
|
||||
85: "Light snow showers",
|
||||
86: "Snow showers",
|
||||
95: "Thunderstorm",
|
||||
96: "Thunderstorm with hail",
|
||||
99: "Severe thunderstorm with hail",
|
||||
}
|
||||
|
||||
|
||||
class WeatherServiceError(Exception):
|
||||
"""Raised when the weather or geocoding API cannot return usable data."""
|
||||
|
||||
|
||||
def fetch_json(url, params):
|
||||
request_url = f"{url}?{urlencode(params)}"
|
||||
api_request = Request(request_url, headers={"User-Agent": USER_AGENT})
|
||||
|
||||
try:
|
||||
with urlopen(api_request, timeout=10) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except HTTPError as exc:
|
||||
raise WeatherServiceError(f"Weather API returned HTTP {exc.code}.") from exc
|
||||
except (TimeoutError, URLError) as exc:
|
||||
raise WeatherServiceError("Weather API is unreachable right now.") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise WeatherServiceError("Weather API returned invalid JSON.") from exc
|
||||
|
||||
|
||||
def geocode_location(query):
|
||||
cleaned_query = (query or DEFAULT_LOCATION).strip() or DEFAULT_LOCATION
|
||||
search_terms = [cleaned_query]
|
||||
if "," in cleaned_query:
|
||||
search_terms.append(cleaned_query.split(",", 1)[0].strip())
|
||||
|
||||
results = []
|
||||
for search_term in search_terms:
|
||||
if not search_term:
|
||||
continue
|
||||
|
||||
payload = fetch_json(
|
||||
GEOCODE_URL,
|
||||
{
|
||||
"name": search_term,
|
||||
"count": 1,
|
||||
"language": "en",
|
||||
"format": "json",
|
||||
},
|
||||
)
|
||||
results = payload.get("results") or []
|
||||
if results:
|
||||
break
|
||||
|
||||
if not results:
|
||||
raise WeatherServiceError(f"No location found for '{cleaned_query}'.")
|
||||
|
||||
result = results[0]
|
||||
display_parts = [
|
||||
result.get("name"),
|
||||
result.get("admin1"),
|
||||
result.get("country"),
|
||||
]
|
||||
|
||||
return {
|
||||
"name": result["name"],
|
||||
"display": ", ".join(part for part in display_parts if part),
|
||||
"latitude": result["latitude"],
|
||||
"longitude": result["longitude"],
|
||||
"timezone": result.get("timezone", "auto"),
|
||||
}
|
||||
|
||||
|
||||
def get_forecast(location):
|
||||
payload = fetch_json(
|
||||
FORECAST_URL,
|
||||
{
|
||||
"latitude": location["latitude"],
|
||||
"longitude": location["longitude"],
|
||||
"current": ",".join(CURRENT_FIELDS),
|
||||
"daily": ",".join(DAILY_FIELDS),
|
||||
"temperature_unit": "fahrenheit",
|
||||
"wind_speed_unit": "mph",
|
||||
"precipitation_unit": "inch",
|
||||
"forecast_days": 5,
|
||||
"timezone": "auto",
|
||||
},
|
||||
)
|
||||
|
||||
return normalize_forecast(location, payload)
|
||||
|
||||
|
||||
def normalize_forecast(location, payload):
|
||||
current = payload.get("current") or {}
|
||||
daily = payload.get("daily") or {}
|
||||
current_code = current.get("weather_code")
|
||||
|
||||
return {
|
||||
"location": location,
|
||||
"current": {
|
||||
"time": format_timestamp(current.get("time")),
|
||||
"temperature": format_number(current.get("temperature_2m"), "F"),
|
||||
"apparent_temperature": format_number(
|
||||
current.get("apparent_temperature"),
|
||||
"F",
|
||||
),
|
||||
"humidity": format_number(current.get("relative_humidity_2m"), "%"),
|
||||
"precipitation": format_number(
|
||||
current.get("precipitation"),
|
||||
" in",
|
||||
digits=2,
|
||||
),
|
||||
"cloud_cover": format_number(current.get("cloud_cover"), "%"),
|
||||
"wind": format_wind(
|
||||
current.get("wind_speed_10m"),
|
||||
current.get("wind_direction_10m"),
|
||||
),
|
||||
"wind_gusts": format_number(current.get("wind_gusts_10m"), " mph"),
|
||||
"label": weather_label(current_code),
|
||||
"icon": weather_icon(current_code),
|
||||
},
|
||||
"days": normalize_daily(daily),
|
||||
"generation_ms": round(payload.get("generationtime_ms", 0), 2),
|
||||
}
|
||||
|
||||
|
||||
def normalize_daily(daily):
|
||||
days = []
|
||||
dates = daily.get("time") or []
|
||||
|
||||
for index, date_value in enumerate(dates):
|
||||
weather_code = daily_value(daily, "weather_code", index)
|
||||
days.append(
|
||||
{
|
||||
"date": format_date(date_value),
|
||||
"label": weather_label(weather_code),
|
||||
"icon": weather_icon(weather_code),
|
||||
"high": format_number(
|
||||
daily_value(daily, "temperature_2m_max", index),
|
||||
"F",
|
||||
),
|
||||
"low": format_number(
|
||||
daily_value(daily, "temperature_2m_min", index),
|
||||
"F",
|
||||
),
|
||||
"precip_probability": format_number(
|
||||
daily_value(daily, "precipitation_probability_max", index),
|
||||
"%",
|
||||
),
|
||||
"precip_sum": format_number(
|
||||
daily_value(daily, "precipitation_sum", index),
|
||||
" in",
|
||||
digits=2,
|
||||
),
|
||||
"wind": format_number(
|
||||
daily_value(daily, "wind_speed_10m_max", index),
|
||||
" mph",
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return days
|
||||
|
||||
|
||||
def daily_value(daily, field, index):
|
||||
values = daily.get(field) or []
|
||||
if index >= len(values):
|
||||
return None
|
||||
return values[index]
|
||||
|
||||
|
||||
def weather_label(code):
|
||||
return WEATHER_CODES.get(code, "Forecast unavailable")
|
||||
|
||||
|
||||
def weather_icon(code):
|
||||
if code in {0, 1}:
|
||||
return "sun"
|
||||
if code in {2, 3, 45, 48}:
|
||||
return "cloud"
|
||||
if code in {51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82}:
|
||||
return "rain"
|
||||
if code in {71, 73, 75, 77, 85, 86}:
|
||||
return "snow"
|
||||
if code in {95, 96, 99}:
|
||||
return "storm"
|
||||
return "cloud"
|
||||
|
||||
|
||||
def format_number(value, suffix="", digits=0):
|
||||
if value is None:
|
||||
return "--"
|
||||
rounded = round(value, digits)
|
||||
if digits == 0:
|
||||
rounded = int(rounded)
|
||||
return f"{rounded}{suffix}"
|
||||
|
||||
|
||||
def format_wind(speed, direction):
|
||||
if speed is None:
|
||||
return "--"
|
||||
return f"{format_number(speed, ' mph')} {compass_direction(direction)}"
|
||||
|
||||
|
||||
def compass_direction(degrees):
|
||||
if degrees is None:
|
||||
return ""
|
||||
|
||||
directions = [
|
||||
"N",
|
||||
"NNE",
|
||||
"NE",
|
||||
"ENE",
|
||||
"E",
|
||||
"ESE",
|
||||
"SE",
|
||||
"SSE",
|
||||
"S",
|
||||
"SSW",
|
||||
"SW",
|
||||
"WSW",
|
||||
"W",
|
||||
"WNW",
|
||||
"NW",
|
||||
"NNW",
|
||||
]
|
||||
index = round(degrees / 22.5) % 16
|
||||
return directions[index]
|
||||
|
||||
|
||||
def format_timestamp(value):
|
||||
if not value:
|
||||
return "Latest model run"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return value
|
||||
return parsed.strftime("%b %-d, %-I:%M %p")
|
||||
|
||||
|
||||
def format_date(value):
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return value
|
||||
return parsed.strftime("%a, %b %-d")
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
query = request.args.get("q", DEFAULT_LOCATION)
|
||||
forecast = None
|
||||
error = None
|
||||
|
||||
try:
|
||||
location = geocode_location(query)
|
||||
forecast = get_forecast(location)
|
||||
except WeatherServiceError as exc:
|
||||
error = str(exc)
|
||||
|
||||
return render_template(
|
||||
"index.html",
|
||||
default_location=DEFAULT_LOCATION,
|
||||
error=error,
|
||||
forecast=forecast,
|
||||
query=query,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="127.0.0.1", port=5000, debug=True)
|
||||
@@ -0,0 +1 @@
|
||||
Flask>=3.0,<4.0
|
||||
@@ -0,0 +1,521 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--background: #f4f6f8;
|
||||
--panel: #ffffff;
|
||||
--ink: #172033;
|
||||
--muted: #667085;
|
||||
--line: #d8dee7;
|
||||
--teal: #00796b;
|
||||
--teal-dark: #00564d;
|
||||
--amber: #f2a413;
|
||||
--coral: #d45b45;
|
||||
--rain: #3d7ea6;
|
||||
--snow: #7b8794;
|
||||
--shadow: 0 16px 40px rgb(23 32 51 / 10%);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #f4f6f8 0%, #eef3f1 48%, #f8f4f0 100%);
|
||||
color: var(--ink);
|
||||
font-family:
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
width: min(1120px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 32px 0 28px;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.current-panel,
|
||||
.day-card,
|
||||
.alert {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: rgb(255 255 255 / 86%);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 4px;
|
||||
color: var(--teal-dark);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 0;
|
||||
font-size: clamp(1.35rem, 2.4vw, 2.2rem);
|
||||
line-height: 1.1;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
width: min(420px, 100%);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.search input,
|
||||
.search button {
|
||||
min-height: 44px;
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.search input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
padding: 0 14px;
|
||||
color: var(--ink);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.search button {
|
||||
border: 0;
|
||||
padding: 0 18px;
|
||||
background: var(--teal);
|
||||
color: #ffffff;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search button:hover,
|
||||
.search button:focus-visible {
|
||||
background: var(--teal-dark);
|
||||
}
|
||||
|
||||
.alert {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
color: #762315;
|
||||
background: #fff4f1;
|
||||
}
|
||||
|
||||
.current-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 0.9fr) minmax(360px, 1.1fr);
|
||||
gap: 22px;
|
||||
margin-top: 20px;
|
||||
padding: 22px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.condition-sun {
|
||||
border-color: rgb(242 164 19 / 45%);
|
||||
}
|
||||
|
||||
.condition-cloud {
|
||||
border-color: rgb(123 135 148 / 40%);
|
||||
}
|
||||
|
||||
.condition-rain,
|
||||
.condition-storm {
|
||||
border-color: rgb(61 126 166 / 45%);
|
||||
}
|
||||
|
||||
.condition-snow {
|
||||
border-color: rgb(123 135 148 / 40%);
|
||||
}
|
||||
|
||||
.current-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.current-summary h2 {
|
||||
margin-bottom: 4px;
|
||||
font-size: clamp(3.2rem, 8vw, 5.8rem);
|
||||
line-height: 0.95;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.condition-label {
|
||||
margin-bottom: 0;
|
||||
color: var(--muted);
|
||||
font-size: 1.08rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metrics,
|
||||
.day-metrics {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.metrics div,
|
||||
.day-metrics div {
|
||||
min-width: 0;
|
||||
border: 1px solid #e7ebf0;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 5px 0 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.forecast-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.day-card {
|
||||
min-height: 255px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.day-header {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-height: 58px;
|
||||
}
|
||||
|
||||
.day-header h2 {
|
||||
margin-bottom: 3px;
|
||||
font-size: 1rem;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.day-header p {
|
||||
margin-bottom: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.day-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.day-metrics div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-height: 40px;
|
||||
padding: 9px 10px;
|
||||
}
|
||||
|
||||
.day-metrics dd {
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.weather-art,
|
||||
.mini-art {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.weather-art {
|
||||
width: 118px;
|
||||
height: 118px;
|
||||
}
|
||||
|
||||
.mini-art {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.weather-art span,
|
||||
.mini-art span,
|
||||
.weather-art::before,
|
||||
.mini-art::before,
|
||||
.weather-art::after,
|
||||
.mini-art::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sun,
|
||||
.mini-art.sun {
|
||||
background: rgb(242 164 19 / 18%);
|
||||
}
|
||||
|
||||
.sun span,
|
||||
.mini-art.sun span {
|
||||
width: 48%;
|
||||
height: 48%;
|
||||
border-radius: 50%;
|
||||
background: var(--amber);
|
||||
box-shadow: 0 0 0 10px rgb(242 164 19 / 16%);
|
||||
}
|
||||
|
||||
.cloud,
|
||||
.mini-art.cloud {
|
||||
background: rgb(123 135 148 / 14%);
|
||||
}
|
||||
|
||||
.cloud span,
|
||||
.rain span,
|
||||
.snow span,
|
||||
.storm span,
|
||||
.mini-art.cloud span,
|
||||
.mini-art.rain span,
|
||||
.mini-art.snow span,
|
||||
.mini-art.storm span {
|
||||
width: 58%;
|
||||
height: 28%;
|
||||
border-radius: 999px;
|
||||
background: #7b8794;
|
||||
}
|
||||
|
||||
.cloud span::before,
|
||||
.rain span::before,
|
||||
.snow span::before,
|
||||
.storm span::before,
|
||||
.mini-art.cloud span::before,
|
||||
.mini-art.rain span::before,
|
||||
.mini-art.snow span::before,
|
||||
.mini-art.storm span::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 12%;
|
||||
bottom: 35%;
|
||||
width: 34%;
|
||||
height: 85%;
|
||||
border-radius: 50%;
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
.cloud span::after,
|
||||
.rain span::after,
|
||||
.snow span::after,
|
||||
.storm span::after,
|
||||
.mini-art.cloud span::after,
|
||||
.mini-art.rain span::after,
|
||||
.mini-art.snow span::after,
|
||||
.mini-art.storm span::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 10%;
|
||||
bottom: 26%;
|
||||
width: 42%;
|
||||
height: 110%;
|
||||
border-radius: 50%;
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
.rain,
|
||||
.mini-art.rain {
|
||||
background: rgb(61 126 166 / 14%);
|
||||
}
|
||||
|
||||
.rain span,
|
||||
.mini-art.rain span {
|
||||
background: var(--rain);
|
||||
}
|
||||
|
||||
.rain::after,
|
||||
.mini-art.rain::after {
|
||||
width: 38%;
|
||||
height: 26%;
|
||||
border-right: 3px solid var(--rain);
|
||||
border-left: 3px solid var(--rain);
|
||||
transform: translateY(75%) skewX(-15deg);
|
||||
}
|
||||
|
||||
.snow,
|
||||
.mini-art.snow {
|
||||
background: rgb(123 135 148 / 14%);
|
||||
}
|
||||
|
||||
.snow span,
|
||||
.mini-art.snow span {
|
||||
background: var(--snow);
|
||||
}
|
||||
|
||||
.snow::after,
|
||||
.mini-art.snow::after {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #aeb8c4;
|
||||
box-shadow: -14px 4px 0 #aeb8c4, 14px 4px 0 #aeb8c4;
|
||||
transform: translateY(95%);
|
||||
}
|
||||
|
||||
.storm,
|
||||
.mini-art.storm {
|
||||
background: rgb(212 91 69 / 14%);
|
||||
}
|
||||
|
||||
.storm span,
|
||||
.mini-art.storm span {
|
||||
background: var(--coral);
|
||||
}
|
||||
|
||||
.storm::after,
|
||||
.mini-art.storm::after {
|
||||
width: 14%;
|
||||
height: 32%;
|
||||
background: var(--amber);
|
||||
clip-path: polygon(45% 0, 100% 0, 68% 44%, 100% 44%, 22% 100%, 45% 55%, 8% 55%);
|
||||
transform: translateY(70%);
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: var(--teal-dark);
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.topbar,
|
||||
.current-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.forecast-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.page-shell {
|
||||
width: min(100% - 20px, 1120px);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.search {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.current-summary {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.weather-art {
|
||||
width: 86px;
|
||||
height: 86px;
|
||||
}
|
||||
|
||||
.current-summary h2 {
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.metrics,
|
||||
.forecast-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
footer {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" role="img" aria-label="Weather">
|
||||
<rect width="96" height="96" rx="8" fill="#ffffff"/>
|
||||
<circle cx="34" cy="34" r="16" fill="#f2a413"/>
|
||||
<path d="M30 62h39a14 14 0 0 0 0-28 20 20 0 0 0-37-7 17 17 0 0 0-2 35z" fill="#3d7ea6"/>
|
||||
<path d="M29 62h40a14 14 0 0 0 12-7H23a13 13 0 0 0 6 7z" fill="#00796b"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 372 B |
@@ -0,0 +1,126 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Weather Forecast</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<main class="page-shell">
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<img class="brand-icon" src="{{ url_for('static', filename='weather-mark.svg') }}" alt="">
|
||||
<div>
|
||||
<p class="eyebrow">Open-Meteo forecast</p>
|
||||
<h1>{{ forecast.location.display if forecast else default_location }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="search" method="get">
|
||||
<label class="sr-only" for="location">Location</label>
|
||||
<input
|
||||
id="location"
|
||||
name="q"
|
||||
type="search"
|
||||
value="{{ query }}"
|
||||
placeholder="City or ZIP"
|
||||
autocomplete="postal-code"
|
||||
>
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
{% if error %}
|
||||
<section class="alert" role="alert">
|
||||
<strong>Forecast error:</strong> {{ error }}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if forecast %}
|
||||
<section class="current-panel condition-{{ forecast.current.icon }}" aria-label="Current weather">
|
||||
<div class="current-summary">
|
||||
<div class="weather-art {{ forecast.current.icon }}" aria-hidden="true">
|
||||
<span></span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="eyebrow">{{ forecast.current.time }}</p>
|
||||
<h2>{{ forecast.current.temperature }}</h2>
|
||||
<p class="condition-label">{{ forecast.current.label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="metrics">
|
||||
<div>
|
||||
<dt>Feels like</dt>
|
||||
<dd>{{ forecast.current.apparent_temperature }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Humidity</dt>
|
||||
<dd>{{ forecast.current.humidity }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Wind</dt>
|
||||
<dd>{{ forecast.current.wind }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Gusts</dt>
|
||||
<dd>{{ forecast.current.wind_gusts }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Clouds</dt>
|
||||
<dd>{{ forecast.current.cloud_cover }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Precip</dt>
|
||||
<dd>{{ forecast.current.precipitation }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="forecast-grid" aria-label="Five day forecast">
|
||||
{% for day in forecast.days %}
|
||||
<article class="day-card">
|
||||
<div class="day-header">
|
||||
<div class="mini-art {{ day.icon }}" aria-hidden="true"><span></span></div>
|
||||
<div>
|
||||
<h2>{{ day.date }}</h2>
|
||||
<p>{{ day.label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="day-metrics">
|
||||
<div>
|
||||
<dt>High</dt>
|
||||
<dd>{{ day.high }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Low</dt>
|
||||
<dd>{{ day.low }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Rain chance</dt>
|
||||
<dd>{{ day.precip_probability }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Rain total</dt>
|
||||
<dd>{{ day.precip_sum }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Max wind</dt>
|
||||
<dd>{{ day.wind }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<span>Generated in {{ forecast.generation_ms }} ms.</span>
|
||||
<a href="https://open-meteo.com/" rel="noreferrer">Open-Meteo</a>
|
||||
</footer>
|
||||
{% endif %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user