feat(i18n): add browser-language localization with Jinja2 _() and locale middleware
Some checks failed
ci/woodpecker/pr/pipeline Pipeline failed

Add i18n support to the blog web UI with 4 languages (en/ru/fr/de),
80 translation keys, automatic Accept-Language detection, persistent
locale cookie, and a language switcher dropdown in the header.

- Infrastructure: TranslationService, translation dicts, convenience _()
- Presentation: locale middleware, /web/lang/{locale} switcher route
- Templates: all 9 templates use {{ _(key, current_locale) }}
- Tests: 26 tests across TranslationService, locale detection helpers
- Docs: TEST_MODEL.md and FEATURE_INFRASTRUCTURE.md updated with TC-UNIT-811-821
This commit is contained in:
2026-05-10 16:22:06 +03:00
parent 4e6505c598
commit d32ad29abc
18 changed files with 1077 additions and 100 deletions

View File

@@ -0,0 +1,9 @@
"""Internationalization support for the blog application.
This package provides translation dictionaries and the translation service
for localizing the web UI into multiple languages.
"""
from app.infrastructure.i18n.translator import SUPPORTED_LOCALES, TranslationService, _
__all__ = ["SUPPORTED_LOCALES", "TranslationService", "_"]

View File

@@ -0,0 +1,337 @@
"""Translation dictionaries for i18n support.
This module provides translation dictionaries for all supported locales.
Translations are organized by feature area for maintainability.
Keys use dot-separated namespacing to avoid collisions.
"""
TRANSLATIONS: dict[str, dict[str, str]] = {
"en": {
"nav.home": "Home",
"nav.posts": "Posts",
"nav.about": "About",
"header.logo": "Blog",
"header.profile": "Profile",
"header.new_post": "New Post",
"header.sign_out": "Sign Out",
"header.sign_in": "Sign In",
"header.toggle_menu": "Toggle menu",
"header.toggle_theme": "Toggle dark mode",
"header.lang_switcher": "Language",
"footer.copyright": "© 2026 Blog. All rights reserved.",
"footer.about": "About",
"footer.privacy": "Privacy",
"footer.terms": "Terms",
"footer.api": "API",
"home.title": "Blog - Home",
"home.meta_description": "Discover stories, thinking, and expertise from writers on any topic. A modern blog built with FastAPI.",
"home.meta_keywords": "blog, articles, posts, writing, fastapi, python",
"home.page_title": "Latest Posts",
"home.page_subtitle": "Discover stories, thinking, and expertise from writers on any topic.",
"home.write_post": "Write a Post",
"home.status_published": "Published",
"home.status_draft": "Draft",
"home.read_more": "Read more",
"home.pagination_previous": "Previous",
"home.pagination_next": "Next",
"home.empty_title": "No posts yet",
"home.empty_description": "Be the first to write a post!",
"home.empty_action": "Create your first post",
"post.status_published": "Published",
"post.status_draft": "Draft",
"post.back_to_posts": "Back to posts",
"post.edit": "Edit",
"post.delete": "Delete",
"post.delete_confirm": "Are you sure you want to delete this post?",
"post_form.title_edit": "Edit Post",
"post_form.title_new": "New Post",
"post_form.page_title_edit": "Edit Post",
"post_form.page_title_new": "Create New Post",
"post_form.label_title": "Title",
"post_form.placeholder_title": "Enter post title",
"post_form.hint_title": "A catchy title for your post",
"post_form.label_content": "Content",
"post_form.placeholder_content": "Write your post content here...",
"post_form.hint_content": "The main content of your post. Markdown is supported.",
"post_form.label_tags": "Tags",
"post_form.placeholder_tags": "python, fastapi, tutorial",
"post_form.hint_tags": "Comma-separated list of tags",
"post_form.cancel": "Cancel",
"post_form.save_draft": "Save as Draft",
"post_form.update_post": "Update Post",
"post_form.publish_post": "Publish Post",
"profile.title": "User Profile",
"profile.email": "Email:",
"profile.not_provided": "Not provided",
"profile.user_id": "User ID:",
"profile.name": "Name:",
"profile.back_home": "Back to Home",
"profile.new_post": "New Post",
"about.title": "About",
"about.page_title": "About",
"about.description": "A modern blog built with FastAPI and Domain-Driven Design architecture.",
"about.signed_in": "Signed in as {username}.",
"about.browsing_guest": "You are browsing as a guest.",
"about.back_home": "Back to Home",
"flash.post_published": "Post published successfully!",
"flash.post_saved_draft": "Post saved as draft!",
"flash.post_updated": "Post updated successfully!",
"flash.post_deleted": "Post deleted successfully!",
"flash.post_not_found": "Post not found.",
"base.close_message": "Close message",
"base.default_title": "Blog",
"base.meta_description": "Blog - A modern blogging platform built with FastAPI",
"base.meta_keywords": "blog, articles, posts, writing",
"base.meta_author": "Blog Team",
"lang.en": "English",
"lang.ru": "Русский",
"lang.fr": "Français",
"lang.de": "Deutsch",
},
"ru": {
"nav.home": "Главная",
"nav.posts": "Статьи",
"nav.about": "О нас",
"header.logo": "Блог",
"header.profile": "Профиль",
"header.new_post": "Новая статья",
"header.sign_out": "Выйти",
"header.sign_in": "Войти",
"header.toggle_menu": "Открыть меню",
"header.toggle_theme": "Сменить тему",
"header.lang_switcher": "Язык",
"footer.copyright": "© 2026 Блог. Все права защищены.",
"footer.about": "О нас",
"footer.privacy": "Конфиденциальность",
"footer.terms": "Условия",
"footer.api": "API",
"home.title": "Блог — Главная",
"home.meta_description": "Откройте для себя истории, мысли и опыт авторов на любую тему. Современный блог на FastAPI.",
"home.meta_keywords": "блог, статьи, посты, fastapi, python",
"home.page_title": "Последние статьи",
"home.page_subtitle": "Откройте для себя истории, мысли и опыт авторов на любую тему.",
"home.write_post": "Написать статью",
"home.status_published": "Опубликовано",
"home.status_draft": "Черновик",
"home.read_more": "Читать далее",
"home.pagination_previous": "Назад",
"home.pagination_next": "Вперёд",
"home.empty_title": "Статей пока нет",
"home.empty_description": "Будьте первым, кто напишет статью!",
"home.empty_action": "Создать первую статью",
"post.status_published": "Опубликовано",
"post.status_draft": "Черновик",
"post.back_to_posts": "К списку статей",
"post.edit": "Редактировать",
"post.delete": "Удалить",
"post.delete_confirm": "Вы уверены, что хотите удалить эту статью?",
"post_form.title_edit": "Редактировать статью",
"post_form.title_new": "Новая статья",
"post_form.page_title_edit": "Редактировать статью",
"post_form.page_title_new": "Создать новую статью",
"post_form.label_title": "Заголовок",
"post_form.placeholder_title": "Введите заголовок статьи",
"post_form.hint_title": "Запоминающийся заголовок для вашей статьи",
"post_form.label_content": "Содержание",
"post_form.placeholder_content": "Напишите содержание статьи здесь...",
"post_form.hint_content": "Основное содержание вашей статьи. Поддерживается Markdown.",
"post_form.label_tags": "Теги",
"post_form.placeholder_tags": "python, fastapi, tutorial",
"post_form.hint_tags": "Список тегов через запятую",
"post_form.cancel": "Отмена",
"post_form.save_draft": "Сохранить черновик",
"post_form.update_post": "Обновить статью",
"post_form.publish_post": "Опубликовать статью",
"profile.title": "Профиль пользователя",
"profile.email": "Email:",
"profile.not_provided": "Не указан",
"profile.user_id": "ID пользователя:",
"profile.name": "Имя:",
"profile.back_home": "На главную",
"profile.new_post": "Новая статья",
"about.title": "О нас",
"about.page_title": "О нас",
"about.description": "Современный блог на FastAPI с архитектурой Domain-Driven Design.",
"about.signed_in": "Вы вошли как {username}.",
"about.browsing_guest": "Вы просматриваете как гость.",
"about.back_home": "На главную",
"flash.post_published": "Статья успешно опубликована!",
"flash.post_saved_draft": "Статья сохранена как черновик!",
"flash.post_updated": "Статья успешно обновлена!",
"flash.post_deleted": "Статья успешно удалена!",
"flash.post_not_found": "Статья не найдена.",
"base.close_message": "Закрыть сообщение",
"base.default_title": "Блог",
"base.meta_description": "Блог — современная платформа для блогов на FastAPI",
"base.meta_keywords": "блог, статьи, посты, письмо",
"base.meta_author": "Команда блога",
"lang.en": "English",
"lang.ru": "Русский",
"lang.fr": "Français",
"lang.de": "Deutsch",
},
"fr": {
"nav.home": "Accueil",
"nav.posts": "Articles",
"nav.about": "À propos",
"header.logo": "Blog",
"header.profile": "Profil",
"header.new_post": "Nouvel article",
"header.sign_out": "Déconnexion",
"header.sign_in": "Connexion",
"header.toggle_menu": "Menu",
"header.toggle_theme": "Changer le thème",
"header.lang_switcher": "Langue",
"footer.copyright": "© 2026 Blog. Tous droits réservés.",
"footer.about": "À propos",
"footer.privacy": "Confidentialité",
"footer.terms": "Conditions",
"footer.api": "API",
"home.title": "Blog — Accueil",
"home.meta_description": "Découvrez des histoires, réflexions et expertises d'auteurs sur tous les sujets. Un blog moderne avec FastAPI.",
"home.meta_keywords": "blog, articles, posts, écriture, fastapi, python",
"home.page_title": "Derniers articles",
"home.page_subtitle": "Découvrez des histoires, réflexions et expertises d'auteurs sur tous les sujets.",
"home.write_post": "Écrire un article",
"home.status_published": "Publié",
"home.status_draft": "Brouillon",
"home.read_more": "Lire la suite",
"home.pagination_previous": "Précédent",
"home.pagination_next": "Suivant",
"home.empty_title": "Aucun article pour le moment",
"home.empty_description": "Soyez le premier à écrire un article !",
"home.empty_action": "Créer votre premier article",
"post.status_published": "Publié",
"post.status_draft": "Brouillon",
"post.back_to_posts": "Retour aux articles",
"post.edit": "Modifier",
"post.delete": "Supprimer",
"post.delete_confirm": "Êtes-vous sûr de vouloir supprimer cet article ?",
"post_form.title_edit": "Modifier l'article",
"post_form.title_new": "Nouvel article",
"post_form.page_title_edit": "Modifier l'article",
"post_form.page_title_new": "Créer un nouvel article",
"post_form.label_title": "Titre",
"post_form.placeholder_title": "Entrez le titre de l'article",
"post_form.hint_title": "Un titre accrocheur pour votre article",
"post_form.label_content": "Contenu",
"post_form.placeholder_content": "Écrivez votre article ici...",
"post_form.hint_content": "Le contenu principal de votre article. Markdown est supporté.",
"post_form.label_tags": "Tags",
"post_form.placeholder_tags": "python, fastapi, tutorial",
"post_form.hint_tags": "Liste de tags séparés par des virgules",
"post_form.cancel": "Annuler",
"post_form.save_draft": "Sauvegarder le brouillon",
"post_form.update_post": "Mettre à jour",
"post_form.publish_post": "Publier",
"profile.title": "Profil utilisateur",
"profile.email": "Email :",
"profile.not_provided": "Non fourni",
"profile.user_id": "ID utilisateur :",
"profile.name": "Nom :",
"profile.back_home": "Retour à l'accueil",
"profile.new_post": "Nouvel article",
"about.title": "À propos",
"about.page_title": "À propos",
"about.description": "Un blog moderne construit avec FastAPI et une architecture Domain-Driven Design.",
"about.signed_in": "Connecté en tant que {username}.",
"about.browsing_guest": "Vous naviguez en tant qu'invité.",
"about.back_home": "Retour à l'accueil",
"flash.post_published": "Article publié avec succès !",
"flash.post_saved_draft": "Article sauvegardé comme brouillon !",
"flash.post_updated": "Article mis à jour avec succès !",
"flash.post_deleted": "Article supprimé avec succès !",
"flash.post_not_found": "Article non trouvé.",
"base.close_message": "Fermer le message",
"base.default_title": "Blog",
"base.meta_description": "Blog — Une plateforme de blog moderne construite avec FastAPI",
"base.meta_keywords": "blog, articles, posts, écriture",
"base.meta_author": "Équipe du blog",
"lang.en": "English",
"lang.ru": "Русский",
"lang.fr": "Français",
"lang.de": "Deutsch",
},
"de": {
"nav.home": "Startseite",
"nav.posts": "Beiträge",
"nav.about": "Über uns",
"header.logo": "Blog",
"header.profile": "Profil",
"header.new_post": "Neuer Beitrag",
"header.sign_out": "Abmelden",
"header.sign_in": "Anmelden",
"header.toggle_menu": "Menü umschalten",
"header.toggle_theme": "Design umschalten",
"header.lang_switcher": "Sprache",
"footer.copyright": "© 2026 Blog. Alle Rechte vorbehalten.",
"footer.about": "Über uns",
"footer.privacy": "Datenschutz",
"footer.terms": "AGB",
"footer.api": "API",
"home.title": "Blog — Startseite",
"home.meta_description": "Entdecken Sie Geschichten, Gedanken und Fachwissen von Autoren zu jedem Thema. Ein moderner Blog mit FastAPI.",
"home.meta_keywords": "Blog, Artikel, Beiträge, Schreiben, Fastapi, Python",
"home.page_title": "Neueste Beiträge",
"home.page_subtitle": "Entdecken Sie Geschichten, Gedanken und Fachwissen von Autoren zu jedem Thema.",
"home.write_post": "Beitrag schreiben",
"home.status_published": "Veröffentlicht",
"home.status_draft": "Entwurf",
"home.read_more": "Weiterlesen",
"home.pagination_previous": "Zurück",
"home.pagination_next": "Weiter",
"home.empty_title": "Noch keine Beiträge",
"home.empty_description": "Schreiben Sie den ersten Beitrag!",
"home.empty_action": "Ersten Beitrag erstellen",
"post.status_published": "Veröffentlicht",
"post.status_draft": "Entwurf",
"post.back_to_posts": "Zurück zu den Beiträgen",
"post.edit": "Bearbeiten",
"post.delete": "Löschen",
"post.delete_confirm": "Sind Sie sicher, dass Sie diesen Beitrag löschen möchten?",
"post_form.title_edit": "Beitrag bearbeiten",
"post_form.title_new": "Neuer Beitrag",
"post_form.page_title_edit": "Beitrag bearbeiten",
"post_form.page_title_new": "Neuen Beitrag erstellen",
"post_form.label_title": "Titel",
"post_form.placeholder_title": "Geben Sie den Beitragstitel ein",
"post_form.hint_title": "Ein eingängiger Titel für Ihren Beitrag",
"post_form.label_content": "Inhalt",
"post_form.placeholder_content": "Schreiben Sie Ihren Beitrag hier...",
"post_form.hint_content": "Der Hauptinhalt Ihres Beitrags. Markdown wird unterstützt.",
"post_form.label_tags": "Tags",
"post_form.placeholder_tags": "python, fastapi, tutorial",
"post_form.hint_tags": "Kommagetrennte Tag-Liste",
"post_form.cancel": "Abbrechen",
"post_form.save_draft": "Als Entwurf speichern",
"post_form.update_post": "Beitrag aktualisieren",
"post_form.publish_post": "Beitrag veröffentlichen",
"profile.title": "Benutzerprofil",
"profile.email": "E-Mail:",
"profile.not_provided": "Nicht angegeben",
"profile.user_id": "Benutzer-ID:",
"profile.name": "Name:",
"profile.back_home": "Zurück zur Startseite",
"profile.new_post": "Neuer Beitrag",
"about.title": "Über uns",
"about.page_title": "Über uns",
"about.description": "Ein moderner Blog, erstellt mit FastAPI und Domain-Driven Design Architektur.",
"about.signed_in": "Angemeldet als {username}.",
"about.browsing_guest": "Sie surfen als Gast.",
"about.back_home": "Zurück zur Startseite",
"flash.post_published": "Beitrag erfolgreich veröffentlicht!",
"flash.post_saved_draft": "Beitrag als Entwurf gespeichert!",
"flash.post_updated": "Beitrag erfolgreich aktualisiert!",
"flash.post_deleted": "Beitrag erfolgreich gelöscht!",
"flash.post_not_found": "Beitrag nicht gefunden.",
"base.close_message": "Nachricht schließen",
"base.default_title": "Blog",
"base.meta_description": "Blog — Eine moderne Blogging-Plattform mit FastAPI",
"base.meta_keywords": "Blog, Artikel, Beiträge, Schreiben",
"base.meta_author": "Blog-Team",
"lang.en": "English",
"lang.ru": "Русский",
"lang.fr": "Français",
"lang.de": "Deutsch",
},
}

