feat(weather): improve weather locations list

This commit is contained in:
2026-06-24 15:45:15 +03:00
parent df2f1d0d81
commit d82fc4ea46
11 changed files with 180 additions and 101 deletions

View File

@@ -50,13 +50,7 @@ def build_weather_response(request: AppRequest, response: WeatherResponse):
router = APIRouter(prefix="/weather") router = APIRouter(prefix="/weather")
@router.get("/", response_class=RedirectResponse) @router.get("/", response_class=HTMLResponse)
async def get_weather(request: AppRequest):
default_provider = request.app.state.api.get_api_providers(WeatherApi.TYPE)[0]
return RedirectResponse(default_provider)
@router.get("/{provider}", response_class=HTMLResponse)
async def get_weather_index(request: AppRequest, weather_api: WeatherApiDepends, query: str | None = None): async def get_weather_index(request: AppRequest, weather_api: WeatherApiDepends, query: str | None = None):
locations = (await weather_api.find_locations(query)) if query else [] locations = (await weather_api.find_locations(query)) if query else []
return templates.TemplateResponse( return templates.TemplateResponse(
@@ -69,7 +63,7 @@ async def get_weather_index(request: AppRequest, weather_api: WeatherApiDepends,
@router.get("/{provider}/{location}", response_class=RedirectResponse) @router.get("/{provider}/{location}", response_class=RedirectResponse)
async def get_weather_default(location: str): async def get_weather(location: str):
return RedirectResponse(f"{location}/tag/today") return RedirectResponse(f"{location}/tag/today")

View File

@@ -1,23 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{_("Weather")}}{% endblock %} {% block title %}{{_("Weather")}}{% endblock %}
{# {% block header %}
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle"
type="button"
data-bs-toggle="dropdown"
aria-expanded="false">
{{provider or providers[0]}}
</button>
<ul class="dropdown-menu">
{% for provider in providers %}
<li><a class="dropdown-item"
href="{{ url_for('get_weather_index').include_query_params(provider=provider) }}">{{provider}}</a></li>
{% endfor %}
</ul>
</div>
{% endblock %} #}
{% block content %} {% block content %}
<h1>{{_("Weather")}}</h1> <h1>{{_("Weather")}}</h1>
<form action="" <form action=""
@@ -29,70 +12,51 @@
id="query" id="query"
name="query" name="query"
placeholder="{{_('Enter the city name')}}"> placeholder="{{_('Enter the city name')}}">
<input type="hidden"
class="form-control"
id="provider"
name="provider"
value="{{provider or providers[0]}}">
<button id="providerBtn"
class="btn btn-secondary dropdown-toggle"
type="text"
data-bs-toggle="dropdown"
aria-expanded="false">{{provider or providers[0]}}</button>
<ul class="dropdown-menu dropdown-menu-end">
{% for provider in providers %}
<li>
<a class="dropdown-item"
onclick="provider.value='{{provider}}'; providerBtn.innerText='{{provider}}'">{{provider}}</a>
</li>
{% endfor %}
</ul>
<button class="btn btn-primary" <button class="btn btn-primary"
type="submit">{{_("Search")}}</button> type="submit">{{_("Search")}}</button>
</div> </div>
</form> </form>
{% if locations %}
<ul id="locations" <ul id="locations"
class="list-group mb-5"> class="list-group mb-5">
{% for location in locations %} {% for location in locations %}
<a href="{{ url_for('get_weather_default', provider=request.path_params.provider, location=location.id) }}" <weather-location location="{{location.model_dump() | tojson | forceescape}}"></weather-location>
class="list-group-item list-group-item-action px-4"
onclick="saveLocation({id:'{{location.id}}', name:'{{location.name}}'});">
<span class="fi fi-{{location.country_code}} me-1"></span>
<span class="text-primary">{{location.name}}</span>
<span class="small ms-1 text-secondary">
{{location.country}}, {{location.district}}, {{location.subdistrict}}
</span>
<span></span>
</a>
{% endfor %} {% endfor %}
</ul> </ul>
<hr>
{% endif %}
<ul id="storedLocations"
class="list-group mb-5">
</ul>
<script> <script>
(function () { (function () {
const getStorageKey = () => {
const provider = window.location.pathname.split('/').pop();
return `locations:${provider}`;
}
document.loadLocations = () => {
const provider = window.location.pathname.split('/').pop();
console.log('!', provider);
const locations = JSON.parse(window.localStorage.getItem(getStorageKey()) || '{}');
const container = document.querySelector('#locations');
container.innerHTML = '';
for (const [id, name] of Object.entries(locations)) {
const element = document.createElement('a');
element.href = `${window.location.pathname}/${id}`;
element.className = 'list-group-item list-group-item-action px-4 d-flex justify-content-between align-items-start';
element.innerHTML = `
<span class="text-primary me-auto">${name}</span>
<span class="text-danger" onclick="removeLocation('${id}'); event.preventDefault();">&#x2715;</span>
`;
container.appendChild(element);
}
}
document.saveLocation = (location) => {
const locations = JSON.parse(window.localStorage.getItem(getStorageKey()) || '{}');
locations[location.id] = location.name;
window.localStorage.setItem(getStorageKey(), JSON.stringify(locations));
}
document.removeLocation = (id) => {
const locations = JSON.parse(window.localStorage.getItem(getStorageKey()) || '{}');
delete locations[id];
window.localStorage.setItem(getStorageKey(), JSON.stringify(locations));
document.loadLocations();
}
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const searchQuery = params.get('query'); const searchQuery = params.get('query');
if (searchQuery) { if (searchQuery) {
document.querySelector('#query').value = searchQuery; document.querySelector('#query').value = searchQuery;
} else {
document.loadLocations();
} }
document.addEventListener("DOMContentLoaded", (event) => {
weatherLocationManager.loadLocations('#storedLocations');
});
})(); })();
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -137,7 +137,7 @@
{% for value in response.values %} {% for value in response.values %}
<td class="precipitation" <td class="precipitation"
style="background-color: rgba(0, 128, 255, {{value.precipitation * 0.1}});"> style="background-color: rgba(0, 128, 255, {{value.precipitation * 0.1}});">
<span class="value">{{value.precipitation or ''}}</span> <span class="value">{{(value.precipitation | round(2)) if value.precipitation else ''}}</span>
</td> </td>
{% endfor %} {% endfor %}
</tr> </tr>

View File

@@ -85,6 +85,7 @@ class GismeteoApi(WeatherApi):
Location( Location(
id=f"{item['slug']}-{item['id']}", id=f"{item['slug']}-{item['id']}",
name=item["translations"]["kk"]["city"]["name"], name=item["translations"]["kk"]["city"]["name"],
provider=self.provider,
lat=item["coordinates"]["latitude"], lat=item["coordinates"]["latitude"],
lon=item["coordinates"]["longitude"], lon=item["coordinates"]["longitude"],
country=item["translations"]["kk"]["country"]["name"], country=item["translations"]["kk"]["country"]["name"],

View File

@@ -38,6 +38,7 @@ class OpenWeatherApi(WeatherApi):
Location( Location(
id=f"{item.lat}:{item.lon}", id=f"{item.lat}:{item.lon}",
name=item.name, name=item.name,
provider=self.provider,
lat=item.lat, lat=item.lat,
lon=item.lon, lon=item.lon,
country=item.country, country=item.country,

View File

@@ -1,5 +1,6 @@
import datetime import datetime
from enum import Enum from enum import StrEnum, auto
from typing import Self
from pydantic import BaseModel from pydantic import BaseModel
@@ -12,6 +13,7 @@ class Model(BaseModel):
class Location(Model): class Location(Model):
id: str id: str
name: str name: str
provider: str
lat: float lat: float
lon: float lon: float
country: str country: str
@@ -20,21 +22,21 @@ class Location(Model):
subdistrict: str subdistrict: str
class Cloudness(str, Enum): class Cloudness(StrEnum):
CLEAR = "clear" CLEAR = auto()
PARTLY_CLOUDY = "party_cloudy" PARTLY_CLOUDY = auto()
CLOUDY = "cloudy" CLOUDY = auto()
MAINLY_CLOUDY = "mainly_cloudy" MAINLY_CLOUDY = auto()
class Precipitation(str, Enum): class Precipitation(StrEnum):
NO = "no" NO = auto()
SMALL_RAIN = "small_rain" SMALL_RAIN = auto()
RAIN = "rain" RAIN = auto()
HEAVY_RAIN = "heavy_rain" HEAVY_RAIN = auto()
SHOWER = "shower" SHOWER = auto()
SNOW = "snow" SNOW = auto()
HEAVY_SNOW = "heavy_snow" HEAVY_SNOW = auto()
class Sky(Model): class Sky(Model):
@@ -44,16 +46,16 @@ class Sky(Model):
fog: bool fog: bool
class WindDirection(str, Enum): class WindDirection(StrEnum):
CALM = "calm" CALM = auto()
N = "N" N = auto()
NE = "NE" NE = auto()
E = "E" E = auto()
SE = "SE" SE = auto()
S = "S" S = auto()
SW = "SW" SW = auto()
W = "W" W = auto()
NW = "NW" NW = auto()
class WindDirectionDeg(float): class WindDirectionDeg(float):
@@ -89,7 +91,7 @@ class WindDirectionDeg(float):
raise ValueError(self) raise ValueError(self)
@classmethod @classmethod
def from_direction(cls, direction: WindDirection) -> "WindDirectionDeg": def from_direction(cls, direction: WindDirection) -> Self:
return cls( return cls(
{ {
WindDirection.CALM: -1, WindDirection.CALM: -1,

View File

@@ -3,6 +3,7 @@ import "./components";
import "./language"; import "./language";
import "./main.scss"; import "./main.scss";
import "./theme"; import "./theme";
import "./weather/weather";
document.addEventListener("DOMContentLoaded", (event) => { document.addEventListener("DOMContentLoaded", (event) => {
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]'); const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');

View File

@@ -30,7 +30,7 @@
//@import "bootstrap/scss/accordion"; //@import "bootstrap/scss/accordion";
//@import "bootstrap/scss/breadcrumb"; //@import "bootstrap/scss/breadcrumb";
//@import "bootstrap/scss/pagination"; //@import "bootstrap/scss/pagination";
//@import "bootstrap/scss/badge"; @import "bootstrap/scss/badge";
//@import "bootstrap/scss/alert"; //@import "bootstrap/scss/alert";
//@import "bootstrap/scss/progress"; //@import "bootstrap/scss/progress";
@import "bootstrap/scss/list-group"; @import "bootstrap/scss/list-group";

View File

@@ -4,7 +4,7 @@
@import "./lib/weather-icons/weather-icons"; @import "./lib/weather-icons/weather-icons";
@import "./widget.scss"; @import "./widget.scss";
@import "./weather.scss"; @import "./weather/weather.scss";
.table.table-compact { .table.table-compact {
td { td {

View File

@@ -0,0 +1,116 @@
export interface Location {
id: string;
name: string;
provider: string;
country_code: string;
country?: string;
district?: string;
subdistrict?: string;
}
export class WeatherLocationStorage {
private storageKey: string = "weather:locations";
getAll(): Location[] {
const locations = JSON.parse(window.localStorage.getItem(this.storageKey) || "{}");
return Object.values(locations);
}
add(location: Location) {
const locations = JSON.parse(window.localStorage.getItem(this.storageKey) || "{}");
locations[location.id] = location;
window.localStorage.setItem(this.storageKey, JSON.stringify(locations));
}
remove(id: string) {
const locations = JSON.parse(window.localStorage.getItem(this.storageKey) || "{}");
delete locations[id];
window.localStorage.setItem(this.storageKey, JSON.stringify(locations));
}
}
export class WeatherLocationManager {
loadLocations(selector: string) {
const locations = window.weatherLocationStorage.getAll();
const container = document.querySelector(selector);
if (container) {
container.innerHTML = "";
for (const location of locations) {
const element = new WeatherLocationElement();
element.setAttribute("location", JSON.stringify(location));
element.setAttribute("removable", "");
container.appendChild(element);
}
}
}
}
class WeatherLocationElement extends HTMLElement {
static observedAttributes = ["location", "removable"];
location: Location | null = null;
constructor() {
super();
this.handleClick = this.handleClick.bind(this);
this.handleRemoveClick = this.handleRemoveClick.bind(this);
}
connectedCallback() {
this.location = JSON.parse(this.getAttribute("location") || "{}");
this.innerHTML = `
<a href="${this.location?.provider}/${this.location?.id}"
class="list-group-item list-group-item-action px-4 d-flex justify-content-between align-items-start">
<span>
<span class="fi fi-${this.location?.country_code} me-1"></span>
<span class="text-primary">${this.location?.name}</span>
<span class="small ms-1 text-secondary">
${this.location?.country}, ${this.location?.district}, ${this.location?.subdistrict}
</span>
</span>
<span>
<span class="badge text-bg-secondary">${this.location?.provider}</span>
<span class="text-danger ms-2" data-remove>
&#x2715;
</span>
</span>
</a>`;
this.addEventListener("click", this.handleClick);
if (this.hasAttribute("removable")) {
this.querySelector<HTMLElement>("[data-remove]")?.addEventListener("click", this.handleRemoveClick);
} else {
this.querySelector("[data-remove]")?.remove();
}
}
disconnectedCallback() {
this.removeEventListener("click", this.handleClick);
}
handleClick(event: MouseEvent) {
if (this.location) {
window.weatherLocationStorage.add(this.location);
}
}
handleRemoveClick(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
if (this.location) {
window.weatherLocationStorage.remove(this.location.id);
}
this.remove();
}
}
customElements.define("weather-location", WeatherLocationElement);
declare global {
interface Window {
weatherLocationStorage: WeatherLocationStorage;
weatherLocationManager: WeatherLocationManager;
}
}
window.weatherLocationStorage = new WeatherLocationStorage();
window.weatherLocationManager = new WeatherLocationManager();