feat(weather): improve weather locations list
This commit is contained in:
@@ -50,13 +50,7 @@ def build_weather_response(request: AppRequest, response: WeatherResponse):
|
||||
router = APIRouter(prefix="/weather")
|
||||
|
||||
|
||||
@router.get("/", response_class=RedirectResponse)
|
||||
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)
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def get_weather_index(request: AppRequest, weather_api: WeatherApiDepends, query: str | None = None):
|
||||
locations = (await weather_api.find_locations(query)) if query else []
|
||||
return templates.TemplateResponse(
|
||||
@@ -69,7 +63,7 @@ async def get_weather_index(request: AppRequest, weather_api: WeatherApiDepends,
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
|
||||
|
||||
@@ -1,23 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% 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 %}
|
||||
<h1>{{_("Weather")}}</h1>
|
||||
<form action=""
|
||||
@@ -29,70 +12,51 @@
|
||||
id="query"
|
||||
name="query"
|
||||
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"
|
||||
type="submit">{{_("Search")}}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% if locations %}
|
||||
<ul id="locations"
|
||||
class="list-group mb-5">
|
||||
{% for location in locations %}
|
||||
<a href="{{ url_for('get_weather_default', provider=request.path_params.provider, location=location.id) }}"
|
||||
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>
|
||||
<weather-location location="{{location.model_dump() | tojson | forceescape}}"></weather-location>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<hr>
|
||||
{% endif %}
|
||||
<ul id="storedLocations"
|
||||
class="list-group mb-5">
|
||||
</ul>
|
||||
<script>
|
||||
(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();">✕</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 searchQuery = params.get('query');
|
||||
if (searchQuery) {
|
||||
document.querySelector('#query').value = searchQuery;
|
||||
} else {
|
||||
document.loadLocations();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", (event) => {
|
||||
weatherLocationManager.loadLocations('#storedLocations');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -137,7 +137,7 @@
|
||||
{% for value in response.values %}
|
||||
<td class="precipitation"
|
||||
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>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
|
||||
@@ -85,6 +85,7 @@ class GismeteoApi(WeatherApi):
|
||||
Location(
|
||||
id=f"{item['slug']}-{item['id']}",
|
||||
name=item["translations"]["kk"]["city"]["name"],
|
||||
provider=self.provider,
|
||||
lat=item["coordinates"]["latitude"],
|
||||
lon=item["coordinates"]["longitude"],
|
||||
country=item["translations"]["kk"]["country"]["name"],
|
||||
|
||||
@@ -38,6 +38,7 @@ class OpenWeatherApi(WeatherApi):
|
||||
Location(
|
||||
id=f"{item.lat}:{item.lon}",
|
||||
name=item.name,
|
||||
provider=self.provider,
|
||||
lat=item.lat,
|
||||
lon=item.lon,
|
||||
country=item.country,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import datetime
|
||||
from enum import Enum
|
||||
from enum import StrEnum, auto
|
||||
from typing import Self
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -12,6 +13,7 @@ class Model(BaseModel):
|
||||
class Location(Model):
|
||||
id: str
|
||||
name: str
|
||||
provider: str
|
||||
lat: float
|
||||
lon: float
|
||||
country: str
|
||||
@@ -20,21 +22,21 @@ class Location(Model):
|
||||
subdistrict: str
|
||||
|
||||
|
||||
class Cloudness(str, Enum):
|
||||
CLEAR = "clear"
|
||||
PARTLY_CLOUDY = "party_cloudy"
|
||||
CLOUDY = "cloudy"
|
||||
MAINLY_CLOUDY = "mainly_cloudy"
|
||||
class Cloudness(StrEnum):
|
||||
CLEAR = auto()
|
||||
PARTLY_CLOUDY = auto()
|
||||
CLOUDY = auto()
|
||||
MAINLY_CLOUDY = auto()
|
||||
|
||||
|
||||
class Precipitation(str, Enum):
|
||||
NO = "no"
|
||||
SMALL_RAIN = "small_rain"
|
||||
RAIN = "rain"
|
||||
HEAVY_RAIN = "heavy_rain"
|
||||
SHOWER = "shower"
|
||||
SNOW = "snow"
|
||||
HEAVY_SNOW = "heavy_snow"
|
||||
class Precipitation(StrEnum):
|
||||
NO = auto()
|
||||
SMALL_RAIN = auto()
|
||||
RAIN = auto()
|
||||
HEAVY_RAIN = auto()
|
||||
SHOWER = auto()
|
||||
SNOW = auto()
|
||||
HEAVY_SNOW = auto()
|
||||
|
||||
|
||||
class Sky(Model):
|
||||
@@ -44,16 +46,16 @@ class Sky(Model):
|
||||
fog: bool
|
||||
|
||||
|
||||
class WindDirection(str, Enum):
|
||||
CALM = "calm"
|
||||
N = "N"
|
||||
NE = "NE"
|
||||
E = "E"
|
||||
SE = "SE"
|
||||
S = "S"
|
||||
SW = "SW"
|
||||
W = "W"
|
||||
NW = "NW"
|
||||
class WindDirection(StrEnum):
|
||||
CALM = auto()
|
||||
N = auto()
|
||||
NE = auto()
|
||||
E = auto()
|
||||
SE = auto()
|
||||
S = auto()
|
||||
SW = auto()
|
||||
W = auto()
|
||||
NW = auto()
|
||||
|
||||
|
||||
class WindDirectionDeg(float):
|
||||
@@ -89,7 +91,7 @@ class WindDirectionDeg(float):
|
||||
raise ValueError(self)
|
||||
|
||||
@classmethod
|
||||
def from_direction(cls, direction: WindDirection) -> "WindDirectionDeg":
|
||||
def from_direction(cls, direction: WindDirection) -> Self:
|
||||
return cls(
|
||||
{
|
||||
WindDirection.CALM: -1,
|
||||
|
||||
@@ -3,6 +3,7 @@ import "./components";
|
||||
import "./language";
|
||||
import "./main.scss";
|
||||
import "./theme";
|
||||
import "./weather/weather";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", (event) => {
|
||||
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||
|
||||
2
static/src/lib/bootstrap.scss
vendored
2
static/src/lib/bootstrap.scss
vendored
@@ -30,7 +30,7 @@
|
||||
//@import "bootstrap/scss/accordion";
|
||||
//@import "bootstrap/scss/breadcrumb";
|
||||
//@import "bootstrap/scss/pagination";
|
||||
//@import "bootstrap/scss/badge";
|
||||
@import "bootstrap/scss/badge";
|
||||
//@import "bootstrap/scss/alert";
|
||||
//@import "bootstrap/scss/progress";
|
||||
@import "bootstrap/scss/list-group";
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
@import "./lib/weather-icons/weather-icons";
|
||||
|
||||
@import "./widget.scss";
|
||||
@import "./weather.scss";
|
||||
@import "./weather/weather.scss";
|
||||
|
||||
.table.table-compact {
|
||||
td {
|
||||
|
||||
116
static/src/weather/weather.ts
Normal file
116
static/src/weather/weather.ts
Normal 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>
|
||||
✕
|
||||
</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();
|
||||
Reference in New Issue
Block a user