View File

@@ -0,0 +1,78 @@
"""Translation service for i18n support.
This module provides the translation service that resolves translation keys
to localized strings using in-memory translation dictionaries. Falls back
from requested locale through English to the raw key.
"""
from app.infrastructure.i18n.translations import TRANSLATIONS
SUPPORTED_LOCALES = frozenset({"en", "ru", "fr", "de"})
DEFAULT_LOCALE = "en"
class TranslationService:
"""Service for resolving translation keys to localized strings.
Provides a singleton-like interface for translating UI strings
across the application. Falls back through requested locale to
English and finally to the raw key if no translation exists.
Attributes:
translations: Dictionary of locale to key to string mappings.
"""
_instance: "TranslationService | None" = None
def __init__(self) -> None:
"""Initialize translation service with translation data."""
self.translations = TRANSLATIONS
@classmethod
def get_instance(cls) -> "TranslationService":
"""Get or create the singleton instance.
Returns:
The shared TranslationService instance.
"""
if cls._instance is None:
cls._instance = cls()
return cls._instance
def get_text(self, key: str, locale: str = DEFAULT_LOCALE) -> str:
"""Get translated text for a given key and locale.
Resolves the key through the locale chain: requested locale,
then English fallback, then the raw key itself.
Args:
key: Translation key (e.g. ``nav.home``).
locale: Target locale code (e.g. ``en``, ``ru``, ``fr``, ``de``).
Returns:
Translated string if found, otherwise the English version
or the key itself as last resort.
"""
locale_translations = self.translations.get(locale)
if locale_translations is not None and key in locale_translations:
return locale_translations[key]
if locale != DEFAULT_LOCALE:
fallback = self.translations.get(DEFAULT_LOCALE, {}).get(key)
if fallback is not None:
return fallback
return key
def _(key: str, locale: str = DEFAULT_LOCALE) -> str:
"""Convenience function for translating a single key.
Args:
key: Translation key to look up.
locale: Target locale code.
Returns:
Translated string or the key itself if no translation found.
"""
return TranslationService.get_instance().get_text(key, locale)

