feat(easel): add localization
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
*.pyc
|
||||
*.mo
|
||||
.pytest_cache
|
||||
.venv
|
||||
#.vscode
|
||||
|
||||
@@ -19,7 +19,7 @@ FROM python:3.12-slim
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
WORKDIR /app
|
||||
RUN apt update && \
|
||||
apt install -y locales && \
|
||||
apt install -y locales gettext && \
|
||||
sed -i -e 's/# ru_RU.UTF-8 UTF-8/ru_RU.UTF-8 UTF-8/' /etc/locale.gen && \
|
||||
dpkg-reconfigure --frontend=noninteractive locales
|
||||
ENV LANG=ru_RU.UTF-8
|
||||
@@ -28,5 +28,6 @@ ENV TZ="Europe/Moscow"
|
||||
COPY --from=builder /app ./
|
||||
COPY --from=node-builder /app/dist ./static/dist
|
||||
COPY gallery gallery/
|
||||
RUN cd gallery/easel/route/view/locales/ru/LC_MESSAGES && msgfmt messages.po
|
||||
|
||||
CMD ["uvicorn", "gallery.main:app", "--host", "0.0.0.0", "--port", "80", "--log-config", "gallery/logging.yaml"]
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import locale as _locale
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from gallery.sketch.bundle import ApiBundle
|
||||
from gallery.util import root_path
|
||||
|
||||
from .route import api, doc, view
|
||||
from .route import api, doc
|
||||
from .route.view import router as view_router
|
||||
|
||||
DEFAULT_LOCALE = "ru_RU.UTF-8"
|
||||
|
||||
@@ -17,7 +20,8 @@ def build_app(api_bundle: ApiBundle, *, locale: str = DEFAULT_LOCALE) -> FastAPI
|
||||
redoc_url=None,
|
||||
)
|
||||
app.state.api = api_bundle
|
||||
app.mount("/static", StaticFiles(directory=root_path / "static/dist"))
|
||||
doc.mount(app)
|
||||
api.mount(app)
|
||||
view.mount(app)
|
||||
app.include_router(view_router)
|
||||
return app
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from . import common, schedule, weather
|
||||
from .common import router as common_router
|
||||
from .schedule import router as schedule_router
|
||||
from .translation import set_language
|
||||
from .weather import router as weather_router
|
||||
|
||||
|
||||
def mount(app: FastAPI):
|
||||
common.mount(app)
|
||||
weather.mount(app)
|
||||
schedule.mount(app)
|
||||
router = APIRouter(dependencies=[Depends(set_language)])
|
||||
router.include_router(common_router)
|
||||
router.include_router(weather_router)
|
||||
router.include_router(schedule_router)
|
||||
|
||||
@@ -1,40 +1,41 @@
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from gallery.util import root_path
|
||||
from gallery.version import __version__
|
||||
|
||||
from ..translation import _
|
||||
|
||||
|
||||
class Section(NamedTuple):
|
||||
link: str
|
||||
title: str
|
||||
icon: str
|
||||
|
||||
|
||||
SECTIONS = [
|
||||
Section("weather", "Weather"),
|
||||
Section("schedule", "TV program"),
|
||||
Section("weather", "Weather", "brightness-high"),
|
||||
Section("schedule", "TV program", "tv"),
|
||||
]
|
||||
|
||||
base_dir = Path(__file__).parent
|
||||
|
||||
def mount(app: FastAPI):
|
||||
base_dir = Path(__file__).parent
|
||||
print("!", root_path / "static/dist")
|
||||
app.mount("/static/main", StaticFiles(directory=root_path / "static/dist"))
|
||||
app.mount("/static/common", StaticFiles(directory=base_dir / "static"))
|
||||
templates = Jinja2Templates(directory=base_dir / "templates")
|
||||
router = APIRouter()
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def get_section_list(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="root_index.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"sections": SECTIONS,
|
||||
},
|
||||
)
|
||||
templates = Jinja2Templates(directory=base_dir / "templates")
|
||||
templates.env.globals.update({"_": _})
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def get_section_list(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="root_index.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"sections": SECTIONS,
|
||||
},
|
||||
)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="{{request.state.language}}">
|
||||
|
||||
<head>
|
||||
{% block head %}
|
||||
@@ -10,13 +10,11 @@
|
||||
content="ie=edge">
|
||||
<title>{% block title %}{% endblock %}</title>
|
||||
<link rel="stylesheet"
|
||||
href="/static/main/gallery-static.css?v={{version}}">
|
||||
href="/static/gallery.css?v={{version}}">
|
||||
<script type="module"
|
||||
src="/static/main/gallery-static.js?v={{version}}"></script>
|
||||
<link rel="stylesheet"
|
||||
href="/static/common/style.css?v={{version}}">
|
||||
src="/static/gallery.es.js?v={{version}}"></script>
|
||||
<link rel="icon"
|
||||
href="/static/common/favicon.ico?v={{version}}"
|
||||
href="/favicon.ico?v={{version}}"
|
||||
type="image/x-icon">
|
||||
{% endblock %}
|
||||
</head>
|
||||
@@ -24,13 +22,43 @@
|
||||
<body>
|
||||
<div class="app col-lg-8 mx-auto p-3 py-md-5">
|
||||
<header class="d-flex align-items-center pb-3 mb-5 border-bottom">
|
||||
<a href="/"
|
||||
class="d-flex align-items-center text-dark text-decoration-none">
|
||||
<div class="icon me-2"
|
||||
style="background-image: url(/static/common/gallery.png);"></div>
|
||||
<span class="fs-4 text-body">API Gallery</span>
|
||||
</a>
|
||||
<app-link href="/"
|
||||
icon="gear">API Gallery</app-link>
|
||||
{% block header %}{% endblock %}
|
||||
<ul class="navbar-nav flex-row flex-wrap ms-md-auto">
|
||||
<li class="nav-item dropdown">
|
||||
<button class="btn btn-link nav-link py-2 px-0 px-lg-2 dropdown-toggle d-flex align-items-center"
|
||||
id="bd-language"
|
||||
type="button"
|
||||
aria-expanded="false"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-label="Select language (default)">
|
||||
<span class="me-2 language-icon-active">🇬🇧</span>
|
||||
<span class="d-lg-none ms-2"
|
||||
id="bd-language-text">Select language</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end"
|
||||
aria-labelledby="bd-language-text">
|
||||
<li>
|
||||
<button type="button"
|
||||
class="dropdown-item d-flex align-items-center"
|
||||
data-bs-language-value="en"
|
||||
aria-pressed="false">
|
||||
<span class="me-2 language-icon">🇬🇧</span>
|
||||
English
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button"
|
||||
class="dropdown-item d-flex align-items-center"
|
||||
data-bs-language-value="ru"
|
||||
aria-pressed="false">
|
||||
<span class="me-2 language-icon">🇷🇺</span>
|
||||
Russian
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<button class="btn btn-link nav-link py-2 px-0 px-lg-2 dropdown-toggle d-flex align-items-center"
|
||||
id="bd-theme"
|
||||
@@ -84,13 +112,6 @@
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
document.toggleTheme = () => {
|
||||
const current = document.documentElement.getAttribute('data-bs-theme');
|
||||
const next = current === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-bs-theme', next);
|
||||
localStorage.setItem('theme', next);
|
||||
};
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const widget = params.get('widget') || window.location.hostname.startsWith('weather');
|
||||
if (widget) {
|
||||
|
||||
@@ -10,15 +10,17 @@
|
||||
{% for section in sections %}
|
||||
<a href="{{section.link}}"
|
||||
class="list-group-item list-group-item-action px-4">
|
||||
<div class="d-flex w-100">
|
||||
<span class="icon me-2"
|
||||
style="background-image: url(/static/{{section.link}}/{{section.link}}.png);"></span>
|
||||
<h4>{{section.title}}</h4>
|
||||
</div>
|
||||
<app-link href="{{section.link}}"
|
||||
icon="{{section.icon}}">
|
||||
{{_(section.title)}}
|
||||
</app-link>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<hr class="col-3 col-md-2 mb-5">
|
||||
<h1>Docs</h1>
|
||||
<a href="/docs" target="_blank"><h4>Swagger</h4></a>
|
||||
<a href="/docs"
|
||||
target="_blank">
|
||||
<h4>Swagger</h4>
|
||||
</a>
|
||||
{% endblock %}
|
||||
15
gallery/easel/route/view/locales/ru/LC_MESSAGES/messages.po
Normal file
15
gallery/easel/route/view/locales/ru/LC_MESSAGES/messages.po
Normal file
@@ -0,0 +1,15 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Gallery\n"
|
||||
"Last-Translator: shmyga <shmyga.z@gmail.com>\n"
|
||||
"Language: ru\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
|
||||
|
||||
msgid "Weather"
|
||||
msgstr "Погода"
|
||||
|
||||
msgid "TV program"
|
||||
msgstr "Телепрограмма"
|
||||
@@ -1,10 +1,8 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from gallery.easel.core import AppRequest
|
||||
@@ -12,71 +10,76 @@ from gallery.sketch.schedule.catalog import BUNDLE
|
||||
from gallery.version import __version__
|
||||
|
||||
from ..common.util import TagType, TagUtil
|
||||
from ..translation import _
|
||||
from .filters import timedelta_format
|
||||
|
||||
base_dir = Path(__file__).parent
|
||||
templates = Jinja2Templates(
|
||||
directory=[
|
||||
base_dir.parent / "common/templates",
|
||||
base_dir / "templates",
|
||||
]
|
||||
)
|
||||
templates.env.globals.update({"_": _})
|
||||
templates.env.filters["timedelta_format"] = timedelta_format
|
||||
|
||||
def mount(app: FastAPI):
|
||||
base_dir = Path(__file__).parent
|
||||
app.mount("/static/schedule", StaticFiles(directory=base_dir / "static"))
|
||||
templates = Jinja2Templates(
|
||||
directory=[
|
||||
base_dir.parent / "common/templates",
|
||||
base_dir / "templates",
|
||||
]
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/schedule", response_class=HTMLResponse)
|
||||
async def get_schedule_list(request: AppRequest):
|
||||
schedule_api = request.app.state.api.schedule
|
||||
channels = await schedule_api.get_channels()
|
||||
channels_data = BUNDLE.select_items(channels)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="index.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"channels": channels_data,
|
||||
},
|
||||
)
|
||||
templates.env.filters["timedelta_format"] = timedelta_format
|
||||
|
||||
@app.get("/schedule", response_class=HTMLResponse)
|
||||
async def get_schedule_list(request: AppRequest):
|
||||
schedule_api = request.app.state.api.schedule
|
||||
channels = await schedule_api.get_channels()
|
||||
channels_data = BUNDLE.select_items(channels)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="index.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"channels": channels_data,
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/schedule/tag/{tag}", response_class=HTMLResponse)
|
||||
async def get_schedule_tag(request: AppRequest, tag: str, live: bool = False):
|
||||
tag_value = TagUtil.parse_tag(tag)
|
||||
schedule_api = request.app.state.api.schedule
|
||||
results = await schedule_api.get_all_schedules(tag_value.date)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="schedule.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"tag_util": TagUtil,
|
||||
"datetime": datetime,
|
||||
"response": results[0],
|
||||
"responses": results,
|
||||
"live": live,
|
||||
},
|
||||
)
|
||||
@router.get("/schedule/tag/{tag}", response_class=HTMLResponse)
|
||||
async def get_schedule_tag(request: AppRequest, tag: str, live: bool = False):
|
||||
tag_value = TagUtil.parse_tag(tag)
|
||||
schedule_api = request.app.state.api.schedule
|
||||
results = await schedule_api.get_all_schedules(tag_value.date)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="schedule.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"tag_util": TagUtil,
|
||||
"datetime": datetime,
|
||||
"response": results[0],
|
||||
"responses": results,
|
||||
"live": live,
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/schedule/{channel}", response_class=RedirectResponse)
|
||||
async def get_channel_default(channel: str):
|
||||
return RedirectResponse(f"{channel}/tag/today")
|
||||
|
||||
@app.get("/schedule/{channel}/tag/{tag}", response_class=HTMLResponse)
|
||||
async def get_channel_tag(request: AppRequest, channel: str, tag: str):
|
||||
tag_value = TagUtil.parse_tag(tag)
|
||||
schedule_api = request.app.state.api.schedule
|
||||
if tag_value.type == TagType.DAY:
|
||||
response = await schedule_api.get_channel_schedule(channel, tag_value.date)
|
||||
else:
|
||||
raise ValueError(tag)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="channel.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"tag_util": TagUtil,
|
||||
"datetime": datetime,
|
||||
"response": response,
|
||||
},
|
||||
)
|
||||
@router.get("/schedule/{channel}", response_class=RedirectResponse)
|
||||
async def get_channel_default(channel: str):
|
||||
return RedirectResponse(f"{channel}/tag/today")
|
||||
|
||||
|
||||
@router.get("/schedule/{channel}/tag/{tag}", response_class=HTMLResponse)
|
||||
async def get_channel_tag(request: AppRequest, channel: str, tag: str):
|
||||
tag_value = TagUtil.parse_tag(tag)
|
||||
schedule_api = request.app.state.api.schedule
|
||||
if tag_value.type == TagType.DAY:
|
||||
response = await schedule_api.get_channel_schedule(channel, tag_value.date)
|
||||
else:
|
||||
raise ValueError(tag)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="channel.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"tag_util": TagUtil,
|
||||
"datetime": datetime,
|
||||
"response": response,
|
||||
},
|
||||
)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
@@ -2,13 +2,11 @@
|
||||
{% block title %}
|
||||
Программа | {{response.channel.name}} | {{response.date.strftime('%a, %d %B %Y')}}
|
||||
{% endblock %}
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet"
|
||||
href="/static/schedule/style.css?v={{version}}">
|
||||
<link rel="icon"
|
||||
href="/static/schedule/favicon.ico?v={{version}}"
|
||||
type="image/x-icon">
|
||||
|
||||
{% block header %}
|
||||
<span class="fs-4 text-body ms-2 me-2">/</span>
|
||||
<app-link href="/schedule"
|
||||
icon="tv">{{_("TV program")}}</app-link>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}TV program{% endblock %}
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet"
|
||||
href="/static/schedule/style.css?v={{version}}">
|
||||
<link rel="icon"
|
||||
href="/static/schedule/favicon.ico?v={{version}}"
|
||||
type="image/x-icon">
|
||||
{% endblock %}
|
||||
{% block title %}{{_("TV program")}}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>TV program</h1>
|
||||
<h1>{{_("TV program")}}</h1>
|
||||
<div class="list-group mb-5">
|
||||
<a href="schedule/tag/today"
|
||||
class="list-group-item list-group-item-action px-4">
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}
|
||||
{{'Прямые трансляции' if live else 'Программа'}} | {{response.date.strftime('%a, %d %B %Y')}}
|
||||
{{'Прямые трансляции' if live else _("TV program")}} | {{response.date.strftime('%a, %d %B %Y')}}
|
||||
{% endblock %}
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet"
|
||||
href="/static/schedule/style.css?v={{version}}">
|
||||
<link rel="icon"
|
||||
href="/static/schedule/favicon.ico?v={{version}}"
|
||||
type="image/x-icon">
|
||||
|
||||
{% block header %}
|
||||
<span class="fs-4 text-body ms-2 me-2">/</span>
|
||||
<app-link href="/schedule"
|
||||
icon="tv">{{_("TV program")}}</app-link>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
34
gallery/easel/route/view/translation.py
Normal file
34
gallery/easel/route/view/translation.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import gettext
|
||||
from contextvars import ContextVar
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import Cookie, Header, Request
|
||||
|
||||
_translation: ContextVar[gettext.GNUTranslations | gettext.NullTranslations] = (
|
||||
ContextVar("translation")
|
||||
)
|
||||
|
||||
|
||||
async def set_language(
|
||||
request: Request,
|
||||
accept_language: str = Header("en"),
|
||||
language: str | None = Cookie(None),
|
||||
):
|
||||
# Simplify the header (e.g., "en-US,en;q=0.9" -> "en")
|
||||
lang = language or accept_language.split(",")[0].split("-")[0]
|
||||
|
||||
try:
|
||||
t = gettext.translation(
|
||||
"messages", localedir=Path(__file__).parent / "locales", languages=[lang]
|
||||
)
|
||||
except FileNotFoundError:
|
||||
t = gettext.NullTranslations()
|
||||
|
||||
token = _translation.set(t)
|
||||
request.state.language = lang
|
||||
yield lang
|
||||
_translation.reset(token)
|
||||
|
||||
|
||||
def _(message: str) -> str:
|
||||
return _translation.get().gettext(message)
|
||||
@@ -1,7 +1,7 @@
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
@@ -11,70 +11,77 @@ from gallery.sketch.weather.model import WeatherResponse
|
||||
from gallery.version import __version__
|
||||
|
||||
from ..common.util import TagType, TagUtil
|
||||
from ..translation import _
|
||||
from .filters import cloudness_icon, wind_direction_icon
|
||||
|
||||
|
||||
def mount(app: FastAPI):
|
||||
base_dir = Path(__file__).parent
|
||||
app.mount("/static/weather", StaticFiles(directory=base_dir / "static"))
|
||||
templates = Jinja2Templates(
|
||||
directory=[
|
||||
base_dir.parent / "common/templates",
|
||||
base_dir / "templates",
|
||||
]
|
||||
base_dir = Path(__file__).parent
|
||||
templates = Jinja2Templates(
|
||||
directory=[
|
||||
base_dir.parent / "common/templates",
|
||||
base_dir / "templates",
|
||||
]
|
||||
)
|
||||
templates.env.globals.update({"_": _})
|
||||
templates.env.filters["wind_direction_icon"] = wind_direction_icon
|
||||
templates.env.filters["cloudness_icon"] = cloudness_icon
|
||||
|
||||
|
||||
def build_weather_response(request: AppRequest, response: WeatherResponse):
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="weather.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"tag_util": TagUtil,
|
||||
"datetime": datetime,
|
||||
"response": response,
|
||||
},
|
||||
)
|
||||
templates.env.filters["wind_direction_icon"] = wind_direction_icon
|
||||
templates.env.filters["cloudness_icon"] = cloudness_icon
|
||||
|
||||
def build_weather_response(request: AppRequest, response: WeatherResponse):
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="weather.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"tag_util": TagUtil,
|
||||
"datetime": datetime,
|
||||
"response": response,
|
||||
},
|
||||
)
|
||||
router = APIRouter()
|
||||
|
||||
@app.get("/weather", response_class=HTMLResponse)
|
||||
async def get_weather_index(request: AppRequest, query: str | None = None):
|
||||
weather_api = request.app.state.api.weather
|
||||
locations = (await weather_api.find_locations(query)) if query else []
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="index.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"locations": locations,
|
||||
},
|
||||
)
|
||||
@router.get("/weather", response_class=HTMLResponse)
|
||||
async def get_weather_index(request: AppRequest, query: str | None = None):
|
||||
weather_api = request.app.state.api.weather
|
||||
locations = (await weather_api.find_locations(query)) if query else []
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="index.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"locations": locations,
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/weather/{location}", response_class=RedirectResponse)
|
||||
async def get_weather_default(location: str):
|
||||
return RedirectResponse(f"{location}/tag/today")
|
||||
|
||||
@app.get("/weather/{location}/day/{date}", response_class=HTMLResponse)
|
||||
async def get_weather_day(request: AppRequest, location: str, date: datetime.date):
|
||||
weather_api = request.app.state.api.weather
|
||||
response = await weather_api.get_day(location, date)
|
||||
return build_weather_response(request, response)
|
||||
@router.get("/weather/{location}", response_class=RedirectResponse)
|
||||
async def get_weather_default(location: str):
|
||||
return RedirectResponse(f"{location}/tag/today")
|
||||
|
||||
@app.get("/weather/{location}/days/{days}", response_class=HTMLResponse)
|
||||
async def get_weather_days(request: AppRequest, location: str, days: int):
|
||||
weather_api = request.app.state.api.weather
|
||||
response = await weather_api.get_days(location, days)
|
||||
return build_weather_response(request, response)
|
||||
|
||||
@app.get("/weather/{location}/tag/{tag}", response_class=HTMLResponse)
|
||||
async def get_weather_tag(request: AppRequest, location: str, tag: str):
|
||||
tag_value = TagUtil.parse_tag(tag)
|
||||
weather_api = request.app.state.api.weather
|
||||
if tag_value.type == TagType.DAY:
|
||||
response = await weather_api.get_day(location, tag_value.date)
|
||||
elif tag_value.type == TagType.DAYS:
|
||||
response = await weather_api.get_days(location, tag_value.days)
|
||||
else:
|
||||
raise ValueError(tag)
|
||||
return build_weather_response(request, response)
|
||||
@router.get("/weather/{location}/day/{date}", response_class=HTMLResponse)
|
||||
async def get_weather_day(request: AppRequest, location: str, date: datetime.date):
|
||||
weather_api = request.app.state.api.weather
|
||||
response = await weather_api.get_day(location, date)
|
||||
return build_weather_response(request, response)
|
||||
|
||||
|
||||
@router.get("/weather/{location}/days/{days}", response_class=HTMLResponse)
|
||||
async def get_weather_days(request: AppRequest, location: str, days: int):
|
||||
weather_api = request.app.state.api.weather
|
||||
response = await weather_api.get_days(location, days)
|
||||
return build_weather_response(request, response)
|
||||
|
||||
|
||||
@router.get("/weather/{location}/tag/{tag}", response_class=HTMLResponse)
|
||||
async def get_weather_tag(request: AppRequest, location: str, tag: str):
|
||||
tag_value = TagUtil.parse_tag(tag)
|
||||
weather_api = request.app.state.api.weather
|
||||
if tag_value.type == TagType.DAY:
|
||||
response = await weather_api.get_day(location, tag_value.date)
|
||||
elif tag_value.type == TagType.DAYS:
|
||||
response = await weather_api.get_days(location, tag_value.days)
|
||||
else:
|
||||
raise ValueError(tag)
|
||||
return build_weather_response(request, response)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,71 +0,0 @@
|
||||
.header {
|
||||
font-size: 0.9rem;
|
||||
text-align: left;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.date {
|
||||
background: rgba(1, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.date.now {
|
||||
background: rgba(0, 128, 255, 0.2) !important;
|
||||
}
|
||||
|
||||
.date .value a {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cloudness {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.cloudness .icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.cloudness .icon:first-child {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.temperature {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.temperature .value {
|
||||
padding: 0.1rem 0.4rem;
|
||||
}
|
||||
|
||||
.temperature .value.positive {
|
||||
color: orangered;
|
||||
}
|
||||
|
||||
.temperature .value.negative {
|
||||
color: blue;
|
||||
}
|
||||
|
||||
.wind .direction {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.wind .gust {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.precipitation .value {
|
||||
color: blue;
|
||||
}
|
||||
|
||||
.pressure {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.pressure .value {
|
||||
padding: 0.1rem 0.4rem;
|
||||
color: blueviolet;
|
||||
}
|
||||
|
||||
.humidity .value {
|
||||
color: blue;
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.5 KiB |
@@ -1,13 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Weather{% endblock %}
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet"
|
||||
href="/static/weather/style.css?v={{version}}">
|
||||
<link rel="icon"
|
||||
href="/static/weather/favicon.ico?v={{version}}"
|
||||
type="image/x-icon">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Weather</h1>
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Погода | {{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}{% endblock %}
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet"
|
||||
href="/static/weather/style.css?v={{version}}">
|
||||
<link rel="icon"
|
||||
href="/static/weather/favicon.ico?v={{version}}"
|
||||
type="image/x-icon">
|
||||
{% block title %}{{_("Weather")}} | {{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
<span class="fs-4 text-body ms-2 me-2">/</span>
|
||||
<app-link href="/weather" icon="brightness-high">{{_("Weather")}}</app-link>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@@ -25,7 +22,8 @@
|
||||
{% endif %}
|
||||
</h4>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-borderless table-compact text-center w-auto" style="font-size: 130%;">
|
||||
<table class="table table-weather table-borderless table-compact text-center w-auto"
|
||||
style="font-size: 130%;">
|
||||
<tbody>
|
||||
<!-- date -->
|
||||
<tr>
|
||||
|
||||
6
scripts/locales
Executable file
6
scripts/locales
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
cd "$(dirname $(dirname "$0"))" || exit
|
||||
|
||||
cd gallery/easel/route/view/locales/ru/LC_MESSAGES || exit
|
||||
msgfmt messages.po
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "gallery-static",
|
||||
"name": "gallery",
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
|
||||
17
static/src/components.ts
Normal file
17
static/src/components.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
class AppLinkElement extends HTMLElement {
|
||||
static observedAttributes = ["icon", "href"];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.innerHTML = `
|
||||
<a href="${this.getAttribute("href")}"
|
||||
class="d-flex align-items-center text-body text-decoration-none">
|
||||
<span class="fs-4">
|
||||
<i class="bi bi-${this.getAttribute("icon")}"></i>
|
||||
<span>${this.textContent}</span>
|
||||
</span>
|
||||
</a>`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("app-link", AppLinkElement);
|
||||
@@ -1,3 +1,5 @@
|
||||
import "./main.scss";
|
||||
import "bootstrap";
|
||||
import "./theme";
|
||||
import "./language";
|
||||
import "./components";
|
||||
|
||||
62
static/src/language.ts
Normal file
62
static/src/language.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
(() => {
|
||||
const getStoredLanguage = () => {
|
||||
const m = document.cookie.match(/language=(\w+)/);
|
||||
return m ? m[1] : null;
|
||||
};
|
||||
const setStoredLanguage = (language: string) => (document.cookie = `language=${language}; max-age=34560000; path=/`);
|
||||
|
||||
const getPreferredLanguage = () => {
|
||||
const storedLanguage = getStoredLanguage();
|
||||
if (storedLanguage) {
|
||||
return storedLanguage;
|
||||
}
|
||||
const result = window.navigator.language.split("-")[0];
|
||||
return ["en", "ru"].includes(result) ? result : "en";
|
||||
};
|
||||
|
||||
const setLanguage = (language: string) => {};
|
||||
|
||||
setLanguage(getPreferredLanguage());
|
||||
|
||||
const showActiveLanguage = (language: string, focus = false) => {
|
||||
const languageSwitcher = document.querySelector("#bd-language");
|
||||
|
||||
if (!languageSwitcher) {
|
||||
return;
|
||||
}
|
||||
|
||||
const languageSwitcherText = document.querySelector("#bd-language-text");
|
||||
const activeLanguageIcon = document.querySelector(".language-icon-active");
|
||||
const btnToActive = document.querySelector(`[data-bs-language-value="${language}"]`);
|
||||
const activeLanguageIconContent = btnToActive?.querySelector("span")?.textContent;
|
||||
|
||||
document.querySelectorAll("[data-bs-language-value]").forEach((element) => {
|
||||
element.classList.remove("active");
|
||||
element.setAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
btnToActive.classList.add("active");
|
||||
btnToActive.setAttribute("aria-pressed", "true");
|
||||
activeLanguageIcon.textContent = activeLanguageIconContent;
|
||||
const languageSwitcherLabel = `${languageSwitcherText.textContent} (${btnToActive.dataset.bsLanguageValue})`;
|
||||
languageSwitcher.setAttribute("aria-label", languageSwitcherLabel);
|
||||
|
||||
if (focus) {
|
||||
languageSwitcher.focus();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
showActiveLanguage(getPreferredLanguage());
|
||||
|
||||
document.querySelectorAll("[data-bs-language-value]").forEach((toggle) => {
|
||||
toggle.addEventListener("click", () => {
|
||||
const language = toggle.getAttribute("data-bs-language-value") || "";
|
||||
setStoredLanguage(language);
|
||||
setLanguage(language);
|
||||
showActiveLanguage(language, true);
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -2,8 +2,18 @@
|
||||
$bootstrap-icons-font-dir: "bootstrap-icons/font/fonts";
|
||||
@import "bootstrap-icons/font/bootstrap-icons";
|
||||
|
||||
@import "./widget.scss";
|
||||
@import "./weather.scss";
|
||||
|
||||
.table.table-compact {
|
||||
td {
|
||||
padding: 0.1rem 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-block;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
/*!
|
||||
* Color mode toggler for Bootstrap's docs (https://getbootstrap.com/)
|
||||
* Copyright 2011-2025 The Bootstrap Authors
|
||||
* Licensed under the Creative Commons Attribution 3.0 Unported License.
|
||||
*/
|
||||
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const getStoredTheme = () => localStorage.getItem("theme");
|
||||
const setStoredTheme = (theme) => localStorage.setItem("theme", theme);
|
||||
const setStoredTheme = (theme: string) => localStorage.setItem("theme", theme);
|
||||
|
||||
const getPreferredTheme = () => {
|
||||
const storedTheme = getStoredTheme();
|
||||
@@ -19,7 +11,7 @@
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
};
|
||||
|
||||
const setTheme = (theme) => {
|
||||
const setTheme = (theme: string) => {
|
||||
if (theme === "auto") {
|
||||
document.documentElement.setAttribute(
|
||||
"data-bs-theme",
|
||||
@@ -32,7 +24,7 @@
|
||||
|
||||
setTheme(getPreferredTheme());
|
||||
|
||||
const showActiveTheme = (theme, focus = false) => {
|
||||
const showActiveTheme = (theme: string, focus = false) => {
|
||||
const themeSwitcher = document.querySelector("#bd-theme");
|
||||
|
||||
if (!themeSwitcher) {
|
||||
@@ -74,7 +66,7 @@
|
||||
|
||||
document.querySelectorAll("[data-bs-theme-value]").forEach((toggle) => {
|
||||
toggle.addEventListener("click", () => {
|
||||
const theme = toggle.getAttribute("data-bs-theme-value");
|
||||
const theme = toggle.getAttribute("data-bs-theme-value") || '';
|
||||
setStoredTheme(theme);
|
||||
setTheme(theme);
|
||||
showActiveTheme(theme, true);
|
||||
73
static/src/weather.scss
Normal file
73
static/src/weather.scss
Normal file
@@ -0,0 +1,73 @@
|
||||
.table-weather {
|
||||
.header {
|
||||
font-size: 0.9rem;
|
||||
text-align: left;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.date {
|
||||
background: rgba(1, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.date.now {
|
||||
background: rgba(0, 128, 255, 0.2) !important;
|
||||
}
|
||||
|
||||
.date .value a {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cloudness {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.cloudness .icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.cloudness .icon:first-child {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.temperature {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.temperature .value {
|
||||
padding: 0.1rem 0.4rem;
|
||||
}
|
||||
|
||||
.temperature .value.positive {
|
||||
color: orangered;
|
||||
}
|
||||
|
||||
.temperature .value.negative {
|
||||
color: blue;
|
||||
}
|
||||
|
||||
.wind .direction {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.wind .gust {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.precipitation .value {
|
||||
color: blue;
|
||||
}
|
||||
|
||||
.pressure {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.pressure .value {
|
||||
padding: 0.1rem 0.4rem;
|
||||
color: blueviolet;
|
||||
}
|
||||
|
||||
.humidity .value {
|
||||
color: blue;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,3 @@
|
||||
.icon {
|
||||
display: inline-block;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
.widget .app {
|
||||
padding: 0.5rem !important;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export default defineConfig({
|
||||
lib: {
|
||||
entry: "./src/index.ts",
|
||||
name: packageJson.name,
|
||||
fileName: (format) => `${packageJson.name}.js`,
|
||||
fileName: (format) => `${packageJson.name}.${format}.js`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user