feat(easel): add localization
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
*.pyc
|
*.pyc
|
||||||
|
*.mo
|
||||||
.pytest_cache
|
.pytest_cache
|
||||||
.venv
|
.venv
|
||||||
#.vscode
|
#.vscode
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ FROM python:3.12-slim
|
|||||||
ENV PATH="/app/.venv/bin:$PATH"
|
ENV PATH="/app/.venv/bin:$PATH"
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apt update && \
|
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 && \
|
sed -i -e 's/# ru_RU.UTF-8 UTF-8/ru_RU.UTF-8 UTF-8/' /etc/locale.gen && \
|
||||||
dpkg-reconfigure --frontend=noninteractive locales
|
dpkg-reconfigure --frontend=noninteractive locales
|
||||||
ENV LANG=ru_RU.UTF-8
|
ENV LANG=ru_RU.UTF-8
|
||||||
@@ -28,5 +28,6 @@ ENV TZ="Europe/Moscow"
|
|||||||
COPY --from=builder /app ./
|
COPY --from=builder /app ./
|
||||||
COPY --from=node-builder /app/dist ./static/dist
|
COPY --from=node-builder /app/dist ./static/dist
|
||||||
COPY gallery gallery/
|
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"]
|
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
|
import locale as _locale
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from gallery.sketch.bundle import ApiBundle
|
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"
|
DEFAULT_LOCALE = "ru_RU.UTF-8"
|
||||||
|
|
||||||
@@ -17,7 +20,8 @@ def build_app(api_bundle: ApiBundle, *, locale: str = DEFAULT_LOCALE) -> FastAPI
|
|||||||
redoc_url=None,
|
redoc_url=None,
|
||||||
)
|
)
|
||||||
app.state.api = api_bundle
|
app.state.api = api_bundle
|
||||||
|
app.mount("/static", StaticFiles(directory=root_path / "static/dist"))
|
||||||
doc.mount(app)
|
doc.mount(app)
|
||||||
api.mount(app)
|
api.mount(app)
|
||||||
view.mount(app)
|
app.include_router(view_router)
|
||||||
return app
|
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
|
||||||
|
|
||||||
|
router = APIRouter(dependencies=[Depends(set_language)])
|
||||||
def mount(app: FastAPI):
|
router.include_router(common_router)
|
||||||
common.mount(app)
|
router.include_router(weather_router)
|
||||||
weather.mount(app)
|
router.include_router(schedule_router)
|
||||||
schedule.mount(app)
|
|
||||||
|
|||||||
@@ -1,35 +1,36 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from gallery.util import root_path
|
|
||||||
from gallery.version import __version__
|
from gallery.version import __version__
|
||||||
|
|
||||||
|
from ..translation import _
|
||||||
|
|
||||||
|
|
||||||
class Section(NamedTuple):
|
class Section(NamedTuple):
|
||||||
link: str
|
link: str
|
||||||
title: str
|
title: str
|
||||||
|
icon: str
|
||||||
|
|
||||||
|
|
||||||
SECTIONS = [
|
SECTIONS = [
|
||||||
Section("weather", "Weather"),
|
Section("weather", "Weather", "brightness-high"),
|
||||||
Section("schedule", "TV program"),
|
Section("schedule", "TV program", "tv"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
base_dir = Path(__file__).parent
|
||||||
|
|
||||||
def mount(app: FastAPI):
|
router = APIRouter()
|
||||||
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")
|
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
templates = Jinja2Templates(directory=base_dir / "templates")
|
||||||
async def get_section_list(request: Request):
|
templates.env.globals.update({"_": _})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
async def get_section_list(request: Request):
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
name="root_index.html",
|
name="root_index.html",
|
||||||
|
|||||||
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>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="{{request.state.language}}">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
{% block head %}
|
{% block head %}
|
||||||
@@ -10,13 +10,11 @@
|
|||||||
content="ie=edge">
|
content="ie=edge">
|
||||||
<title>{% block title %}{% endblock %}</title>
|
<title>{% block title %}{% endblock %}</title>
|
||||||
<link rel="stylesheet"
|
<link rel="stylesheet"
|
||||||
href="/static/main/gallery-static.css?v={{version}}">
|
href="/static/gallery.css?v={{version}}">
|
||||||
<script type="module"
|
<script type="module"
|
||||||
src="/static/main/gallery-static.js?v={{version}}"></script>
|
src="/static/gallery.es.js?v={{version}}"></script>
|
||||||
<link rel="stylesheet"
|
|
||||||
href="/static/common/style.css?v={{version}}">
|
|
||||||
<link rel="icon"
|
<link rel="icon"
|
||||||
href="/static/common/favicon.ico?v={{version}}"
|
href="/favicon.ico?v={{version}}"
|
||||||
type="image/x-icon">
|
type="image/x-icon">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
@@ -24,13 +22,43 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="app col-lg-8 mx-auto p-3 py-md-5">
|
<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">
|
<header class="d-flex align-items-center pb-3 mb-5 border-bottom">
|
||||||
<a href="/"
|
<app-link href="/"
|
||||||
class="d-flex align-items-center text-dark text-decoration-none">
|
icon="gear">API Gallery</app-link>
|
||||||
<div class="icon me-2"
|
{% block header %}{% endblock %}
|
||||||
style="background-image: url(/static/common/gallery.png);"></div>
|
|
||||||
<span class="fs-4 text-body">API Gallery</span>
|
|
||||||
</a>
|
|
||||||
<ul class="navbar-nav flex-row flex-wrap ms-md-auto">
|
<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">
|
<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"
|
<button class="btn btn-link nav-link py-2 px-0 px-lg-2 dropdown-toggle d-flex align-items-center"
|
||||||
id="bd-theme"
|
id="bd-theme"
|
||||||
@@ -84,13 +112,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(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 params = new URLSearchParams(window.location.search);
|
||||||
const widget = params.get('widget') || window.location.hostname.startsWith('weather');
|
const widget = params.get('widget') || window.location.hostname.startsWith('weather');
|
||||||
if (widget) {
|
if (widget) {
|
||||||
|
|||||||
@@ -10,15 +10,17 @@
|
|||||||
{% for section in sections %}
|
{% for section in sections %}
|
||||||
<a href="{{section.link}}"
|
<a href="{{section.link}}"
|
||||||
class="list-group-item list-group-item-action px-4">
|
class="list-group-item list-group-item-action px-4">
|
||||||
<div class="d-flex w-100">
|
<app-link href="{{section.link}}"
|
||||||
<span class="icon me-2"
|
icon="{{section.icon}}">
|
||||||
style="background-image: url(/static/{{section.link}}/{{section.link}}.png);"></span>
|
{{_(section.title)}}
|
||||||
<h4>{{section.title}}</h4>
|
</app-link>
|
||||||
</div>
|
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<hr class="col-3 col-md-2 mb-5">
|
<hr class="col-3 col-md-2 mb-5">
|
||||||
<h1>Docs</h1>
|
<h1>Docs</h1>
|
||||||
<a href="/docs" target="_blank"><h4>Swagger</h4></a>
|
<a href="/docs"
|
||||||
|
target="_blank">
|
||||||
|
<h4>Swagger</h4>
|
||||||
|
</a>
|
||||||
{% endblock %}
|
{% 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
|
import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import APIRouter
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from gallery.easel.core import AppRequest
|
from gallery.easel.core import AppRequest
|
||||||
@@ -12,22 +10,24 @@ from gallery.sketch.schedule.catalog import BUNDLE
|
|||||||
from gallery.version import __version__
|
from gallery.version import __version__
|
||||||
|
|
||||||
from ..common.util import TagType, TagUtil
|
from ..common.util import TagType, TagUtil
|
||||||
|
from ..translation import _
|
||||||
from .filters import timedelta_format
|
from .filters import timedelta_format
|
||||||
|
|
||||||
|
base_dir = Path(__file__).parent
|
||||||
def mount(app: FastAPI):
|
templates = Jinja2Templates(
|
||||||
base_dir = Path(__file__).parent
|
|
||||||
app.mount("/static/schedule", StaticFiles(directory=base_dir / "static"))
|
|
||||||
templates = Jinja2Templates(
|
|
||||||
directory=[
|
directory=[
|
||||||
base_dir.parent / "common/templates",
|
base_dir.parent / "common/templates",
|
||||||
base_dir / "templates",
|
base_dir / "templates",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
templates.env.filters["timedelta_format"] = timedelta_format
|
templates.env.globals.update({"_": _})
|
||||||
|
templates.env.filters["timedelta_format"] = timedelta_format
|
||||||
|
|
||||||
@app.get("/schedule", response_class=HTMLResponse)
|
router = APIRouter()
|
||||||
async def get_schedule_list(request: AppRequest):
|
|
||||||
|
|
||||||
|
@router.get("/schedule", response_class=HTMLResponse)
|
||||||
|
async def get_schedule_list(request: AppRequest):
|
||||||
schedule_api = request.app.state.api.schedule
|
schedule_api = request.app.state.api.schedule
|
||||||
channels = await schedule_api.get_channels()
|
channels = await schedule_api.get_channels()
|
||||||
channels_data = BUNDLE.select_items(channels)
|
channels_data = BUNDLE.select_items(channels)
|
||||||
@@ -40,8 +40,9 @@ def mount(app: FastAPI):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/schedule/tag/{tag}", response_class=HTMLResponse)
|
|
||||||
async def get_schedule_tag(request: AppRequest, tag: str, live: bool = False):
|
@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)
|
tag_value = TagUtil.parse_tag(tag)
|
||||||
schedule_api = request.app.state.api.schedule
|
schedule_api = request.app.state.api.schedule
|
||||||
results = await schedule_api.get_all_schedules(tag_value.date)
|
results = await schedule_api.get_all_schedules(tag_value.date)
|
||||||
@@ -58,12 +59,14 @@ def mount(app: FastAPI):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/schedule/{channel}", response_class=RedirectResponse)
|
|
||||||
async def get_channel_default(channel: str):
|
@router.get("/schedule/{channel}", response_class=RedirectResponse)
|
||||||
|
async def get_channel_default(channel: str):
|
||||||
return RedirectResponse(f"{channel}/tag/today")
|
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):
|
@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)
|
tag_value = TagUtil.parse_tag(tag)
|
||||||
schedule_api = request.app.state.api.schedule
|
schedule_api = request.app.state.api.schedule
|
||||||
if tag_value.type == TagType.DAY:
|
if tag_value.type == TagType.DAY:
|
||||||
|
|||||||
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 %}
|
{% block title %}
|
||||||
Программа | {{response.channel.name}} | {{response.date.strftime('%a, %d %B %Y')}}
|
Программа | {{response.channel.name}} | {{response.date.strftime('%a, %d %B %Y')}}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block head %}
|
|
||||||
{{ super() }}
|
{% block header %}
|
||||||
<link rel="stylesheet"
|
<span class="fs-4 text-body ms-2 me-2">/</span>
|
||||||
href="/static/schedule/style.css?v={{version}}">
|
<app-link href="/schedule"
|
||||||
<link rel="icon"
|
icon="tv">{{_("TV program")}}</app-link>
|
||||||
href="/static/schedule/favicon.ico?v={{version}}"
|
|
||||||
type="image/x-icon">
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}TV program{% endblock %}
|
{% 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 content %}
|
{% block content %}
|
||||||
<h1>TV program</h1>
|
<h1>{{_("TV program")}}</h1>
|
||||||
<div class="list-group mb-5">
|
<div class="list-group mb-5">
|
||||||
<a href="schedule/tag/today"
|
<a href="schedule/tag/today"
|
||||||
class="list-group-item list-group-item-action px-4">
|
class="list-group-item list-group-item-action px-4">
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}
|
{% 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 %}
|
{% endblock %}
|
||||||
{% block head %}
|
|
||||||
{{ super() }}
|
{% block header %}
|
||||||
<link rel="stylesheet"
|
<span class="fs-4 text-body ms-2 me-2">/</span>
|
||||||
href="/static/schedule/style.css?v={{version}}">
|
<app-link href="/schedule"
|
||||||
<link rel="icon"
|
icon="tv">{{_("TV program")}}</app-link>
|
||||||
href="/static/schedule/favicon.ico?v={{version}}"
|
|
||||||
type="image/x-icon">
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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
|
import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import APIRouter, FastAPI
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
@@ -11,22 +11,23 @@ from gallery.sketch.weather.model import WeatherResponse
|
|||||||
from gallery.version import __version__
|
from gallery.version import __version__
|
||||||
|
|
||||||
from ..common.util import TagType, TagUtil
|
from ..common.util import TagType, TagUtil
|
||||||
|
from ..translation import _
|
||||||
from .filters import cloudness_icon, wind_direction_icon
|
from .filters import cloudness_icon, wind_direction_icon
|
||||||
|
|
||||||
|
|
||||||
def mount(app: FastAPI):
|
base_dir = Path(__file__).parent
|
||||||
base_dir = Path(__file__).parent
|
templates = Jinja2Templates(
|
||||||
app.mount("/static/weather", StaticFiles(directory=base_dir / "static"))
|
|
||||||
templates = Jinja2Templates(
|
|
||||||
directory=[
|
directory=[
|
||||||
base_dir.parent / "common/templates",
|
base_dir.parent / "common/templates",
|
||||||
base_dir / "templates",
|
base_dir / "templates",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
templates.env.filters["wind_direction_icon"] = wind_direction_icon
|
templates.env.globals.update({"_": _})
|
||||||
templates.env.filters["cloudness_icon"] = cloudness_icon
|
templates.env.filters["wind_direction_icon"] = wind_direction_icon
|
||||||
|
templates.env.filters["cloudness_icon"] = cloudness_icon
|
||||||
|
|
||||||
def build_weather_response(request: AppRequest, response: WeatherResponse):
|
|
||||||
|
def build_weather_response(request: AppRequest, response: WeatherResponse):
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
name="weather.html",
|
name="weather.html",
|
||||||
@@ -38,8 +39,10 @@ def mount(app: FastAPI):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/weather", response_class=HTMLResponse)
|
router = APIRouter()
|
||||||
async def get_weather_index(request: AppRequest, query: str | None = None):
|
|
||||||
|
@router.get("/weather", response_class=HTMLResponse)
|
||||||
|
async def get_weather_index(request: AppRequest, query: str | None = None):
|
||||||
weather_api = request.app.state.api.weather
|
weather_api = request.app.state.api.weather
|
||||||
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(
|
||||||
@@ -51,24 +54,28 @@ def mount(app: FastAPI):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/weather/{location}", response_class=RedirectResponse)
|
|
||||||
async def get_weather_default(location: str):
|
@router.get("/weather/{location}", response_class=RedirectResponse)
|
||||||
|
async def get_weather_default(location: str):
|
||||||
return RedirectResponse(f"{location}/tag/today")
|
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):
|
@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
|
weather_api = request.app.state.api.weather
|
||||||
response = await weather_api.get_day(location, date)
|
response = await weather_api.get_day(location, date)
|
||||||
return build_weather_response(request, response)
|
return build_weather_response(request, response)
|
||||||
|
|
||||||
@app.get("/weather/{location}/days/{days}", response_class=HTMLResponse)
|
|
||||||
async def get_weather_days(request: AppRequest, location: str, days: int):
|
@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
|
weather_api = request.app.state.api.weather
|
||||||
response = await weather_api.get_days(location, days)
|
response = await weather_api.get_days(location, days)
|
||||||
return build_weather_response(request, response)
|
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):
|
@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)
|
tag_value = TagUtil.parse_tag(tag)
|
||||||
weather_api = request.app.state.api.weather
|
weather_api = request.app.state.api.weather
|
||||||
if tag_value.type == TagType.DAY:
|
if tag_value.type == TagType.DAY:
|
||||||
|
|||||||
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" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Weather{% endblock %}
|
{% 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 %}
|
{% block content %}
|
||||||
<h1>Weather</h1>
|
<h1>Weather</h1>
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Погода | {{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}{% endblock %}
|
{% block title %}{{_("Weather")}} | {{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}{% endblock %}
|
||||||
{% block head %}
|
|
||||||
{{ super() }}
|
{% block header %}
|
||||||
<link rel="stylesheet"
|
<span class="fs-4 text-body ms-2 me-2">/</span>
|
||||||
href="/static/weather/style.css?v={{version}}">
|
<app-link href="/weather" icon="brightness-high">{{_("Weather")}}</app-link>
|
||||||
<link rel="icon"
|
|
||||||
href="/static/weather/favicon.ico?v={{version}}"
|
|
||||||
type="image/x-icon">
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@@ -25,7 +22,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</h4>
|
</h4>
|
||||||
<div class="table-responsive">
|
<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>
|
<tbody>
|
||||||
<!-- date -->
|
<!-- date -->
|
||||||
<tr>
|
<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",
|
"version": "0.1.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"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 "./main.scss";
|
||||||
import "bootstrap";
|
import "bootstrap";
|
||||||
import "./theme";
|
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";
|
$bootstrap-icons-font-dir: "bootstrap-icons/font/fonts";
|
||||||
@import "bootstrap-icons/font/bootstrap-icons";
|
@import "bootstrap-icons/font/bootstrap-icons";
|
||||||
|
|
||||||
|
@import "./widget.scss";
|
||||||
|
@import "./weather.scss";
|
||||||
|
|
||||||
.table.table-compact {
|
.table.table-compact {
|
||||||
td {
|
td {
|
||||||
padding: 0.1rem 0.4rem;
|
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 getStoredTheme = () => localStorage.getItem("theme");
|
||||||
const setStoredTheme = (theme) => localStorage.setItem("theme", theme);
|
const setStoredTheme = (theme: string) => localStorage.setItem("theme", theme);
|
||||||
|
|
||||||
const getPreferredTheme = () => {
|
const getPreferredTheme = () => {
|
||||||
const storedTheme = getStoredTheme();
|
const storedTheme = getStoredTheme();
|
||||||
@@ -19,7 +11,7 @@
|
|||||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||||
};
|
};
|
||||||
|
|
||||||
const setTheme = (theme) => {
|
const setTheme = (theme: string) => {
|
||||||
if (theme === "auto") {
|
if (theme === "auto") {
|
||||||
document.documentElement.setAttribute(
|
document.documentElement.setAttribute(
|
||||||
"data-bs-theme",
|
"data-bs-theme",
|
||||||
@@ -32,7 +24,7 @@
|
|||||||
|
|
||||||
setTheme(getPreferredTheme());
|
setTheme(getPreferredTheme());
|
||||||
|
|
||||||
const showActiveTheme = (theme, focus = false) => {
|
const showActiveTheme = (theme: string, focus = false) => {
|
||||||
const themeSwitcher = document.querySelector("#bd-theme");
|
const themeSwitcher = document.querySelector("#bd-theme");
|
||||||
|
|
||||||
if (!themeSwitcher) {
|
if (!themeSwitcher) {
|
||||||
@@ -74,7 +66,7 @@
|
|||||||
|
|
||||||
document.querySelectorAll("[data-bs-theme-value]").forEach((toggle) => {
|
document.querySelectorAll("[data-bs-theme-value]").forEach((toggle) => {
|
||||||
toggle.addEventListener("click", () => {
|
toggle.addEventListener("click", () => {
|
||||||
const theme = toggle.getAttribute("data-bs-theme-value");
|
const theme = toggle.getAttribute("data-bs-theme-value") || '';
|
||||||
setStoredTheme(theme);
|
setStoredTheme(theme);
|
||||||
setTheme(theme);
|
setTheme(theme);
|
||||||
showActiveTheme(theme, true);
|
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 {
|
.widget .app {
|
||||||
padding: 0.5rem !important;
|
padding: 0.5rem !important;
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,7 @@ export default defineConfig({
|
|||||||
lib: {
|
lib: {
|
||||||
entry: "./src/index.ts",
|
entry: "./src/index.ts",
|
||||||
name: packageJson.name,
|
name: packageJson.name,
|
||||||
fileName: (format) => `${packageJson.name}.js`,
|
fileName: (format) => `${packageJson.name}.${format}.js`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user