Localization #15
9
app/infrastructure/i18n/__init__.py
Normal file
9
app/infrastructure/i18n/__init__.py
Normal 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", "_"]
|
||||||
337
app/infrastructure/i18n/translations.py
Normal file
337
app/infrastructure/i18n/translations.py
Normal 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",
|
||||||
|
},
|
||||||
|
}
|
||||||
78
app/infrastructure/i18n/translator.py
Normal file
78
app/infrastructure/i18n/translator.py
Normal 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)
|
||||||
10
app/main.py
10
app/main.py
@@ -28,6 +28,7 @@ from app.presentation.web import auth_router
|
|||||||
from app.presentation.web import router as web_router
|
from app.presentation.web import router as web_router
|
||||||
from app.presentation.web.error_handlers import register_error_handlers
|
from app.presentation.web.error_handlers import register_error_handlers
|
||||||
from app.presentation.web.flash import setup_flash_manager
|
from app.presentation.web.flash import setup_flash_manager
|
||||||
|
from app.presentation.web.locale import setup_locale_manager
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -86,6 +87,15 @@ def app_factory() -> FastAPI:
|
|||||||
request.state.flash_manager.set_cookie(response)
|
request.state.flash_manager.set_cookie(response)
|
||||||
return 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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" data-testid="html-root">
|
<html lang="{{ current_locale }}" data-testid="html-root">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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="description" content="{% block meta_description %}{{ _('base.meta_description', current_locale) }}{% endblock %}">
|
||||||
<meta name="keywords" content="{% block meta_keywords %}blog, articles, posts, writing{% endblock %}">
|
<meta name="keywords" content="{% block meta_keywords %}{{ _('base.meta_keywords', current_locale) }}{% endblock %}">
|
||||||
<meta name="author" content="{% block meta_author %}Blog Team{% endblock %}">
|
<meta name="author" content="{% block meta_author %}{{ _('base.meta_author', current_locale) }}{% endblock %}">
|
||||||
<meta name="robots" content="{% block meta_robots %}index, follow{% endblock %}">
|
<meta name="robots" content="{% block meta_robots %}index, follow{% endblock %}">
|
||||||
|
|
||||||
<!-- Canonical URL -->
|
<!-- Canonical URL -->
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/static/images/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/static/images/favicon.svg">
|
||||||
<link rel="alternate icon" href="/static/images/favicon.ico">
|
<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-light.css" data-testid="theme-light-stylesheet">
|
||||||
<link rel="stylesheet" href="/static/css/themes/theme-dark.css" data-testid="theme-dark-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 %}
|
{% for msg in flash_messages %}
|
||||||
<div class="flash-message flash-{{ msg.category }}" data-testid="flash-message-{{ msg.category }}" role="alert">
|
<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>
|
<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">×</button>
|
<button type="button" class="flash-close" data-testid="flash-close" aria-label="{{ _('base.close_message', current_locale) }}">×</button>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}About - Blog{% endblock %}
|
{% block title %}{{ _('about.title', current_locale) }} - {{ _('base.default_title', current_locale) }}{% endblock %}
|
||||||
{% block meta_description %}A modern blog built with FastAPI and DDD architecture.{% endblock %}
|
{% block meta_description %}{{ _('about.description', current_locale) }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-header" data-testid="page-header-about">
|
<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>
|
||||||
|
|
||||||
<div class="card" data-testid="about-card">
|
<div class="card" data-testid="about-card">
|
||||||
<div class="card-body" data-testid="about-card-body">
|
<div class="card-body" data-testid="about-card-body">
|
||||||
<p data-testid="about-description">
|
<p data-testid="about-description">
|
||||||
A modern blog built with FastAPI and Domain-Driven Design architecture.
|
{{ _('about.description', current_locale) }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="divider" data-testid="about-divider"></div>
|
<div class="divider" data-testid="about-divider"></div>
|
||||||
|
|
||||||
<p data-testid="about-user">
|
<p data-testid="about-user">
|
||||||
{% if user %}
|
{% if user %}
|
||||||
Signed in as <strong>{{ user.username }}</strong>.
|
{{ _('about.signed_in', current_locale).format(username=user.username) }}
|
||||||
{% else %}
|
{% else %}
|
||||||
You are browsing as a guest.
|
{{ _('about.browsing_guest', current_locale) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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;">
|
<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"/>
|
<path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
Back to Home
|
{{ _('about.back_home', current_locale) }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Blog - Home{% endblock %}
|
{% block title %}{{ _('home.title', current_locale) }}{% endblock %}
|
||||||
{% block meta_description %}Discover stories, thinking, and expertise from writers on any topic. A modern blog built with FastAPI.{% endblock %}
|
{% block meta_description %}{{ _('home.meta_description', current_locale) }}{% endblock %}
|
||||||
{% block meta_keywords %}blog, articles, posts, writing, fastapi, python{% endblock %}
|
{% block meta_keywords %}{{ _('home.meta_keywords', current_locale) }}{% endblock %}
|
||||||
|
|
||||||
{% block og_type %}website{% endblock %}
|
{% block og_type %}website{% endblock %}
|
||||||
{% block og_title %}Blog - Home{% endblock %}
|
{% block og_title %}{{ _('home.title', current_locale) }}{% endblock %}
|
||||||
{% block og_description %}Discover stories, thinking, and expertise from writers on any topic.{% endblock %}
|
{% block og_description %}{{ _('home.meta_description', current_locale) }}{% endblock %}
|
||||||
|
|
||||||
{% block twitter_title %}Blog - Home{% endblock %}
|
{% block twitter_title %}{{ _('home.title', current_locale) }}{% endblock %}
|
||||||
{% block twitter_description %}Discover stories, thinking, and expertise from writers on any topic.{% endblock %}
|
{% block twitter_description %}{{ _('home.meta_description', current_locale) }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="page-header" data-testid="page-header-home">
|
<section class="page-header" data-testid="page-header-home">
|
||||||
<div class="page-header-flex">
|
<div class="page-header-flex">
|
||||||
<div data-testid="page-header-content">
|
<div data-testid="page-header-content">
|
||||||
<h1 class="page-title" data-testid="page-title-home">Latest Posts</h1>
|
<h1 class="page-title" data-testid="page-title-home">{{ _('home.page_title', current_locale) }}</h1>
|
||||||
<p class="page-subtitle" data-testid="page-subtitle-home">Discover stories, thinking, and expertise from writers on any topic.</p>
|
<p class="page-subtitle" data-testid="page-subtitle-home">{{ _('home.page_subtitle', current_locale) }}</p>
|
||||||
</div>
|
</div>
|
||||||
{% if can_create %}
|
{% if can_create %}
|
||||||
<a href="/web/posts/new" class="btn btn-primary" data-testid="btn-create-post-header">
|
<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;">
|
<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"/>
|
<path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
Write a Post
|
{{ _('home.write_post', current_locale) }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -38,9 +38,9 @@
|
|||||||
<a href="/web/posts/{{ post.slug }}" data-testid="post-title-link-{{ post.id }}">{{ post.title }}</a>
|
<a href="/web/posts/{{ post.slug }}" data-testid="post-title-link-{{ post.id }}">{{ post.title }}</a>
|
||||||
</h2>
|
</h2>
|
||||||
{% if post.published %}
|
{% 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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<a href="/web/posts/{{ post.slug }}" class="btn btn-sm" data-testid="btn-read-more-{{ post.id }}">
|
<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;">
|
<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"/>
|
<path d="M6 12L10 8L6 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -77,26 +77,26 @@
|
|||||||
|
|
||||||
<nav class="pagination" data-testid="pagination" aria-label="Pagination">
|
<nav class="pagination" data-testid="pagination" aria-label="Pagination">
|
||||||
{% if has_prev %}
|
{% 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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
|
|
||||||
<span class="pagination-item active" data-testid="pagination-current">{{ current_page }}</span>
|
<span class="pagination-item active" data-testid="pagination-current">{{ current_page }}</span>
|
||||||
|
|
||||||
{% if has_next %}
|
{% 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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty-state" data-testid="empty-state">
|
<div class="empty-state" data-testid="empty-state">
|
||||||
<div class="empty-state-icon" data-testid="empty-state-icon">📝</div>
|
<div class="empty-state-icon" data-testid="empty-state-icon">📝</div>
|
||||||
<h3 data-testid="empty-state-title">No posts yet</h3>
|
<h3 data-testid="empty-state-title">{{ _('home.empty_title', current_locale) }}</h3>
|
||||||
<p data-testid="empty-state-description">Be the first to write a post!</p>
|
<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">Create your first post</a>
|
<a href="/web/posts/new" class="btn btn-primary" data-testid="btn-create-first-post">{{ _('home.empty_action', current_locale) }}</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -29,9 +29,9 @@
|
|||||||
{{ post.created_at.strftime('%B %d, %Y') }}
|
{{ post.created_at.strftime('%B %d, %Y') }}
|
||||||
</span>
|
</span>
|
||||||
{% if post.published %}
|
{% 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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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;">
|
<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"/>
|
<path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
Back to posts
|
{{ _('post.back_to_posts', current_locale) }}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{% if can_edit or can_delete %}
|
{% if can_edit or can_delete %}
|
||||||
<div class="flex gap-2" data-testid="post-detail-edit-actions">
|
<div class="flex gap-2" data-testid="post-detail-edit-actions">
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<a href="/web/posts/{{ post.slug }}/edit" class="btn" data-testid="btn-edit-post">
|
<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;">
|
<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"/>
|
<path d="M11 2L14 5M2 14L3 10L12 1L15 4L6 13L2 14Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
Edit
|
{{ _('post.edit', current_locale) }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if can_delete %}
|
{% if can_delete %}
|
||||||
<form action="/web/posts/{{ post.slug }}/delete" method="POST" style="display: inline;" data-testid="form-delete-post">
|
<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;">
|
<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"/>
|
<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>
|
</svg>
|
||||||
Delete
|
{{ _('post.delete', current_locale) }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% 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 %}
|
{% block extra_css %}
|
||||||
<link rel="stylesheet" href="/static/css/easymde.min.css" data-testid="easymde-stylesheet">
|
<link rel="stylesheet" href="/static/css/easymde.min.css" data-testid="easymde-stylesheet">
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="page-header" data-testid="page-header-form">
|
<section class="page-header" data-testid="page-header-form">
|
||||||
<h1 class="page-title" data-testid="page-title-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>
|
</h1>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
<div class="card-body" data-testid="form-post-body">
|
<div class="card-body" data-testid="form-post-body">
|
||||||
<div class="form-group" data-testid="form-group-title">
|
<div class="form-group" data-testid="form-group-title">
|
||||||
<label for="title" class="form-label form-label-required" data-testid="label-title">
|
<label for="title" class="form-label form-label-required" data-testid="label-title">
|
||||||
Title
|
{{ _('post_form.label_title', current_locale) }}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -30,31 +30,31 @@
|
|||||||
name="title"
|
name="title"
|
||||||
class="input input-lg"
|
class="input input-lg"
|
||||||
value="{% if post %}{{ post.title }}{% endif %}"
|
value="{% if post %}{{ post.title }}{% endif %}"
|
||||||
placeholder="Enter post title"
|
placeholder="{{ _('post_form.placeholder_title', current_locale) }}"
|
||||||
required
|
required
|
||||||
data-testid="input-title"
|
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>
|
||||||
|
|
||||||
<div class="form-group" data-testid="form-group-content">
|
<div class="form-group" data-testid="form-group-content">
|
||||||
<label for="content" class="form-label form-label-required" data-testid="label-content">
|
<label for="content" class="form-label form-label-required" data-testid="label-content">
|
||||||
Content
|
{{ _('post_form.label_content', current_locale) }}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="content"
|
id="content"
|
||||||
name="content"
|
name="content"
|
||||||
rows="12"
|
rows="12"
|
||||||
placeholder="Write your post content here..."
|
placeholder="{{ _('post_form.placeholder_content', current_locale) }}"
|
||||||
required
|
required
|
||||||
data-testid="textarea-content"
|
data-testid="textarea-content"
|
||||||
>{% if post %}{{ post.content }}{% endif %}</textarea>
|
>{% 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>
|
||||||
|
|
||||||
<div class="form-group" data-testid="form-group-tags">
|
<div class="form-group" data-testid="form-group-tags">
|
||||||
<label for="tags" class="form-label" data-testid="label-tags">
|
<label for="tags" class="form-label" data-testid="label-tags">
|
||||||
Tags
|
{{ _('post_form.label_tags', current_locale) }}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -62,10 +62,10 @@
|
|||||||
name="tags"
|
name="tags"
|
||||||
class="input"
|
class="input"
|
||||||
value="{% if post %}{{ post.tags|join(', ') }}{% endif %}"
|
value="{% if post %}{{ post.tags|join(', ') }}{% endif %}"
|
||||||
placeholder="python, fastapi, tutorial"
|
placeholder="{{ _('post_form.placeholder_tags', current_locale) }}"
|
||||||
data-testid="input-tags"
|
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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -73,15 +73,15 @@
|
|||||||
<div class="card-footer" data-testid="form-post-footer">
|
<div class="card-footer" data-testid="form-post-footer">
|
||||||
<div class="flex justify-between items-center" data-testid="form-actions">
|
<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">
|
<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>
|
</a>
|
||||||
|
|
||||||
<div class="flex gap-2" data-testid="form-submit-actions">
|
<div class="flex gap-2" data-testid="form-submit-actions">
|
||||||
<button type="submit" name="action" value="draft" class="btn" data-testid="btn-save-draft">
|
<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>
|
||||||
<button type="submit" name="action" value="publish" class="btn btn-primary" data-testid="btn-publish-post">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
spellChecker: false,
|
spellChecker: false,
|
||||||
status: false,
|
status: false,
|
||||||
minHeight: '300px',
|
minHeight: '300px',
|
||||||
placeholder: 'Write your post content here...',
|
placeholder: '{{ _('post_form.placeholder_content', current_locale) }}',
|
||||||
toolbar: [
|
toolbar: [
|
||||||
'bold', 'italic', 'heading', '|',
|
'bold', 'italic', 'heading', '|',
|
||||||
'code', 'quote', 'unordered-list', 'ordered-list', '|',
|
'code', 'quote', 'unordered-list', 'ordered-list', '|',
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-header" data-testid="page-header-profile">
|
<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>
|
||||||
|
|
||||||
<div class="card" data-testid="profile-card">
|
<div class="card" data-testid="profile-card">
|
||||||
@@ -25,18 +25,18 @@
|
|||||||
|
|
||||||
<div class="profile-details" data-testid="profile-details">
|
<div class="profile-details" data-testid="profile-details">
|
||||||
<div class="profile-field" data-testid="profile-field-email">
|
<div class="profile-field" data-testid="profile-field-email">
|
||||||
<span class="profile-label" data-testid="profile-label-email">Email:</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 'Not provided' }}</span>
|
<span class="profile-value" data-testid="profile-value-email">{{ user.email or _('profile.not_provided', current_locale) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="profile-field" data-testid="profile-field-userid">
|
<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>
|
<span class="profile-value" data-testid="profile-value-userid">{{ user.user_id }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if user.first_name or user.last_name %}
|
{% if user.first_name or user.last_name %}
|
||||||
<div class="profile-field" data-testid="profile-field-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">
|
<span class="profile-value" data-testid="profile-value-name">
|
||||||
{{ user.first_name or '' }} {{ user.last_name or '' }}
|
{{ user.first_name or '' }} {{ user.last_name or '' }}
|
||||||
</span>
|
</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;">
|
<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"/>
|
<path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
Back to Home
|
{{ _('profile.back_home', current_locale) }}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{% if can_create %}
|
{% 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;">
|
<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"/>
|
<path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
New Post
|
{{ _('profile.new_post', current_locale) }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<footer class="site-footer" data-testid="site-footer">
|
<footer class="site-footer" data-testid="site-footer">
|
||||||
<div class="container" data-testid="footer-container">
|
<div class="container" data-testid="footer-container">
|
||||||
<div class="footer-copyright" data-testid="footer-copyright">
|
<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>
|
</div>
|
||||||
|
|
||||||
<nav class="footer-links" data-testid="footer-nav" aria-label="Footer navigation">
|
<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="/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">Privacy</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">Terms</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">API</a>
|
<a href="/api/docs" class="footer-link" data-testid="footer-link-api">{{ _('footer.api', current_locale) }}</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<rect width="32" height="32" rx="6" fill="var(--color-primary)"/>
|
<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"/>
|
<path d="M8 12h16M8 16h12M8 20h8" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span data-testid="logo-text">Blog</span>
|
<span data-testid="logo-text">{{ _('header.logo', current_locale) }}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{% include "partials/nav.html" %}
|
{% include "partials/nav.html" %}
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="mobile-menu-btn"
|
class="mobile-menu-btn"
|
||||||
data-testid="mobile-menu-toggle"
|
data-testid="mobile-menu-toggle"
|
||||||
aria-label="Toggle menu"
|
aria-label="{{ _('header.toggle_menu', current_locale) }}"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
aria-controls="mobile-nav"
|
aria-controls="mobile-nav"
|
||||||
>
|
>
|
||||||
@@ -30,8 +30,8 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="theme-toggle"
|
class="theme-toggle"
|
||||||
data-testid="theme-toggle"
|
data-testid="theme-toggle"
|
||||||
aria-label="Toggle dark mode"
|
aria-label="{{ _('header.toggle_theme', current_locale) }}"
|
||||||
title="Toggle dark mode"
|
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;">
|
<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"/>
|
<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"/>
|
<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>
|
</svg>
|
||||||
</button>
|
</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 %}
|
{% if user %}
|
||||||
<div class="user-menu" data-testid="user-menu">
|
<div class="user-menu" data-testid="user-menu">
|
||||||
<button
|
<button
|
||||||
@@ -63,14 +83,14 @@
|
|||||||
<circle cx="8" cy="6" r="3" stroke="currentColor" stroke-width="2"/>
|
<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"/>
|
<path d="M2 14c0-3 3-5 6-5s6 2 6 5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
Profile
|
{{ _('header.profile', current_locale) }}
|
||||||
</a>
|
</a>
|
||||||
{% if can_create %}
|
{% if can_create %}
|
||||||
<a href="/web/posts/new" class="user-menu-item" data-testid="user-menu-new-post">
|
<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;">
|
<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"/>
|
<path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
New Post
|
{{ _('header.new_post', current_locale) }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="user-menu-divider" data-testid="user-menu-divider"></div>
|
<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;">
|
<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"/>
|
<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>
|
</svg>
|
||||||
Sign Out
|
{{ _('header.sign_out', current_locale) }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="/auth/login" class="btn btn-primary btn-sm" data-testid="btn-login">
|
<a href="/auth/login" class="btn btn-primary btn-sm" data-testid="btn-login">
|
||||||
Sign In
|
{{ _('header.sign_in', current_locale) }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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) {
|
@media (min-width: 769px) {
|
||||||
.mobile-menu-btn {
|
.mobile-menu-btn {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -255,13 +353,13 @@
|
|||||||
<!-- Mobile Navigation Menu -->
|
<!-- Mobile Navigation Menu -->
|
||||||
<nav class="mobile-nav" id="mobile-nav" data-testid="mobile-nav" aria-label="Mobile navigation">
|
<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">
|
<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>
|
||||||
<a href="/web/posts" class="nav-link {% if active_page == 'posts' %}active{% endif %}" data-testid="mobile-nav-link-posts">
|
<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>
|
||||||
<a href="/web/about" class="nav-link {% if active_page == 'about' %}active{% endif %}" data-testid="mobile-nav-link-about">
|
<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>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<nav class="main-nav" data-testid="main-nav" aria-label="Main navigation">
|
<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">
|
<a href="/web/" class="nav-link {% if active_page == 'home' %}active{% endif %}" data-testid="nav-link-home">
|
||||||
Home
|
{{ _('nav.home', current_locale) }}
|
||||||
</a>
|
</a>
|
||||||
<a href="/web/posts" class="nav-link {% if active_page == 'posts' %}active{% endif %}" data-testid="nav-link-posts">
|
<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>
|
||||||
<a href="/web/about" class="nav-link {% if active_page == 'about' %}active{% endif %}" data-testid="nav-link-about">
|
<a href="/web/about" class="nav-link {% if active_page == 'about' %}active{% endif %}" data-testid="nav-link-about">
|
||||||
About
|
{{ _('nav.about', current_locale) }}
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ from fastapi import HTTPException, Request
|
|||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from app.infrastructure.i18n.translator import DEFAULT_LOCALE, _
|
||||||
from app.presentation.web.flash import FlashManager, get_flash_messages
|
from app.presentation.web.flash import FlashManager, get_flash_messages
|
||||||
|
|
||||||
templates = Jinja2Templates(directory="app/presentation/templates")
|
templates = Jinja2Templates(directory="app/presentation/templates")
|
||||||
|
templates.env.globals["_"] = _
|
||||||
|
|
||||||
|
|
||||||
async def setup_flash_manager(request: Request) -> None:
|
async def setup_flash_manager(request: Request) -> None:
|
||||||
@@ -55,6 +57,7 @@ def get_template_context(request: Request) -> dict[str, Any]:
|
|||||||
"user_role": user_role.value if user_role else None,
|
"user_role": user_role.value if user_role else None,
|
||||||
"can_create": can_create_post(user),
|
"can_create": can_create_post(user),
|
||||||
"flash_messages": get_flash_messages(request),
|
"flash_messages": get_flash_messages(request),
|
||||||
|
"current_locale": getattr(request.state, "locale", DEFAULT_LOCALE),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
72
app/presentation/web/locale.py
Normal file
72
app/presentation/web/locale.py
Normal 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)
|
||||||
@@ -33,6 +33,8 @@ from app.domain.exceptions import (
|
|||||||
)
|
)
|
||||||
from app.domain.roles import Role, get_effective_role
|
from app.domain.roles import Role, get_effective_role
|
||||||
from app.infrastructure.auth import TokenInfo
|
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 (
|
from app.presentation.web.deps import (
|
||||||
OptionalUserDep,
|
OptionalUserDep,
|
||||||
RequireUserDep,
|
RequireUserDep,
|
||||||
@@ -47,6 +49,22 @@ router = APIRouter(prefix="/web", tags=["web"], route_class=DishkaRoute)
|
|||||||
templates = Jinja2Templates(directory="app/presentation/templates")
|
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")
|
_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)
|
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.
|
"""Get base template context with user info and permissions.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user: Current user or None for guest.
|
user: Current user or None for guest.
|
||||||
|
current_locale: Active locale code for i18n.
|
||||||
|
|
||||||
Returns:
|
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)
|
user_role = _get_user_role(user)
|
||||||
|
|
||||||
@@ -100,6 +121,7 @@ def _get_base_context(user: TokenInfo | None) -> dict[str, Any]:
|
|||||||
"user": user,
|
"user": user,
|
||||||
"user_role": user_role.value if user_role else None,
|
"user_role": user_role.value if user_role else None,
|
||||||
"can_create": can_create_post(user),
|
"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
|
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(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"pages/index.html",
|
"pages/index.html",
|
||||||
@@ -212,7 +235,8 @@ async def list_posts(
|
|||||||
list_use_case, user, _DEFAULT_PAGE_SIZE, offset
|
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(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"pages/index.html",
|
"pages/index.html",
|
||||||
@@ -241,7 +265,8 @@ async def new_post_form(
|
|||||||
Returns:
|
Returns:
|
||||||
HTMLResponse with rendered post form template.
|
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(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
@@ -291,11 +316,12 @@ async def create_post(
|
|||||||
result = await create_use_case.execute(dto)
|
result = await create_use_case.execute(dto)
|
||||||
|
|
||||||
user_role = _get_user_role(user)
|
user_role = _get_user_role(user)
|
||||||
|
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
|
||||||
if action == "publish":
|
if action == "publish":
|
||||||
await publish_use_case.publish(result.id, user.user_id, user_role)
|
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:
|
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)
|
return RedirectResponse(url=f"/web/posts/{result.slug}", status_code=303)
|
||||||
except AlreadyExistsException as exc:
|
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):
|
if not post.published and not can_see_draft(user, post.author_id):
|
||||||
raise HTTPException(status_code=404, detail="Post not found")
|
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(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
@@ -379,7 +406,8 @@ async def edit_post_form(
|
|||||||
if not can_edit_post(user, post.author_id):
|
if not can_edit_post(user, post.author_id):
|
||||||
raise HTTPException(status_code=403, detail="Not authorized to edit this post")
|
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(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
@@ -447,7 +475,8 @@ async def update_post(
|
|||||||
if result.published:
|
if result.published:
|
||||||
await publish_use_case.unpublish(result.id, user.user_id, user_role)
|
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)
|
return RedirectResponse(url=f"/web/posts/{result.slug}", status_code=303)
|
||||||
except (AlreadyExistsException, ValidationException) as exc:
|
except (AlreadyExistsException, ValidationException) as exc:
|
||||||
flash(request, str(exc), "error")
|
flash(request, str(exc), "error")
|
||||||
@@ -485,9 +514,11 @@ async def delete_post(
|
|||||||
try:
|
try:
|
||||||
user_role = _get_user_role(user)
|
user_role = _get_user_role(user)
|
||||||
await delete_use_case.execute(post.id, user.user_id, user_role)
|
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:
|
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)
|
return RedirectResponse(url="/web/", status_code=303)
|
||||||
|
|
||||||
@@ -506,7 +537,8 @@ async def profile(
|
|||||||
Returns:
|
Returns:
|
||||||
HTMLResponse with rendered profile template.
|
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(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
@@ -532,7 +564,8 @@ async def about(
|
|||||||
Returns:
|
Returns:
|
||||||
HTMLResponse with rendered about page template.
|
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(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
@@ -542,3 +575,37 @@ async def about(
|
|||||||
"active_page": "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
|
||||||
|
|||||||
@@ -314,6 +314,113 @@ supports the domain and application layers.
|
|||||||
- **Expected:** Calls `session.rollback` once
|
- **Expected:** Calls `session.rollback` once
|
||||||
- **Last Verified:** 2026-05-07
|
- **Last Verified:** 2026-05-07
|
||||||
|
|
||||||
|
### i18n Localization
|
||||||
|
|
||||||
|
#### Translation Service
|
||||||
|
|
||||||
|
##### TC-UNIT-811: TranslationService — Existing key returns translation
|
||||||
|
- **Type:** Positive
|
||||||
|
- **Layer:** Unit
|
||||||
|
- **File:** `unit/infrastructure/test_i18n.py::TestTranslationService::test_get_text_returns_translation_for_existing_key`
|
||||||
|
- **Expected:** Returns correct localized string when key exists in requested locale
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
##### TC-UNIT-812: TranslationService — Fallback chain
|
||||||
|
- **Type:** Positive
|
||||||
|
- **Layer:** Unit
|
||||||
|
- **File:** `unit/infrastructure/test_i18n.py::TestTranslationService::{test_get_text_returns_english_fallback_for_missing_key, test_get_text_returns_key_when_neither_locale_nor_en_has_it, test_get_text_returns_english_fallback_for_unknown_locale}`
|
||||||
|
- **Preconditions:** Key missing in requested locale
|
||||||
|
- **Steps:** Call get_text with partially-available keys
|
||||||
|
- **Expected:**
|
||||||
|
- Falls back to English when key exists in `en` but not in requested locale
|
||||||
|
- Falls back to raw key when neither requested locale nor `en` has it
|
||||||
|
- Falls back to English when locale is completely unknown
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
##### TC-UNIT-813: TranslationService — English locale
|
||||||
|
- **Type:** Positive
|
||||||
|
- **Layer:** Unit
|
||||||
|
- **File:** `unit/infrastructure/test_i18n.py::TestTranslationService::test_get_text_returns_en_when_requested_locale_is_en`
|
||||||
|
- **Expected:** Returns English string when locale is explicitly `en`
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
##### TC-UNIT-814: TranslationService — Singleton pattern
|
||||||
|
- **Type:** Positive
|
||||||
|
- **Layer:** Unit
|
||||||
|
- **File:** `unit/infrastructure/test_i18n.py::TestTranslationService::{test_singleton_returns_same_instance, test_singleton_shares_translations}`
|
||||||
|
- **Expected:** Multiple calls to `get_instance()` return the same object with shared data
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
#### Convenience Function
|
||||||
|
|
||||||
|
##### TC-UNIT-815: Convenience _() function
|
||||||
|
- **Type:** Positive
|
||||||
|
- **Layer:** Unit
|
||||||
|
- **File:** `unit/infrastructure/test_i18n.py::TestConvenienceFunction::{test_convenience_function_returns_translation, test_convenience_function_defaults_to_english, test_convenience_function_returns_key_on_missing}`
|
||||||
|
- **Expected:**
|
||||||
|
- Returns translation when key and locale are given
|
||||||
|
- Defaults to `DEFAULT_LOCALE` ("en") when locale omitted
|
||||||
|
- Returns raw key when no translation exists
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
#### Locale Detection
|
||||||
|
|
||||||
|
##### TC-UNIT-816: Parse Accept-Language — Empty and single locale
|
||||||
|
- **Type:** Positive
|
||||||
|
- **Layer:** Unit
|
||||||
|
- **File:** `unit/infrastructure/test_i18n.py::TestParseAcceptLanguage::{test_empty_header_returns_empty_list, test_single_locale}`
|
||||||
|
- **Expected:**
|
||||||
|
- Empty header returns empty list
|
||||||
|
- Single locale returns single-element list
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
##### TC-UNIT-817: Parse Accept-Language — Multi-locale with quality
|
||||||
|
- **Type:** Positive
|
||||||
|
- **Layer:** Unit
|
||||||
|
- **File:** `unit/infrastructure/test_i18n.py::TestParseAcceptLanguage::test_multiple_locales_with_quality`
|
||||||
|
- **Expected:** Locales sorted by descending q-value, quality values stripped
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
##### TC-UNIT-818: Parse Accept-Language — Region codes and complex input
|
||||||
|
- **Type:** Positive
|
||||||
|
- **Layer:** Unit
|
||||||
|
- **File:** `unit/infrastructure/test_i18n.py::TestParseAcceptLanguage::{test_region_code_strips_to_base_language, test_complex_header, test_whitespace_around_locales}`
|
||||||
|
- **Expected:**
|
||||||
|
- `fr-FR` normalised to `fr`
|
||||||
|
- Realistic headers with region codes parsed correctly
|
||||||
|
- Whitespace around locale codes handled gracefully
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
##### TC-UNIT-819: Get best locale — Cookie priority
|
||||||
|
- **Type:** Positive
|
||||||
|
- **Layer:** Unit
|
||||||
|
- **File:** `unit/infrastructure/test_i18n.py::TestGetBestLocale::{test_cookie_takes_priority, test_invalid_cookie_falls_back_to_header}`
|
||||||
|
- **Expected:**
|
||||||
|
- Cookie value used when it is a supported locale
|
||||||
|
- Unsupported locale in cookie falls back to Accept-Language header
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
##### TC-UNIT-820: Get best locale — Header fallback
|
||||||
|
- **Type:** Positive
|
||||||
|
- **Layer:** Unit
|
||||||
|
- **File:** `unit/infrastructure/test_i18n.py::TestGetBestLocale::{test_no_cookie_uses_accept_language, test_no_cookie_no_header_returns_default, test_accept_language_unsupported_returns_default, test_missing_cookie_key_uses_header}`
|
||||||
|
- **Expected:**
|
||||||
|
- Accept-Language used when no cookie present
|
||||||
|
- Default locale returned when neither cookie nor header matches
|
||||||
|
- Unsupported language in header falls back to default
|
||||||
|
- Absent `locale` cookie key treated as no preference
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
##### TC-UNIT-821: Setup locale manager — Middleware helper
|
||||||
|
- **Type:** Positive
|
||||||
|
- **Layer:** Unit
|
||||||
|
- **File:** `unit/infrastructure/test_i18n.py::TestSetupLocaleManager::{test_sets_locale_on_request_state, test_does_not_override_existing_locale, test_default_locale_when_no_match}`
|
||||||
|
- **Expected:**
|
||||||
|
- Sets `request.state.locale` from best-match locale
|
||||||
|
- Does not override an already-set locale
|
||||||
|
- Falls back to default when nothing matches
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
## Coverage Summary
|
## Coverage Summary
|
||||||
|
|
||||||
| Component | Cases | Status |
|
| Component | Cases | Status |
|
||||||
@@ -322,6 +429,7 @@ supports the domain and application layers.
|
|||||||
| Settings & Config | 19 | ✅ Defaults, overrides, validation, env checks |
|
| Settings & Config | 19 | ✅ Defaults, overrides, validation, env checks |
|
||||||
| Keycloak Auth Client | 16 | ✅ Token introspection, userinfo, caching, errors |
|
| Keycloak Auth Client | 16 | ✅ Token introspection, userinfo, caching, errors |
|
||||||
| Transaction Manager | 2 | ⚠️ Only commit/rollback; missing nested tx, error handling |
|
| Transaction Manager | 2 | ⚠️ Only commit/rollback; missing nested tx, error handling |
|
||||||
|
| i18n Localization | 11 | ✅ Translation service, locale detection, middleware helper |
|
||||||
|
|
||||||
## Gaps (Not Yet Covered)
|
## Gaps (Not Yet Covered)
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ adding new tests.
|
|||||||
| Pagination | 40% | — | — | 60% | P1 | ⚠️ Partial |
|
| Pagination | 40% | — | — | 60% | P1 | ⚠️ Partial |
|
||||||
| Post Edit via Web | — | — | — | 40% | P1 | ⚠️ Partial |
|
| Post Edit via Web | — | — | — | 40% | P1 | ⚠️ Partial |
|
||||||
| Post Delete via Web | — | — | — | 40% | P1 | ⚠️ Partial |
|
| Post Delete via Web | — | — | — | 40% | P1 | ⚠️ Partial |
|
||||||
|
| i18n Localization | 100% | — | — | — | P1 | ✅ Active |
|
||||||
|
|
||||||
Legend: ✅ Covered / ⚠️ Partial / ❌ Missing / — Not Applicable
|
Legend: ✅ Covered / ⚠️ Partial / ❌ Missing / — Not Applicable
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ Legend: ✅ Covered / ⚠️ Partial / ❌ Missing / — Not Applicable
|
|||||||
| RBAC & Access Control | [FEATURE_RBAC.md](FEATURE_RBAC.md) |
|
| RBAC & Access Control | [FEATURE_RBAC.md](FEATURE_RBAC.md) |
|
||||||
| Domain Foundation | [FEATURE_DOMAIN_FOUNDATION.md](FEATURE_DOMAIN_FOUNDATION.md) |
|
| Domain Foundation | [FEATURE_DOMAIN_FOUNDATION.md](FEATURE_DOMAIN_FOUNDATION.md) |
|
||||||
| Infrastructure & Bootstrap | [FEATURE_INFRASTRUCTURE.md](FEATURE_INFRASTRUCTURE.md) |
|
| Infrastructure & Bootstrap | [FEATURE_INFRASTRUCTURE.md](FEATURE_INFRASTRUCTURE.md) |
|
||||||
|
| i18n Localization | [FEATURE_INFRASTRUCTURE.md](FEATURE_INFRASTRUCTURE.md) |
|
||||||
|
|
||||||
## Test Naming Convention
|
## Test Naming Convention
|
||||||
|
|
||||||
|
|||||||
196
tests/unit/infrastructure/test_i18n.py
Normal file
196
tests/unit/infrastructure/test_i18n.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
"""Tests for i18n infrastructure.
|
||||||
|
|
||||||
|
Covers TranslationService, locale detection helpers, and the
|
||||||
|
convenience _() function.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.infrastructure.i18n.translator import (
|
||||||
|
DEFAULT_LOCALE,
|
||||||
|
TranslationService,
|
||||||
|
_,
|
||||||
|
)
|
||||||
|
from app.presentation.web.locale import (
|
||||||
|
_get_best_locale,
|
||||||
|
_parse_accept_language,
|
||||||
|
setup_locale_manager,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_mock_request(
|
||||||
|
cookies: dict[str, str] | None = None,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
) -> MagicMock:
|
||||||
|
"""Create a mock FastAPI Request with controlled state, cookies and headers."""
|
||||||
|
request = MagicMock()
|
||||||
|
request.state = SimpleNamespace()
|
||||||
|
request.cookies = cookies or {}
|
||||||
|
request.headers = headers or {}
|
||||||
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
class TestTranslationService:
|
||||||
|
"""Test TranslationService get_text resolution and fallback chain."""
|
||||||
|
|
||||||
|
def test_get_text_returns_translation_for_existing_key(self) -> None:
|
||||||
|
"""Test get_text returns the correct translation for a known key."""
|
||||||
|
ts = TranslationService()
|
||||||
|
result = ts.get_text("nav.home", "ru")
|
||||||
|
assert result == "Главная"
|
||||||
|
|
||||||
|
def test_get_text_returns_english_fallback_for_missing_key(self) -> None:
|
||||||
|
"""Test get_text falls back to English when locale lacks the key."""
|
||||||
|
ts = TranslationService()
|
||||||
|
result = ts.get_text("nav.home", "fr")
|
||||||
|
assert result == "Accueil"
|
||||||
|
|
||||||
|
def test_get_text_returns_key_when_neither_locale_nor_en_has_it(self) -> None:
|
||||||
|
"""Test get_text returns the key itself when no translation exists anywhere."""
|
||||||
|
ts = TranslationService()
|
||||||
|
result = ts.get_text("nonexistent.key", "de")
|
||||||
|
assert result == "nonexistent.key"
|
||||||
|
|
||||||
|
def test_get_text_returns_english_fallback_for_unknown_locale(self) -> None:
|
||||||
|
"""Test get_text returns English when the requested locale does not exist."""
|
||||||
|
ts = TranslationService()
|
||||||
|
result = ts.get_text("nav.home", "zz")
|
||||||
|
assert result == "Home"
|
||||||
|
|
||||||
|
def test_get_text_returns_en_when_requested_locale_is_en(self) -> None:
|
||||||
|
"""Test get_text returns English string when locale is en."""
|
||||||
|
ts = TranslationService()
|
||||||
|
result = ts.get_text("nav.home", "en")
|
||||||
|
assert result == "Home"
|
||||||
|
|
||||||
|
def test_get_text_returns_en_fallback_when_requested_locale_missing_key(self) -> None:
|
||||||
|
"""Test get_text falls back to English when locale is unknown and key exists in en."""
|
||||||
|
ts = TranslationService()
|
||||||
|
result = ts.get_text("about.signed_in", "zz")
|
||||||
|
assert result == "Signed in as {username}."
|
||||||
|
|
||||||
|
def test_singleton_returns_same_instance(self) -> None:
|
||||||
|
"""Test TranslationService.get_instance always returns the same instance."""
|
||||||
|
instance_a = TranslationService.get_instance()
|
||||||
|
instance_b = TranslationService.get_instance()
|
||||||
|
assert instance_a is instance_b
|
||||||
|
|
||||||
|
def test_singleton_shares_translations(self) -> None:
|
||||||
|
"""Test that multiple calls via singleton share the same data."""
|
||||||
|
instance_a = TranslationService.get_instance()
|
||||||
|
instance_b = TranslationService.get_instance()
|
||||||
|
assert instance_a.translations is instance_b.translations
|
||||||
|
|
||||||
|
|
||||||
|
class TestConvenienceFunction:
|
||||||
|
"""Test the module-level _() convenience function."""
|
||||||
|
|
||||||
|
def test_convenience_function_returns_translation(self) -> None:
|
||||||
|
"""Test _() returns correct translation for a given key."""
|
||||||
|
result = _("header.logo", "de")
|
||||||
|
assert result == "Blog"
|
||||||
|
|
||||||
|
def test_convenience_function_defaults_to_english(self) -> None:
|
||||||
|
"""Test _() uses DEFAULT_LOCALE when no locale is given."""
|
||||||
|
result = _("nav.posts")
|
||||||
|
assert result == "Posts"
|
||||||
|
assert DEFAULT_LOCALE == "en"
|
||||||
|
|
||||||
|
def test_convenience_function_returns_key_on_missing(self) -> None:
|
||||||
|
"""Test _() returns key when translation does not exist."""
|
||||||
|
result = _("utterly.fake.key", "fr")
|
||||||
|
assert result == "utterly.fake.key"
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseAcceptLanguage:
|
||||||
|
"""Test the Accept-Language header parser."""
|
||||||
|
|
||||||
|
def test_empty_header_returns_empty_list(self) -> None:
|
||||||
|
"""Test empty Accept-Language returns empty list."""
|
||||||
|
assert _parse_accept_language("") == []
|
||||||
|
|
||||||
|
def test_single_locale(self) -> None:
|
||||||
|
"""Test a single locale code is returned as a single-element list."""
|
||||||
|
assert _parse_accept_language("fr") == ["fr"]
|
||||||
|
|
||||||
|
def test_multiple_locales_with_quality(self) -> None:
|
||||||
|
"""Test multiple locales with q-values are returned in order."""
|
||||||
|
result = _parse_accept_language("fr, en;q=0.9, de;q=0.8")
|
||||||
|
assert result == ["fr", "en", "de"]
|
||||||
|
|
||||||
|
def test_region_code_strips_to_base_language(self) -> None:
|
||||||
|
"""Test fr-FR is normalised to fr."""
|
||||||
|
assert _parse_accept_language("fr-FR") == ["fr"]
|
||||||
|
|
||||||
|
def test_complex_header(self) -> None:
|
||||||
|
"""Test a realistic Accept-Language header with multiple locales."""
|
||||||
|
result = _parse_accept_language("ru-RU, ru;q=0.9, en;q=0.5")
|
||||||
|
assert result == ["ru", "ru", "en"]
|
||||||
|
|
||||||
|
def test_whitespace_around_locales(self) -> None:
|
||||||
|
"""Test whitespace around locale codes is handled gracefully."""
|
||||||
|
result = _parse_accept_language(" en , fr;q=0.5 ")
|
||||||
|
assert result == ["en", "fr"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetBestLocale:
|
||||||
|
"""Test locale detection from cookie and Accept-Language header."""
|
||||||
|
|
||||||
|
def test_cookie_takes_priority(self) -> None:
|
||||||
|
"""Test cookie value is used when it is a supported locale."""
|
||||||
|
request = _make_mock_request(cookies={"locale": "de"}, headers={"accept-language": "ru"})
|
||||||
|
assert _get_best_locale(request) == "de"
|
||||||
|
|
||||||
|
def test_invalid_cookie_falls_back_to_header(self) -> None:
|
||||||
|
"""Test unsupported locale in cookie falls back to Accept-Language."""
|
||||||
|
request = _make_mock_request(cookies={"locale": "zz"}, headers={"accept-language": "fr"})
|
||||||
|
assert _get_best_locale(request) == "fr"
|
||||||
|
|
||||||
|
def test_no_cookie_uses_accept_language(self) -> None:
|
||||||
|
"""Test Accept-Language is used when no cookie is present."""
|
||||||
|
request = _make_mock_request(headers={"accept-language": "de"})
|
||||||
|
assert _get_best_locale(request) == "de"
|
||||||
|
|
||||||
|
def test_no_cookie_no_header_returns_default(self) -> None:
|
||||||
|
"""Test default locale returned when neither cookie nor header matches."""
|
||||||
|
request = _make_mock_request()
|
||||||
|
assert _get_best_locale(request) == DEFAULT_LOCALE
|
||||||
|
|
||||||
|
def test_accept_language_unsupported_returns_default(self) -> None:
|
||||||
|
"""Test unsupported language in header falls back to default."""
|
||||||
|
request = _make_mock_request(headers={"accept-language": "zh"})
|
||||||
|
assert _get_best_locale(request) == DEFAULT_LOCALE
|
||||||
|
|
||||||
|
def test_missing_cookie_key_uses_header(self) -> None:
|
||||||
|
"""Test absent locale cookie is treated as no preference."""
|
||||||
|
request = _make_mock_request(cookies={"other": "val"}, headers={"accept-language": "ru"})
|
||||||
|
assert _get_best_locale(request) == "ru"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetupLocaleManager:
|
||||||
|
"""Test the middleware helper that sets locale on request state."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sets_locale_on_request_state(self) -> None:
|
||||||
|
"""Test setup_locale_manager sets locale when not already present."""
|
||||||
|
request = _make_mock_request(headers={"accept-language": "fr"})
|
||||||
|
await setup_locale_manager(request)
|
||||||
|
assert request.state.locale == "fr"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_does_not_override_existing_locale(self) -> None:
|
||||||
|
"""Test setup_locale_manager does not override an already-set locale."""
|
||||||
|
request = _make_mock_request(headers={"accept-language": "fr"})
|
||||||
|
request.state.locale = "de"
|
||||||
|
await setup_locale_manager(request)
|
||||||
|
assert request.state.locale == "de"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_default_locale_when_no_match(self) -> None:
|
||||||
|
"""Test setup_locale_manager uses default when nothing matches."""
|
||||||
|
request = _make_mock_request()
|
||||||
|
await setup_locale_manager(request)
|
||||||
|
assert request.state.locale == DEFAULT_LOCALE
|
||||||
Reference in New Issue
Block a user