Weather: icon-left/temp-right layout, feels-like/high-low/rain
chance, and an hourly temperature graph - Current conditions block redone horizontally (icon left, big temp + description + an extras line on the right) instead of stacked vertically. - New extras line: "feels like" (apparent_temperature), today's high/low, and rain chance (precipitation_probability_max) -- each independently toggleable in settings, all on by default. Switched from the legacy current_weather=true param to Open-Meteo's current= param, since that's what exposes apparent_temperature; confirmed field names/formats live before wiring them in. - New hourly-today temperature graph (also toggleable): a polyline across today's 24 hourly points, min/max temps labeled at their points, a red dot marking the current hour, and hour labels along the bottom. - When both the graph and the daily forecast strip are shown, they split the remaining vertical space; either can be toggled off to give the other more room, matching how forecast_days already worked.
This commit is contained in:
1 parent
132e133b1d
commit
27f4397b04
3 files changed
+218
-58
No files matched your search
+192
-58
@@ -1,6 +1,7 @@
|
|||||||
"""Current weather + daily forecast via Open-Meteo (free, no API key)."""
|
"""Current weather + daily forecast via Open-Meteo (free, no API key)."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@@ -10,6 +11,10 @@ from screens.base import Screen
|
|||||||
from screens.render_utils import load_font, text_width
|
from screens.render_utils import load_font, text_width
|
||||||
from screens.weather_icons import WMO_CODES, draw_weather_icon
|
from screens.weather_icons import WMO_CODES, draw_weather_icon
|
||||||
|
|
||||||
|
BLACK = (0, 0, 0)
|
||||||
|
BLUE = (0, 0, 255)
|
||||||
|
RED = (200, 0, 0)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
HTTP_TIMEOUT = 10
|
HTTP_TIMEOUT = 10
|
||||||
@@ -25,7 +30,13 @@ class WeatherScreen(Screen):
|
|||||||
poll_interval = 1800 # fallback default (30 min) if settings has no update_minutes yet
|
poll_interval = 1800 # fallback default (30 min) if settings has no update_minutes yet
|
||||||
|
|
||||||
def default_settings(self) -> dict:
|
def default_settings(self) -> dict:
|
||||||
return {"city": "", "latitude": None, "longitude": None, "forecast_days": 3, "update_minutes": 30}
|
return {
|
||||||
|
"city": "", "latitude": None, "longitude": None, "forecast_days": 3, "update_minutes": 30,
|
||||||
|
"show_feels_like": True,
|
||||||
|
"show_high_low": True,
|
||||||
|
"show_rain_chance": True,
|
||||||
|
"show_hourly_graph": True,
|
||||||
|
}
|
||||||
|
|
||||||
def get_poll_interval(self, settings: dict) -> int:
|
def get_poll_interval(self, settings: dict) -> int:
|
||||||
raw = settings.get("update_minutes")
|
raw = settings.get("update_minutes")
|
||||||
@@ -76,8 +87,13 @@ class WeatherScreen(Screen):
|
|||||||
params={
|
params={
|
||||||
"latitude": lat,
|
"latitude": lat,
|
||||||
"longitude": lon,
|
"longitude": lon,
|
||||||
"current_weather": "true",
|
# "current" (not the legacy "current_weather=true") is what
|
||||||
"daily": "weather_code,temperature_2m_max,temperature_2m_min",
|
# exposes apparent_temperature. Confirmed live which fields
|
||||||
|
# come back plain-local (no Z suffix) under timezone=auto,
|
||||||
|
# same as the "daily" block already relied on below.
|
||||||
|
"current": "temperature_2m,apparent_temperature,weather_code",
|
||||||
|
"daily": "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max",
|
||||||
|
"hourly": "temperature_2m",
|
||||||
"forecast_days": forecast_days,
|
"forecast_days": forecast_days,
|
||||||
"timezone": "auto",
|
"timezone": "auto",
|
||||||
},
|
},
|
||||||
@@ -86,11 +102,11 @@ class WeatherScreen(Screen):
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
|
|
||||||
current = payload.get("current_weather") or {}
|
current = payload.get("current") or {}
|
||||||
if "temperature" not in current:
|
if "temperature_2m" not in current:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
code = int(current.get("weathercode", -1))
|
code = int(current.get("weather_code", -1))
|
||||||
description, kind = WMO_CODES.get(code, ("Unbekannt", "clear"))
|
description, kind = WMO_CODES.get(code, ("Unbekannt", "clear"))
|
||||||
|
|
||||||
days = []
|
days = []
|
||||||
@@ -109,12 +125,31 @@ class WeatherScreen(Screen):
|
|||||||
"low": daily["temperature_2m_min"][i],
|
"low": daily["temperature_2m_min"][i],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
rain_chance = None
|
||||||
|
probs = daily.get("precipitation_probability_max") or []
|
||||||
|
if probs and probs[0] is not None:
|
||||||
|
rain_chance = int(probs[0])
|
||||||
|
|
||||||
|
# Today's hourly points, for the day-graph -- "hourly" spans all of
|
||||||
|
# forecast_days, filtered down to just today's date string prefix.
|
||||||
|
today_str = date.today().isoformat()
|
||||||
|
hourly = payload.get("hourly") or {}
|
||||||
|
hourly_today = [
|
||||||
|
{"hour": int(t[11:13]), "temp": temp}
|
||||||
|
for t, temp in zip(hourly.get("time", []), hourly.get("temperature_2m", []))
|
||||||
|
if t.startswith(today_str)
|
||||||
|
]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"temperature": current["temperature"],
|
"temperature": current["temperature_2m"],
|
||||||
"windspeed": current.get("windspeed"),
|
"feels_like": current.get("apparent_temperature"),
|
||||||
"description": description,
|
"description": description,
|
||||||
"kind": kind,
|
"kind": kind,
|
||||||
"days": days,
|
"days": days,
|
||||||
|
"high_today": days[0]["high"] if days else None,
|
||||||
|
"low_today": days[0]["low"] if days else None,
|
||||||
|
"rain_chance": rain_chance,
|
||||||
|
"hourly_today": hourly_today,
|
||||||
}
|
}
|
||||||
|
|
||||||
def render(self, state, settings: dict, resolution: tuple[int, int]) -> Image.Image:
|
def render(self, state, settings: dict, resolution: tuple[int, int]) -> Image.Image:
|
||||||
@@ -122,6 +157,7 @@ class WeatherScreen(Screen):
|
|||||||
image = Image.new("RGB", (width, height), "white")
|
image = Image.new("RGB", (width, height), "white")
|
||||||
draw = ImageDraw.Draw(image)
|
draw = ImageDraw.Draw(image)
|
||||||
scale = min(width, height)
|
scale = min(width, height)
|
||||||
|
pad = width * 0.06
|
||||||
|
|
||||||
if not state:
|
if not state:
|
||||||
font = load_font(size=int(scale * 0.06))
|
font = load_font(size=int(scale * 0.06))
|
||||||
@@ -134,72 +170,170 @@ class WeatherScreen(Screen):
|
|||||||
|
|
||||||
days = state.get("days") or []
|
days = state.get("days") or []
|
||||||
has_forecast = bool(days)
|
has_forecast = bool(days)
|
||||||
|
hourly = state.get("hourly_today") or []
|
||||||
|
show_graph = settings.get("show_hourly_graph", True) and len(hourly) >= 2
|
||||||
|
lower_sections = int(has_forecast) + int(show_graph)
|
||||||
|
|
||||||
# Icon + temperature + description, stacked. Sized from `scale`
|
# Current conditions: icon on the left, temp/description/extras
|
||||||
# directly (not a guessed fraction of current_h) and measured via
|
# stacked on the right -- sized from `scale` and measured via actual
|
||||||
# actual text bounding boxes, so current_h can be *derived* from
|
# text bounding boxes so current_h can be *derived* from what the
|
||||||
# what the block actually needs -- rather than picking current_h
|
# block actually needs, same principle as the original stacked
|
||||||
# first and hoping the content fits in it, which overflowed into
|
# layout, just laid out horizontally now.
|
||||||
# the forecast row for some font metrics.
|
icon_size = scale * 0.34
|
||||||
icon_size = scale * (0.24 if has_forecast else 0.5)
|
temp_font = load_font(size=int(scale * 0.15), bold=True)
|
||||||
temp_font = load_font(size=int(scale * (0.13 if has_forecast else 0.22)), bold=True)
|
desc_font = load_font(size=int(scale * 0.055))
|
||||||
desc_font = load_font(size=int(scale * (0.045 if has_forecast else 0.05)))
|
extra_font = load_font(size=int(scale * 0.04))
|
||||||
|
|
||||||
temp_str = f"{round(state['temperature'])}°"
|
temp_str = f"{round(state['temperature'])}°"
|
||||||
desc = state["description"]
|
desc = state["description"]
|
||||||
|
|
||||||
|
extra_parts = []
|
||||||
|
if settings.get("show_feels_like", True) and state.get("feels_like") is not None:
|
||||||
|
extra_parts.append(f"Gefühlt {round(state['feels_like'])}°")
|
||||||
|
if (
|
||||||
|
settings.get("show_high_low", True)
|
||||||
|
and state.get("high_today") is not None
|
||||||
|
and state.get("low_today") is not None
|
||||||
|
):
|
||||||
|
extra_parts.append(f"H {round(state['high_today'])}° L {round(state['low_today'])}°")
|
||||||
|
if settings.get("show_rain_chance", True) and state.get("rain_chance") is not None:
|
||||||
|
extra_parts.append(f"{state['rain_chance']}% Regen")
|
||||||
|
extra_line = " · ".join(extra_parts)
|
||||||
|
|
||||||
temp_left, temp_top, temp_right, temp_bottom = draw.textbbox((0, 0), temp_str, font=temp_font)
|
temp_left, temp_top, temp_right, temp_bottom = draw.textbbox((0, 0), temp_str, font=temp_font)
|
||||||
temp_h = temp_bottom - temp_top
|
temp_h = temp_bottom - temp_top
|
||||||
desc_left, desc_top, desc_right, desc_bottom = draw.textbbox((0, 0), desc, font=desc_font)
|
desc_left, desc_top, desc_right, desc_bottom = draw.textbbox((0, 0), desc, font=desc_font)
|
||||||
desc_h = desc_bottom - desc_top
|
desc_h = desc_bottom - desc_top
|
||||||
|
extra_top = extra_h = 0
|
||||||
|
if extra_line:
|
||||||
|
extra_left, extra_top, extra_right, extra_bottom = draw.textbbox((0, 0), extra_line, font=extra_font)
|
||||||
|
extra_h = extra_bottom - extra_top
|
||||||
|
|
||||||
icon_gap = icon_size * 0.12
|
text_gap = temp_h * 0.22
|
||||||
temp_gap = temp_h * 0.4
|
text_block_h = temp_h + text_gap + desc_h + ((text_gap + extra_h) if extra_line else 0)
|
||||||
block_h = icon_size + icon_gap + temp_h + temp_gap + desc_h
|
current_h = max(icon_size, text_block_h)
|
||||||
|
top_pad = (height - current_h) / 2 if lower_sections == 0 else height * 0.05
|
||||||
|
block_top = top_pad
|
||||||
|
|
||||||
if has_forecast:
|
icon_cx = pad + icon_size / 2
|
||||||
top_pad = height * 0.04
|
icon_cy = block_top + current_h / 2
|
||||||
bottom_pad = height * 0.035
|
draw_weather_icon(draw, state["kind"], icon_cx, icon_cy, icon_size)
|
||||||
current_h = block_h + top_pad + bottom_pad
|
|
||||||
block_top = top_pad
|
|
||||||
else:
|
|
||||||
current_h = height
|
|
||||||
block_top = (current_h - block_h) / 2
|
|
||||||
|
|
||||||
icon_cy = block_top + icon_size / 2
|
text_x = pad + icon_size + scale * 0.05
|
||||||
draw_weather_icon(draw, state["kind"], width / 2, icon_cy, icon_size)
|
text_top = block_top + (current_h - text_block_h) / 2
|
||||||
|
|
||||||
temp_y = block_top + icon_size + icon_gap
|
draw.text((text_x, text_top - temp_top), temp_str, font=temp_font, fill="black")
|
||||||
tw = temp_right - temp_left
|
|
||||||
draw.text(((width - tw) / 2, temp_y - temp_top), temp_str, font=temp_font, fill="black")
|
|
||||||
|
|
||||||
desc_y = temp_y + temp_h + temp_gap
|
desc_y = text_top + temp_h + text_gap
|
||||||
dw = desc_right - desc_left
|
draw.text((text_x, desc_y - desc_top), desc, font=desc_font, fill="black")
|
||||||
draw.text(((width - dw) / 2, desc_y - desc_top), desc, font=desc_font, fill="black")
|
|
||||||
|
|
||||||
if has_forecast:
|
if extra_line:
|
||||||
forecast_y = current_h
|
extra_y = desc_y + desc_h + text_gap
|
||||||
forecast_h = height - forecast_y
|
draw.text((text_x, extra_y - extra_top), extra_line, font=extra_font, fill="black")
|
||||||
draw.rectangle((width * 0.06, forecast_y, width * 0.94, forecast_y + 2), fill=(0, 0, 0))
|
|
||||||
|
|
||||||
col_w = width / len(days)
|
if lower_sections == 0:
|
||||||
label_font = load_font(size=int(scale * 0.052), bold=True)
|
return image
|
||||||
day_temp_font = load_font(size=int(scale * 0.046), bold=True)
|
|
||||||
day_icon_size = min(col_w * 0.75, forecast_h * 0.48)
|
|
||||||
|
|
||||||
for i, day in enumerate(days):
|
current_bottom = block_top + current_h + height * 0.045
|
||||||
cx = col_w * i + col_w / 2
|
remaining_h = height - current_bottom - height * 0.02
|
||||||
y = forecast_y + forecast_h * 0.1
|
content_w = width - 2 * pad
|
||||||
|
|
||||||
label = day["label"]
|
if show_graph and has_forecast:
|
||||||
lw = text_width(draw, label, label_font)
|
graph_h = remaining_h * 0.48
|
||||||
draw.text((cx - lw / 2, y), label, font=label_font, fill="black")
|
self._draw_hourly_graph(draw, hourly, pad, current_bottom, content_w, graph_h, scale)
|
||||||
y += label_font.size * 1.4
|
self._draw_daily_forecast(
|
||||||
|
draw, days, pad, current_bottom + graph_h + height * 0.015,
|
||||||
draw_weather_icon(draw, day["kind"], cx, y + day_icon_size / 2, day_icon_size)
|
content_w, remaining_h - graph_h - height * 0.015, scale,
|
||||||
y += day_icon_size * 1.1
|
)
|
||||||
|
elif show_graph:
|
||||||
temps = f"{round(day['high'])}°/{round(day['low'])}°"
|
self._draw_hourly_graph(draw, hourly, pad, current_bottom, content_w, remaining_h, scale)
|
||||||
dtw = text_width(draw, temps, day_temp_font)
|
elif has_forecast:
|
||||||
draw.text((cx - dtw / 2, y), temps, font=day_temp_font, fill="black")
|
self._draw_daily_forecast(draw, days, pad, current_bottom, content_w, remaining_h, scale)
|
||||||
|
|
||||||
return image
|
return image
|
||||||
|
|
||||||
|
def _draw_daily_forecast(self, draw, days, x, y, w, h, scale):
|
||||||
|
draw.rectangle((x, y, x + w, y + 2), fill=BLACK)
|
||||||
|
content_top = y + h * 0.1
|
||||||
|
content_h = h - h * 0.1
|
||||||
|
|
||||||
|
col_w = w / len(days)
|
||||||
|
label_font = load_font(size=int(scale * 0.052), bold=True)
|
||||||
|
day_temp_font = load_font(size=int(scale * 0.046), bold=True)
|
||||||
|
day_icon_size = min(col_w * 0.75, content_h * 0.48)
|
||||||
|
|
||||||
|
for i, day in enumerate(days):
|
||||||
|
cx = x + col_w * i + col_w / 2
|
||||||
|
yy = content_top
|
||||||
|
|
||||||
|
label = day["label"]
|
||||||
|
lw = text_width(draw, label, label_font)
|
||||||
|
draw.text((cx - lw / 2, yy), label, font=label_font, fill="black")
|
||||||
|
yy += label_font.size * 1.4
|
||||||
|
|
||||||
|
draw_weather_icon(draw, day["kind"], cx, yy + day_icon_size / 2, day_icon_size)
|
||||||
|
yy += day_icon_size * 1.1
|
||||||
|
|
||||||
|
temps = f"{round(day['high'])}°/{round(day['low'])}°"
|
||||||
|
dtw = text_width(draw, temps, day_temp_font)
|
||||||
|
draw.text((cx - dtw / 2, yy), temps, font=day_temp_font, fill="black")
|
||||||
|
|
||||||
|
def _draw_hourly_graph(self, draw, hourly, x, y, w, h, scale):
|
||||||
|
"""A simple polyline of today's hourly temps -- min/max labeled at
|
||||||
|
their points, a red dot marking the current hour, and a handful of
|
||||||
|
evenly-spaced hour labels along the bottom."""
|
||||||
|
temps = [pt["temp"] for pt in hourly]
|
||||||
|
lo, hi = min(temps), max(temps)
|
||||||
|
span = max(hi - lo, 1.0) # avoid a divide-by-zero on a dead-flat day
|
||||||
|
|
||||||
|
point_font = load_font(size=int(scale * 0.032))
|
||||||
|
axis_font = load_font(size=int(scale * 0.028))
|
||||||
|
|
||||||
|
top_margin = point_font.size * 1.5
|
||||||
|
bottom_margin = axis_font.size * 1.6
|
||||||
|
plot_top = y + top_margin
|
||||||
|
plot_h = max(1.0, h - top_margin - bottom_margin)
|
||||||
|
|
||||||
|
n = len(hourly)
|
||||||
|
step_x = w / (n - 1)
|
||||||
|
|
||||||
|
def point_xy(i):
|
||||||
|
px = x + step_x * i
|
||||||
|
py = plot_top + plot_h * (1 - (hourly[i]["temp"] - lo) / span)
|
||||||
|
return px, py
|
||||||
|
|
||||||
|
points = [point_xy(i) for i in range(n)]
|
||||||
|
draw.line(points, fill=BLUE, width=max(2, int(scale * 0.006)), joint="curve")
|
||||||
|
|
||||||
|
def label_point(i):
|
||||||
|
# Always above the point, never below -- a below-point label on
|
||||||
|
# a trough sitting right at the plot's left/right edge would
|
||||||
|
# otherwise land in the same spot as the hour-axis labels.
|
||||||
|
px, py = points[i]
|
||||||
|
label = f"{round(hourly[i]['temp'])}°"
|
||||||
|
lw = text_width(draw, label, point_font)
|
||||||
|
ly = py - point_font.size * 1.25
|
||||||
|
lx = min(max(px - lw / 2, x), x + w - lw)
|
||||||
|
draw.text((lx, ly), label, font=point_font, fill="black")
|
||||||
|
|
||||||
|
max_i = max(range(n), key=lambda i: hourly[i]["temp"])
|
||||||
|
min_i = min(range(n), key=lambda i: hourly[i]["temp"])
|
||||||
|
label_point(max_i)
|
||||||
|
if min_i != max_i:
|
||||||
|
label_point(min_i)
|
||||||
|
|
||||||
|
now_hour = time.localtime().tm_hour
|
||||||
|
now_i = next((i for i, pt in enumerate(hourly) if pt["hour"] == now_hour), None)
|
||||||
|
if now_i is not None:
|
||||||
|
px, py = points[now_i]
|
||||||
|
r = max(3, int(scale * 0.011))
|
||||||
|
draw.ellipse((px - r, py - r, px + r, py + r), fill=RED)
|
||||||
|
|
||||||
|
axis_y = y + h - axis_font.size
|
||||||
|
tick_idxs = sorted(set((0, n // 4, n // 2, (3 * n) // 4, n - 1)))
|
||||||
|
for i in tick_idxs:
|
||||||
|
label = f"{hourly[i]['hour']:02d}"
|
||||||
|
lw = text_width(draw, label, axis_font)
|
||||||
|
px, _ = points[i]
|
||||||
|
lx = min(max(px - lw / 2, x), x + w - lw)
|
||||||
|
draw.text((lx, axis_y), label, font=axis_font, fill="black")
|
||||||
@@ -69,6 +69,12 @@ def create_app(config, screens: dict, scheduler) -> Flask:
|
|||||||
if forecast_days not in (3, 5, 7):
|
if forecast_days not in (3, 5, 7):
|
||||||
forecast_days = 3
|
forecast_days = 3
|
||||||
update_minutes = max(1, int(request.form.get("update_minutes", 30) or 30))
|
update_minutes = max(1, int(request.form.get("update_minutes", 30) or 30))
|
||||||
|
display_toggles = {
|
||||||
|
"show_hourly_graph": "show_hourly_graph" in request.form,
|
||||||
|
"show_feels_like": "show_feels_like" in request.form,
|
||||||
|
"show_high_low": "show_high_low" in request.form,
|
||||||
|
"show_rain_chance": "show_rain_chance" in request.form,
|
||||||
|
}
|
||||||
|
|
||||||
if city:
|
if city:
|
||||||
try:
|
try:
|
||||||
@@ -84,6 +90,7 @@ def create_app(config, screens: dict, scheduler) -> Flask:
|
|||||||
"longitude": result["longitude"],
|
"longitude": result["longitude"],
|
||||||
"forecast_days": forecast_days,
|
"forecast_days": forecast_days,
|
||||||
"update_minutes": update_minutes,
|
"update_minutes": update_minutes,
|
||||||
|
**display_toggles,
|
||||||
})
|
})
|
||||||
return redirect(url_for("settings_weather", saved=1))
|
return redirect(url_for("settings_weather", saved=1))
|
||||||
else:
|
else:
|
||||||
@@ -93,6 +100,7 @@ def create_app(config, screens: dict, scheduler) -> Flask:
|
|||||||
"longitude": float(lon_raw) if lon_raw else None,
|
"longitude": float(lon_raw) if lon_raw else None,
|
||||||
"forecast_days": forecast_days,
|
"forecast_days": forecast_days,
|
||||||
"update_minutes": update_minutes,
|
"update_minutes": update_minutes,
|
||||||
|
**display_toggles,
|
||||||
})
|
})
|
||||||
return redirect(url_for("settings_weather", saved=1))
|
return redirect(url_for("settings_weather", saved=1))
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,24 @@
|
|||||||
<input type="number" id="update_minutes" name="update_minutes" min="1" value="{{ settings.update_minutes or 30 }}">
|
<input type="number" id="update_minutes" name="update_minutes" min="1" value="{{ settings.update_minutes or 30 }}">
|
||||||
<p class="hint">Weather itself only refreshes the panel when something actually changed, so a shorter interval here mostly just means fresher data, not more flashing.</p>
|
<p class="hint">Weather itself only refreshes the panel when something actually changed, so a shorter interval here mostly just means fresher data, not more flashing.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="show_hourly_graph" {{ "checked" if settings.show_hourly_graph != False }}>
|
||||||
|
Hourly temperature graph
|
||||||
|
</label>
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="show_feels_like" {{ "checked" if settings.show_feels_like != False }}>
|
||||||
|
"Feels like" temperature
|
||||||
|
</label>
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="show_high_low" {{ "checked" if settings.show_high_low != False }}>
|
||||||
|
Today's high/low
|
||||||
|
</label>
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="show_rain_chance" {{ "checked" if settings.show_rain_chance != False }}>
|
||||||
|
Rain chance
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
Reference in new issue
Block a user