feat(easel): add localization

This commit is contained in:
2026-04-23 15:47:16 +03:00
parent ecb574e286
commit 9351b9f53a
34 changed files with 464 additions and 311 deletions

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
*.pyc *.pyc
*.mo
.pytest_cache .pytest_cache
.venv .venv
#.vscode #.vscode

View File

@@ -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"]

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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) {

View File

@@ -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 %}

View 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 "Телепрограмма"

View File

@@ -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

View File

@@ -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 %}

View File

@@ -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">

View File

@@ -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 %}

View 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)

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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
View 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

View File

@@ -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
View 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);

View File

@@ -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
View 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();
});
});
});
})();

View File

@@ -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;
}

View File

@@ -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
View 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;
}
}

View File

@@ -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;
} }

View File

@@ -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`,
}, },
}, },
}); });