View File

@@ -28,6 +28,7 @@ from app.presentation.web import auth_router
from app.presentation.web import router as web_router
from app.presentation.web.error_handlers import register_error_handlers
from app.presentation.web.flash import setup_flash_manager
from app.presentation.web.locale import setup_locale_manager
@asynccontextmanager
@@ -86,6 +87,15 @@ def app_factory() -> FastAPI:
request.state.flash_manager.set_cookie(response)
return response
@app.middleware("http")
async def locale_middleware(
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
"""Middleware to detect and set locale for each request."""
await setup_locale_manager(request)
response = await call_next(request)
return response
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],

View File

@@ -1,11 +1,11 @@
<!DOCTYPE html>
<html lang="en" data-testid="html-root">
<html lang="{{ current_locale }}" data-testid="html-root">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="{% block meta_description %}Blog - A modern blogging platform built with FastAPI{% endblock %}">
<meta name="keywords" content="{% block meta_keywords %}blog, articles, posts, writing{% endblock %}">
<meta name="author" content="{% block meta_author %}Blog Team{% endblock %}">
<meta name="description" content="{% block meta_description %}{{ _('base.meta_description', current_locale) }}{% endblock %}">
<meta name="keywords" content="{% block meta_keywords %}{{ _('base.meta_keywords', current_locale) }}{% endblock %}">
<meta name="author" content="{% block meta_author %}{{ _('base.meta_author', current_locale) }}{% endblock %}">
<meta name="robots" content="{% block meta_robots %}index, follow{% endblock %}">
<!-- Canonical URL -->
@@ -30,7 +30,7 @@
<link rel="icon" type="image/svg+xml" href="/static/images/favicon.svg">
<link rel="alternate icon" href="/static/images/favicon.ico">
<title data-testid="page-title">{% block title %}Blog{% endblock %}</title>
<title data-testid="page-title">{% block title %}{{ _('base.default_title', current_locale) }}{% endblock %}</title>
<link rel="stylesheet" href="/static/css/themes/theme-light.css" data-testid="theme-light-stylesheet">
<link rel="stylesheet" href="/static/css/themes/theme-dark.css" data-testid="theme-dark-stylesheet">
@@ -51,7 +51,7 @@
{% for msg in flash_messages %}
<div class="flash-message flash-{{ msg.category }}" data-testid="flash-message-{{ msg.category }}" role="alert">
<span class="flash-text" data-testid="flash-text">{{ msg.message }}</span>
<button type="button" class="flash-close" data-testid="flash-close" aria-label="Close message">&times;</button>
<button type="button" class="flash-close" data-testid="flash-close" aria-label="{{ _('base.close_message', current_locale) }}">&times;</button>
</div>
{% endfor %}
</div>

