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 = 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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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();">✕</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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"]');
|
||||||
|
|||||||
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/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";
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
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