View File

@@ -1,26 +1,26 @@
{% extends "base.html" %}
{% block title %}About - Blog{% endblock %}
{% block meta_description %}A modern blog built with FastAPI and DDD architecture.{% endblock %}
{% block title %}{{ _('about.title', current_locale) }} - {{ _('base.default_title', current_locale) }}{% endblock %}
{% block meta_description %}{{ _('about.description', current_locale) }}{% endblock %}
{% block content %}
<div class="page-header" data-testid="page-header-about">
<h1 class="page-title" data-testid="page-title-about">About</h1>
<h1 class="page-title" data-testid="page-title-about">{{ _('about.page_title', current_locale) }}</h1>
</div>
<div class="card" data-testid="about-card">
<div class="card-body" data-testid="about-card-body">
<p data-testid="about-description">
A modern blog built with FastAPI and Domain-Driven Design architecture.
{{ _('about.description', current_locale) }}
</p>
<div class="divider" data-testid="about-divider"></div>
<p data-testid="about-user">
{% if user %}
Signed in as <strong>{{ user.username }}</strong>.
{{ _('about.signed_in', current_locale).format(username=user.username) }}
{% else %}
You are browsing as a guest.
{{ _('about.browsing_guest', current_locale) }}
{% endif %}
</p>
</div>
@@ -30,7 +30,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Back to Home
{{ _('about.back_home', current_locale) }}
</a>
</div>
</div>

View File

@@ -1,29 +1,29 @@
{% extends "base.html" %}
{% block title %}Blog - Home{% endblock %}
{% block meta_description %}Discover stories, thinking, and expertise from writers on any topic. A modern blog built with FastAPI.{% endblock %}
{% block meta_keywords %}blog, articles, posts, writing, fastapi, python{% endblock %}
{% block title %}{{ _('home.title', current_locale) }}{% endblock %}
{% block meta_description %}{{ _('home.meta_description', current_locale) }}{% endblock %}
{% block meta_keywords %}{{ _('home.meta_keywords', current_locale) }}{% endblock %}
{% block og_type %}website{% endblock %}
{% block og_title %}Blog - Home{% endblock %}
{% block og_description %}Discover stories, thinking, and expertise from writers on any topic.{% endblock %}
{% block og_title %}{{ _('home.title', current_locale) }}{% endblock %}
{% block og_description %}{{ _('home.meta_description', current_locale) }}{% endblock %}
{% block twitter_title %}Blog - Home{% endblock %}
{% block twitter_description %}Discover stories, thinking, and expertise from writers on any topic.{% endblock %}
{% block twitter_title %}{{ _('home.title', current_locale) }}{% endblock %}
{% block twitter_description %}{{ _('home.meta_description', current_locale) }}{% endblock %}
{% block content %}
<section class="page-header" data-testid="page-header-home">
<div class="page-header-flex">
<div data-testid="page-header-content">
<h1 class="page-title" data-testid="page-title-home">Latest Posts</h1>
<p class="page-subtitle" data-testid="page-subtitle-home">Discover stories, thinking, and expertise from writers on any topic.</p>
<h1 class="page-title" data-testid="page-title-home">{{ _('home.page_title', current_locale) }}</h1>
<p class="page-subtitle" data-testid="page-subtitle-home">{{ _('home.page_subtitle', current_locale) }}</p>
</div>
{% if can_create %}
<a href="/web/posts/new" class="btn btn-primary" data-testid="btn-create-post-header">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Write a Post
{{ _('home.write_post', current_locale) }}
</a>
{% endif %}
</div>
@@ -38,9 +38,9 @@
<a href="/web/posts/{{ post.slug }}" data-testid="post-title-link-{{ post.id }}">{{ post.title }}</a>
</h2>
{% if post.published %}
<span class="badge badge-success" data-testid="post-status-{{ post.id }}">Published</span>
<span class="badge badge-success" data-testid="post-status-{{ post.id }}">{{ _('home.status_published', current_locale) }}</span>
{% else %}
<span class="badge" data-testid="post-status-{{ post.id }}">Draft</span>
<span class="badge" data-testid="post-status-{{ post.id }}">{{ _('home.status_draft', current_locale) }}</span>
{% endif %}
</div>
@@ -65,7 +65,7 @@
{% endfor %}
</div>
<a href="/web/posts/{{ post.slug }}" class="btn btn-sm" data-testid="btn-read-more-{{ post.id }}">
Read more
{{ _('home.read_more', current_locale) }}
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-left: 0.25rem;">
<path d="M6 12L10 8L6 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -77,26 +77,26 @@
<nav class="pagination" data-testid="pagination" aria-label="Pagination">
{% if has_prev %}
<a href="{{ request.url.path }}?page={{ current_page - 1 }}" class="pagination-item" data-testid="pagination-prev">Previous</a>
<a href="{{ request.url.path }}?page={{ current_page - 1 }}" class="pagination-item" data-testid="pagination-prev">{{ _('home.pagination_previous', current_locale) }}</a>
{% else %}
<span class="pagination-item disabled" data-testid="pagination-prev">Previous</span>
<span class="pagination-item disabled" data-testid="pagination-prev">{{ _('home.pagination_previous', current_locale) }}</span>
{% endif %}
<span class="pagination-item active" data-testid="pagination-current">{{ current_page }}</span>
{% if has_next %}
<a href="{{ request.url.path }}?page={{ current_page + 1 }}" class="pagination-item" data-testid="pagination-next">Next</a>
<a href="{{ request.url.path }}?page={{ current_page + 1 }}" class="pagination-item" data-testid="pagination-next">{{ _('home.pagination_next', current_locale) }}</a>
{% else %}
<span class="pagination-item disabled" data-testid="pagination-next">Next</span>
<span class="pagination-item disabled" data-testid="pagination-next">{{ _('home.pagination_next', current_locale) }}</span>
{% endif %}
</nav>
{% else %}
<div class="empty-state" data-testid="empty-state">
<div class="empty-state-icon" data-testid="empty-state-icon">📝</div>
<h3 data-testid="empty-state-title">No posts yet</h3>
<p data-testid="empty-state-description">Be the first to write a post!</p>
<a href="/web/posts/new" class="btn btn-primary" data-testid="btn-create-first-post">Create your first post</a>
<h3 data-testid="empty-state-title">{{ _('home.empty_title', current_locale) }}</h3>
<p data-testid="empty-state-description">{{ _('home.empty_description', current_locale) }}</p>
<a href="/web/posts/new" class="btn btn-primary" data-testid="btn-create-first-post">{{ _('home.empty_action', current_locale) }}</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -29,9 +29,9 @@
{{ post.created_at.strftime('%B %d, %Y') }}
</span>
{% if post.published %}
<span class="badge badge-success" data-testid="post-detail-status">Published</span>
<span class="badge badge-success" data-testid="post-detail-status">{{ _('post.status_published', current_locale) }}</span>
{% else %}
<span class="badge" data-testid="post-detail-status">Draft</span>
<span class="badge" data-testid="post-detail-status">{{ _('post.status_draft', current_locale) }}</span>
{% endif %}
</div>
</header>
@@ -54,26 +54,26 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Back to posts
{{ _('post.back_to_posts', current_locale) }}
</a>
{% if can_edit or can_delete %}
<div class="flex gap-2" data-testid="post-detail-edit-actions">
{% if can_edit %}
<a href="/web/posts/{{ post.slug }}/edit" class="btn" data-testid="btn-edit-post">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M11 2L14 5M2 14L3 10L12 1L15 4L6 13L2 14Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Edit
</a>
<a href="/web/posts/{{ post.slug }}/edit" class="btn" data-testid="btn-edit-post">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M11 2L14 5M2 14L3 10L12 1L15 4L6 13L2 14Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{{ _('post.edit', current_locale) }}
</a>
{% endif %}
{% if can_delete %}
<form action="/web/posts/{{ post.slug }}/delete" method="POST" style="display: inline;" data-testid="form-delete-post">
<button type="submit" class="btn btn-danger" data-testid="btn-delete-post" onclick="return confirm('Are you sure you want to delete this post?');">
<button type="submit" class="btn btn-danger" data-testid="btn-delete-post" onclick="return confirm('{{ _('post.delete_confirm', current_locale) }}');">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M2 4h12M6 4V2a2 2 0 012-2h0a2 2 0 012 2v2m3 0v10a2 2 0 01-2 2H5a2 2 0 01-2-2V4h9z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Delete
{{ _('post.delete', current_locale) }}
</button>
</form>
{% endif %}

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}{% if is_edit %}Edit Post{% else %}New Post{% endif %} - Blog{% endblock %}
{% block title %}{% if is_edit %}{{ _('post_form.title_edit', current_locale) }}{% else %}{{ _('post_form.title_new', current_locale) }}{% endif %} - {{ _('base.default_title', current_locale) }}{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="/static/css/easymde.min.css" data-testid="easymde-stylesheet">
@@ -9,7 +9,7 @@
{% block content %}
<section class="page-header" data-testid="page-header-form">
<h1 class="page-title" data-testid="page-title-form">
{% if is_edit %}Edit Post{% else %}Create New Post{% endif %}
{% if is_edit %}{{ _('post_form.page_title_edit', current_locale) }}{% else %}{{ _('post_form.page_title_new', current_locale) }}{% endif %}
</h1>
</section>
@@ -22,7 +22,7 @@
<div class="card-body" data-testid="form-post-body">
<div class="form-group" data-testid="form-group-title">
<label for="title" class="form-label form-label-required" data-testid="label-title">
Title
{{ _('post_form.label_title', current_locale) }}
</label>
<input
type="text"
@@ -30,31 +30,31 @@
name="title"
class="input input-lg"
value="{% if post %}{{ post.title }}{% endif %}"
placeholder="Enter post title"
placeholder="{{ _('post_form.placeholder_title', current_locale) }}"
required
data-testid="input-title"
>
<span class="form-hint" data-testid="hint-title">A catchy title for your post</span>
<span class="form-hint" data-testid="hint-title">{{ _('post_form.hint_title', current_locale) }}</span>
</div>
<div class="form-group" data-testid="form-group-content">
<label for="content" class="form-label form-label-required" data-testid="label-content">
Content
{{ _('post_form.label_content', current_locale) }}
</label>
<textarea
id="content"
name="content"
rows="12"
placeholder="Write your post content here..."
placeholder="{{ _('post_form.placeholder_content', current_locale) }}"
required
data-testid="textarea-content"
>{% if post %}{{ post.content }}{% endif %}</textarea>
<span class="form-hint" data-testid="hint-content">The main content of your post. Markdown is supported.</span>
<span class="form-hint" data-testid="hint-content">{{ _('post_form.hint_content', current_locale) }}</span>
</div>
<div class="form-group" data-testid="form-group-tags">
<label for="tags" class="form-label" data-testid="label-tags">
Tags
{{ _('post_form.label_tags', current_locale) }}
</label>
<input
type="text"
@@ -62,10 +62,10 @@
name="tags"
class="input"
value="{% if post %}{{ post.tags|join(', ') }}{% endif %}"
placeholder="python, fastapi, tutorial"
placeholder="{{ _('post_form.placeholder_tags', current_locale) }}"
data-testid="input-tags"
>
<span class="form-hint" data-testid="hint-tags">Comma-separated list of tags</span>
<span class="form-hint" data-testid="hint-tags">{{ _('post_form.hint_tags', current_locale) }}</span>
</div>
</div>
@@ -73,15 +73,15 @@
<div class="card-footer" data-testid="form-post-footer">
<div class="flex justify-between items-center" data-testid="form-actions">
<a href="{% if is_edit %}/web/posts/{{ post.slug }}{% else %}/web/{% endif %}" class="btn" data-testid="btn-cancel">
Cancel
{{ _('post_form.cancel', current_locale) }}
</a>
<div class="flex gap-2" data-testid="form-submit-actions">
<button type="submit" name="action" value="draft" class="btn" data-testid="btn-save-draft">
Save as Draft
{{ _('post_form.save_draft', current_locale) }}
</button>
<button type="submit" name="action" value="publish" class="btn btn-primary" data-testid="btn-publish-post">
{% if is_edit %}Update Post{% else %}Publish Post{% endif %}
{% if is_edit %}{{ _('post_form.update_post', current_locale) }}{% else %}{{ _('post_form.publish_post', current_locale) }}{% endif %}
</button>
</div>
</div>
@@ -99,7 +99,7 @@
spellChecker: false,
status: false,
minHeight: '300px',
placeholder: 'Write your post content here...',
placeholder: '{{ _('post_form.placeholder_content', current_locale) }}',
toolbar: [
'bold', 'italic', 'heading', '|',
'code', 'quote', 'unordered-list', 'ordered-list', '|',

View File

@@ -4,7 +4,7 @@
{% block content %}
<div class="page-header" data-testid="page-header-profile">
<h1 class="page-title" data-testid="page-title-profile">User Profile</h1>
<h1 class="page-title" data-testid="page-title-profile">{{ _('profile.title', current_locale) }}</h1>
</div>
<div class="card" data-testid="profile-card">
@@ -25,18 +25,18 @@
<div class="profile-details" data-testid="profile-details">
<div class="profile-field" data-testid="profile-field-email">
<span class="profile-label" data-testid="profile-label-email">Email:</span>
<span class="profile-value" data-testid="profile-value-email">{{ user.email or 'Not provided' }}</span>
<span class="profile-label" data-testid="profile-label-email">{{ _('profile.email', current_locale) }}</span>
<span class="profile-value" data-testid="profile-value-email">{{ user.email or _('profile.not_provided', current_locale) }}</span>
</div>
<div class="profile-field" data-testid="profile-field-userid">
<span class="profile-label" data-testid="profile-label-userid">User ID:</span>
<span class="profile-label" data-testid="profile-label-userid">{{ _('profile.user_id', current_locale) }}</span>
<span class="profile-value" data-testid="profile-value-userid">{{ user.user_id }}</span>
</div>
{% if user.first_name or user.last_name %}
<div class="profile-field" data-testid="profile-field-name">
<span class="profile-label" data-testid="profile-label-name">Name:</span>
<span class="profile-label" data-testid="profile-label-name">{{ _('profile.name', current_locale) }}</span>
<span class="profile-value" data-testid="profile-value-name">
{{ user.first_name or '' }} {{ user.last_name or '' }}
</span>
@@ -51,7 +51,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Back to Home
{{ _('profile.back_home', current_locale) }}
</a>
{% if can_create %}
@@ -59,7 +59,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
New Post
{{ _('profile.new_post', current_locale) }}
</a>
{% endif %}
</div>

View File

@@ -1,14 +1,14 @@
<footer class="site-footer" data-testid="site-footer">
<div class="container" data-testid="footer-container">
<div class="footer-copyright" data-testid="footer-copyright">
<span data-testid="copyright-text">© 2026 Blog. All rights reserved.</span>
<span data-testid="copyright-text">{{ _('footer.copyright', current_locale) }}</span>
</div>
<nav class="footer-links" data-testid="footer-nav" aria-label="Footer navigation">
<a href="/about" class="footer-link" data-testid="footer-link-about">About</a>
<a href="/privacy" class="footer-link" data-testid="footer-link-privacy">Privacy</a>
<a href="/terms" class="footer-link" data-testid="footer-link-terms">Terms</a>
<a href="/api/docs" class="footer-link" data-testid="footer-link-api">API</a>
<a href="/about" class="footer-link" data-testid="footer-link-about">{{ _('footer.about', current_locale) }}</a>
<a href="/privacy" class="footer-link" data-testid="footer-link-privacy">{{ _('footer.privacy', current_locale) }}</a>
<a href="/terms" class="footer-link" data-testid="footer-link-terms">{{ _('footer.terms', current_locale) }}</a>
<a href="/api/docs" class="footer-link" data-testid="footer-link-api">{{ _('footer.api', current_locale) }}</a>
</nav>
</div>
</footer>

View File

@@ -5,7 +5,7 @@
<rect width="32" height="32" rx="6" fill="var(--color-primary)"/>
<path d="M8 12h16M8 16h12M8 20h8" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>
<span data-testid="logo-text">Blog</span>
<span data-testid="logo-text">{{ _('header.logo', current_locale) }}</span>
</a>
{% include "partials/nav.html" %}
@@ -15,7 +15,7 @@
type="button"
class="mobile-menu-btn"
data-testid="mobile-menu-toggle"
aria-label="Toggle menu"
aria-label="{{ _('header.toggle_menu', current_locale) }}"
aria-expanded="false"
aria-controls="mobile-nav"
>
@@ -30,8 +30,8 @@
type="button"
class="theme-toggle"
data-testid="theme-toggle"
aria-label="Toggle dark mode"
title="Toggle dark mode"
aria-label="{{ _('header.toggle_theme', current_locale) }}"
title="{{ _('header.toggle_theme', current_locale) }}"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" data-testid="theme-light-icon" style="display: none;">
<path d="M10 2v2M10 16v2M4.22 4.22l1.42 1.42M14.36 14.36l1.42 1.42M2 10h2M16 10h2M4.22 15.78l1.42-1.42M14.36 5.64l1.42-1.42" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
@@ -41,7 +41,27 @@
<path d="M17.293 13.293A8 8 0 116.707 2.707a8.003 8.003 0 0010.586 10.586z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div class="lang-switcher" data-testid="lang-switcher">
<button
type="button"
class="lang-switcher-toggle"
data-testid="lang-switcher-toggle"
aria-haspopup="true"
aria-expanded="false"
title="{{ _('header.lang_switcher', current_locale) }}"
>
<span data-testid="current-lang-code">{{ current_locale|upper }}</span>
</button>
<div class="lang-switcher-dropdown" data-testid="lang-switcher-dropdown">
{% for code in ('en', 'ru', 'fr', 'de') %}
<a href="/web/lang/{{ code }}" class="lang-switcher-item {% if code == current_locale %}active{% endif %}" data-testid="lang-option-{{ code }}">
{{ _('lang.' + code, current_locale) }}
</a>
{% endfor %}
</div>
</div>
{% if user %}
<div class="user-menu" data-testid="user-menu">
<button
@@ -63,14 +83,14 @@
<circle cx="8" cy="6" r="3" stroke="currentColor" stroke-width="2"/>
<path d="M2 14c0-3 3-5 6-5s6 2 6 5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
Profile
{{ _('header.profile', current_locale) }}
</a>
{% if can_create %}
<a href="/web/posts/new" class="user-menu-item" data-testid="user-menu-new-post">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
New Post
{{ _('header.new_post', current_locale) }}
</a>
{% endif %}
<div class="user-menu-divider" data-testid="user-menu-divider"></div>
@@ -78,13 +98,13 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M10 12h2a2 2 0 002-2V6a2 2 0 00-2-2h-2M6 12l-3-3m0 0l3-3m-3 3h8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Sign Out
{{ _('header.sign_out', current_locale) }}
</a>
</div>
</div>
{% else %}
<a href="/auth/login" class="btn btn-primary btn-sm" data-testid="btn-login">
Sign In
{{ _('header.sign_in', current_locale) }}
</a>
{% endif %}
</div>
@@ -241,6 +261,84 @@
}
}
.lang-switcher {
position: relative;
}
.lang-switcher-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
padding: 0;
background: transparent;
border: 1px solid var(--color-border);
border-radius: 6px;
color: var(--color-text);
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.lang-switcher-toggle:hover {
background-color: var(--color-hover);
border-color: var(--color-secondary-dark-2);
}
.lang-switcher-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 0.5rem;
min-width: 140px;
background-color: var(--color-box-body);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 4px 12px var(--color-shadow);
opacity: 0;
visibility: hidden;
transform: translateY(-10px);
transition: all 0.2s ease;
z-index: 1000;
}
.lang-switcher:hover .lang-switcher-dropdown,
.lang-switcher-toggle:focus + .lang-switcher-dropdown,
.lang-switcher-dropdown:hover {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.lang-switcher-item {
display: block;
padding: 0.6rem 1rem;
color: var(--color-text);
text-decoration: none;
font-size: 0.875rem;
transition: background-color 0.2s ease;
}
.lang-switcher-item:first-child {
border-radius: 8px 8px 0 0;
}
.lang-switcher-item:last-child {
border-radius: 0 0 8px 8px;
}
.lang-switcher-item:hover {
background-color: var(--color-hover);
text-decoration: none;
}
.lang-switcher-item.active {
font-weight: 600;
color: var(--color-primary);
}
@media (min-width: 769px) {
.mobile-menu-btn {
display: none;
@@ -255,13 +353,13 @@
<!-- Mobile Navigation Menu -->
<nav class="mobile-nav" id="mobile-nav" data-testid="mobile-nav" aria-label="Mobile navigation">
<a href="/web/" class="nav-link {% if active_page == 'home' %}active{% endif %}" data-testid="mobile-nav-link-home">
Home
{{ _('nav.home', current_locale) }}
</a>
<a href="/web/posts" class="nav-link {% if active_page == 'posts' %}active{% endif %}" data-testid="mobile-nav-link-posts">
Posts
{{ _('nav.posts', current_locale) }}
</a>
<a href="/web/about" class="nav-link {% if active_page == 'about' %}active{% endif %}" data-testid="mobile-nav-link-about">
About
{{ _('nav.about', current_locale) }}
</a>
</nav>

View File

@@ -1,11 +1,11 @@
<nav class="main-nav" data-testid="main-nav" aria-label="Main navigation">
<a href="/web/" class="nav-link {% if active_page == 'home' %}active{% endif %}" data-testid="nav-link-home">
Home
{{ _('nav.home', current_locale) }}
</a>
<a href="/web/posts" class="nav-link {% if active_page == 'posts' %}active{% endif %}" data-testid="nav-link-posts">
Posts
{{ _('nav.posts', current_locale) }}
</a>
<a href="/web/about" class="nav-link {% if active_page == 'about' %}active{% endif %}" data-testid="nav-link-about">
About
{{ _('nav.about', current_locale) }}
</a>
</nav>

View File

@@ -0,0 +1,72 @@
"""Locale detection and management for i18n support.
This module provides locale detection from Accept-Language headers and cookies,
following the same middleware pattern as the flash message system.
"""
from fastapi import Request
from app.infrastructure.i18n.translator import DEFAULT_LOCALE, SUPPORTED_LOCALES
LOCALE_COOKIE_NAME = "locale"
SUPPORTED_LOCALES_SET: frozenset[str] = SUPPORTED_LOCALES
def _parse_accept_language(header: str) -> list[str]:
"""Parse Accept-Language header into ordered list of locale codes.
Args:
header: Raw Accept-Language header value.
Returns:
List of locale codes in preference order, with region subtags removed.
"""
if not header:
return []
locales: list[str] = []
for part in header.split(","):
part = part.strip()
if not part:
continue
locale = part.split(";")[0].strip().split("-")[0]
if locale:
locales.append(locale)
return locales
def _get_best_locale(request: Request) -> str:
"""Detect the best locale for the current request.
Priority order: cookie → Accept-Language header → default.
Args:
request: FastAPI request object.
Returns:
Best matching locale code, defaulting to ``en``.
"""
cookie_locale = request.cookies.get(LOCALE_COOKIE_NAME)
if cookie_locale and cookie_locale in SUPPORTED_LOCALES_SET:
return cookie_locale
accept_language = request.headers.get("accept-language", "")
for lang in _parse_accept_language(accept_language):
if lang in SUPPORTED_LOCALES_SET:
return lang
return DEFAULT_LOCALE
async def setup_locale_manager(request: Request) -> None:
"""Set the detected locale on request state.
Called early in the request lifecycle so that route handlers and
template rendering can access the current locale via
``request.state.locale``.
Args:
request: FastAPI request object.
"""
if not hasattr(request.state, "locale"):
request.state.locale = _get_best_locale(request)

View File

@@ -33,6 +33,8 @@ from app.domain.exceptions import (
)
from app.domain.roles import Role, get_effective_role
from app.infrastructure.auth import TokenInfo
from app.infrastructure.config.settings import settings
from app.infrastructure.i18n.translator import DEFAULT_LOCALE, SUPPORTED_LOCALES, _
from app.presentation.web.deps import (
OptionalUserDep,
RequireUserDep,
@@ -47,6 +49,22 @@ router = APIRouter(prefix="/web", tags=["web"], route_class=DishkaRoute)
templates = Jinja2Templates(directory="app/presentation/templates")
def _jinja_translate(key: str, locale: str = DEFAULT_LOCALE) -> str:
"""Jinja2 global function for template translation.
Args:
key: Translation key to look up.
locale: Target locale code.
Returns:
Translated string or the key itself if no translation found.
"""
return _(key, locale)
templates.env.globals["_"] = _jinja_translate
_md = MarkdownIt("commonmark", {"html": False}).enable("table")
@@ -85,14 +103,17 @@ def _get_user_role(user: TokenInfo | None) -> Role:
return get_effective_role(user.roles)
def _get_base_context(user: TokenInfo | None) -> dict[str, Any]:
def _get_base_context(
user: TokenInfo | None, current_locale: str = DEFAULT_LOCALE
) -> dict[str, Any]:
"""Get base template context with user info and permissions.
Args:
user: Current user or None for guest.
current_locale: Active locale code for i18n.
Returns:
Dictionary with user, user_role, and can_create flags.
Dictionary with user, user_role, can_create, and current_locale.
"""
user_role = _get_user_role(user)
@@ -100,6 +121,7 @@ def _get_base_context(user: TokenInfo | None) -> dict[str, Any]:
"user": user,
"user_role": user_role.value if user_role else None,
"can_create": can_create_post(user),
"current_locale": current_locale,
}
@@ -173,7 +195,8 @@ async def home(
list_use_case, user, _DEFAULT_PAGE_SIZE, offset
)
context = _get_base_context(user)
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
context = _get_base_context(user, locale)
return templates.TemplateResponse(
request,
"pages/index.html",
@@ -212,7 +235,8 @@ async def list_posts(
list_use_case, user, _DEFAULT_PAGE_SIZE, offset
)
context = _get_base_context(user)
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
context = _get_base_context(user, locale)
return templates.TemplateResponse(
request,
"pages/index.html",
@@ -241,7 +265,8 @@ async def new_post_form(
Returns:
HTMLResponse with rendered post form template.
"""
context = _get_base_context(user)
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
context = _get_base_context(user, locale)
return templates.TemplateResponse(
request,
@@ -291,11 +316,12 @@ async def create_post(
result = await create_use_case.execute(dto)
user_role = _get_user_role(user)
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
if action == "publish":
await publish_use_case.publish(result.id, user.user_id, user_role)
flash(request, "Post published successfully!", "success")
flash(request, _("flash.post_published", locale), "success")
else:
flash(request, "Post saved as draft!", "success")
flash(request, _("flash.post_saved_draft", locale), "success")
return RedirectResponse(url=f"/web/posts/{result.slug}", status_code=303)
except AlreadyExistsException as exc:
@@ -335,7 +361,8 @@ async def post_detail(
if not post.published and not can_see_draft(user, post.author_id):
raise HTTPException(status_code=404, detail="Post not found")
context = _get_base_context(user)
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
context = _get_base_context(user, locale)
return templates.TemplateResponse(
request,
@@ -379,7 +406,8 @@ async def edit_post_form(
if not can_edit_post(user, post.author_id):
raise HTTPException(status_code=403, detail="Not authorized to edit this post")
context = _get_base_context(user)
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
context = _get_base_context(user, locale)
return templates.TemplateResponse(
request,
@@ -447,7 +475,8 @@ async def update_post(
if result.published:
await publish_use_case.unpublish(result.id, user.user_id, user_role)
flash(request, "Post updated successfully!", "success")
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
flash(request, _("flash.post_updated", locale), "success")
return RedirectResponse(url=f"/web/posts/{result.slug}", status_code=303)
except (AlreadyExistsException, ValidationException) as exc:
flash(request, str(exc), "error")
@@ -485,9 +514,11 @@ async def delete_post(
try:
user_role = _get_user_role(user)
await delete_use_case.execute(post.id, user.user_id, user_role)
flash(request, "Post deleted successfully!", "success")
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
flash(request, _("flash.post_deleted", locale), "success")
except NotFoundException:
flash(request, "Post not found.", "error")
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
flash(request, _("flash.post_not_found", locale), "error")
return RedirectResponse(url="/web/", status_code=303)
@@ -506,7 +537,8 @@ async def profile(
Returns:
HTMLResponse with rendered profile template.
"""
context = _get_base_context(user)
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
context = _get_base_context(user, locale)
return templates.TemplateResponse(
request,
@@ -532,7 +564,8 @@ async def about(
Returns:
HTMLResponse with rendered about page template.
"""
context = _get_base_context(user)
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
context = _get_base_context(user, locale)
return templates.TemplateResponse(
request,
@@ -542,3 +575,37 @@ async def about(
"active_page": "about",
},
)
@router.get("/lang/{locale}")
async def set_language(
request: Request,
locale: str,
) -> RedirectResponse:
"""Set the active language and redirect back to the previous page.
Stores the locale choice in a persistent cookie so that subsequent
requests use the selected language. Falls back to browser preference
or English default.
Args:
request: HTTP request object.
locale: Target locale code (en, ru, fr, de).
Returns:
RedirectResponse back to the referrer or home page.
"""
if locale not in SUPPORTED_LOCALES:
locale = DEFAULT_LOCALE
referer = request.headers.get("referer", "/web/")
response = RedirectResponse(url=referer, status_code=303)
response.set_cookie(
key="locale",
value=locale,
httponly=True,
secure=not settings.is_dev,
samesite="lax",
max_age=365 * 24 * 3600,
)
return response