feat(i18n): add browser-language localization with Jinja2 _() and locale middleware
Some checks failed
ci/woodpecker/pr/pipeline Pipeline failed
Some checks failed
ci/woodpecker/pr/pipeline Pipeline failed
Add i18n support to the blog web UI with 4 languages (en/ru/fr/de),
80 translation keys, automatic Accept-Language detection, persistent
locale cookie, and a language switcher dropdown in the header.
- Infrastructure: TranslationService, translation dicts, convenience _()
- Presentation: locale middleware, /web/lang/{locale} switcher route
- Templates: all 9 templates use {{ _(key, current_locale) }}
- Tests: 26 tests across TranslationService, locale detection helpers
- Docs: TEST_MODEL.md and FEATURE_INFRASTRUCTURE.md updated with TC-UNIT-811-821
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-testid="html-root">
|
||||
<html lang="{{ current_locale }}" data-testid="html-root">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="{% block meta_description %}Blog - A modern blogging platform built with FastAPI{% endblock %}">
|
||||
<meta name="keywords" content="{% block meta_keywords %}blog, articles, posts, writing{% endblock %}">
|
||||
<meta name="author" content="{% block meta_author %}Blog Team{% endblock %}">
|
||||
<meta name="description" content="{% block meta_description %}{{ _('base.meta_description', current_locale) }}{% endblock %}">
|
||||
<meta name="keywords" content="{% block meta_keywords %}{{ _('base.meta_keywords', current_locale) }}{% endblock %}">
|
||||
<meta name="author" content="{% block meta_author %}{{ _('base.meta_author', current_locale) }}{% endblock %}">
|
||||
<meta name="robots" content="{% block meta_robots %}index, follow{% endblock %}">
|
||||
|
||||
<!-- Canonical URL -->
|
||||
@@ -30,7 +30,7 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/static/images/favicon.svg">
|
||||
<link rel="alternate icon" href="/static/images/favicon.ico">
|
||||
|
||||
<title data-testid="page-title">{% block title %}Blog{% endblock %}</title>
|
||||
<title data-testid="page-title">{% block title %}{{ _('base.default_title', current_locale) }}{% endblock %}</title>
|
||||
|
||||
<link rel="stylesheet" href="/static/css/themes/theme-light.css" data-testid="theme-light-stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/themes/theme-dark.css" data-testid="theme-dark-stylesheet">
|
||||
@@ -51,7 +51,7 @@
|
||||
{% for msg in flash_messages %}
|
||||
<div class="flash-message flash-{{ msg.category }}" data-testid="flash-message-{{ msg.category }}" role="alert">
|
||||
<span class="flash-text" data-testid="flash-text">{{ msg.message }}</span>
|
||||
<button type="button" class="flash-close" data-testid="flash-close" aria-label="Close message">×</button>
|
||||
<button type="button" class="flash-close" data-testid="flash-close" aria-label="{{ _('base.close_message', current_locale) }}">×</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}About - Blog{% endblock %}
|
||||
{% block meta_description %}A modern blog built with FastAPI and DDD architecture.{% endblock %}
|
||||
{% block title %}{{ _('about.title', current_locale) }} - {{ _('base.default_title', current_locale) }}{% endblock %}
|
||||
{% block meta_description %}{{ _('about.description', current_locale) }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header" data-testid="page-header-about">
|
||||
<h1 class="page-title" data-testid="page-title-about">About</h1>
|
||||
<h1 class="page-title" data-testid="page-title-about">{{ _('about.page_title', current_locale) }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="card" data-testid="about-card">
|
||||
<div class="card-body" data-testid="about-card-body">
|
||||
<p data-testid="about-description">
|
||||
A modern blog built with FastAPI and Domain-Driven Design architecture.
|
||||
{{ _('about.description', current_locale) }}
|
||||
</p>
|
||||
|
||||
<div class="divider" data-testid="about-divider"></div>
|
||||
|
||||
<p data-testid="about-user">
|
||||
{% if user %}
|
||||
Signed in as <strong>{{ user.username }}</strong>.
|
||||
{{ _('about.signed_in', current_locale).format(username=user.username) }}
|
||||
{% else %}
|
||||
You are browsing as a guest.
|
||||
{{ _('about.browsing_guest', current_locale) }}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
@@ -30,7 +30,7 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
|
||||
<path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Back to Home
|
||||
{{ _('about.back_home', current_locale) }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Blog - Home{% endblock %}
|
||||
{% block meta_description %}Discover stories, thinking, and expertise from writers on any topic. A modern blog built with FastAPI.{% endblock %}
|
||||
{% block meta_keywords %}blog, articles, posts, writing, fastapi, python{% endblock %}
|
||||
{% block title %}{{ _('home.title', current_locale) }}{% endblock %}
|
||||
{% block meta_description %}{{ _('home.meta_description', current_locale) }}{% endblock %}
|
||||
{% block meta_keywords %}{{ _('home.meta_keywords', current_locale) }}{% endblock %}
|
||||
|
||||
{% block og_type %}website{% endblock %}
|
||||
{% block og_title %}Blog - Home{% endblock %}
|
||||
{% block og_description %}Discover stories, thinking, and expertise from writers on any topic.{% endblock %}
|
||||
{% block og_title %}{{ _('home.title', current_locale) }}{% endblock %}
|
||||
{% block og_description %}{{ _('home.meta_description', current_locale) }}{% endblock %}
|
||||
|
||||
{% block twitter_title %}Blog - Home{% endblock %}
|
||||
{% block twitter_description %}Discover stories, thinking, and expertise from writers on any topic.{% endblock %}
|
||||
{% block twitter_title %}{{ _('home.title', current_locale) }}{% endblock %}
|
||||
{% block twitter_description %}{{ _('home.meta_description', current_locale) }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-header" data-testid="page-header-home">
|
||||
<div class="page-header-flex">
|
||||
<div data-testid="page-header-content">
|
||||
<h1 class="page-title" data-testid="page-title-home">Latest Posts</h1>
|
||||
<p class="page-subtitle" data-testid="page-subtitle-home">Discover stories, thinking, and expertise from writers on any topic.</p>
|
||||
<h1 class="page-title" data-testid="page-title-home">{{ _('home.page_title', current_locale) }}</h1>
|
||||
<p class="page-subtitle" data-testid="page-subtitle-home">{{ _('home.page_subtitle', current_locale) }}</p>
|
||||
</div>
|
||||
{% if can_create %}
|
||||
<a href="/web/posts/new" class="btn btn-primary" data-testid="btn-create-post-header">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
|
||||
<path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Write a Post
|
||||
{{ _('home.write_post', current_locale) }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -38,9 +38,9 @@
|
||||
<a href="/web/posts/{{ post.slug }}" data-testid="post-title-link-{{ post.id }}">{{ post.title }}</a>
|
||||
</h2>
|
||||
{% if post.published %}
|
||||
<span class="badge badge-success" data-testid="post-status-{{ post.id }}">Published</span>
|
||||
<span class="badge badge-success" data-testid="post-status-{{ post.id }}">{{ _('home.status_published', current_locale) }}</span>
|
||||
{% else %}
|
||||
<span class="badge" data-testid="post-status-{{ post.id }}">Draft</span>
|
||||
<span class="badge" data-testid="post-status-{{ post.id }}">{{ _('home.status_draft', current_locale) }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
<a href="/web/posts/{{ post.slug }}" class="btn btn-sm" data-testid="btn-read-more-{{ post.id }}">
|
||||
Read more
|
||||
{{ _('home.read_more', current_locale) }}
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-left: 0.25rem;">
|
||||
<path d="M6 12L10 8L6 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
@@ -77,26 +77,26 @@
|
||||
|
||||
<nav class="pagination" data-testid="pagination" aria-label="Pagination">
|
||||
{% if has_prev %}
|
||||
<a href="{{ request.url.path }}?page={{ current_page - 1 }}" class="pagination-item" data-testid="pagination-prev">Previous</a>
|
||||
<a href="{{ request.url.path }}?page={{ current_page - 1 }}" class="pagination-item" data-testid="pagination-prev">{{ _('home.pagination_previous', current_locale) }}</a>
|
||||
{% else %}
|
||||
<span class="pagination-item disabled" data-testid="pagination-prev">Previous</span>
|
||||
<span class="pagination-item disabled" data-testid="pagination-prev">{{ _('home.pagination_previous', current_locale) }}</span>
|
||||
{% endif %}
|
||||
|
||||
<span class="pagination-item active" data-testid="pagination-current">{{ current_page }}</span>
|
||||
|
||||
{% if has_next %}
|
||||
<a href="{{ request.url.path }}?page={{ current_page + 1 }}" class="pagination-item" data-testid="pagination-next">Next</a>
|
||||
<a href="{{ request.url.path }}?page={{ current_page + 1 }}" class="pagination-item" data-testid="pagination-next">{{ _('home.pagination_next', current_locale) }}</a>
|
||||
{% else %}
|
||||
<span class="pagination-item disabled" data-testid="pagination-next">Next</span>
|
||||
<span class="pagination-item disabled" data-testid="pagination-next">{{ _('home.pagination_next', current_locale) }}</span>
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
{% else %}
|
||||
<div class="empty-state" data-testid="empty-state">
|
||||
<div class="empty-state-icon" data-testid="empty-state-icon">📝</div>
|
||||
<h3 data-testid="empty-state-title">No posts yet</h3>
|
||||
<p data-testid="empty-state-description">Be the first to write a post!</p>
|
||||
<a href="/web/posts/new" class="btn btn-primary" data-testid="btn-create-first-post">Create your first post</a>
|
||||
<h3 data-testid="empty-state-title">{{ _('home.empty_title', current_locale) }}</h3>
|
||||
<p data-testid="empty-state-description">{{ _('home.empty_description', current_locale) }}</p>
|
||||
<a href="/web/posts/new" class="btn btn-primary" data-testid="btn-create-first-post">{{ _('home.empty_action', current_locale) }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -29,9 +29,9 @@
|
||||
{{ post.created_at.strftime('%B %d, %Y') }}
|
||||
</span>
|
||||
{% if post.published %}
|
||||
<span class="badge badge-success" data-testid="post-detail-status">Published</span>
|
||||
<span class="badge badge-success" data-testid="post-detail-status">{{ _('post.status_published', current_locale) }}</span>
|
||||
{% else %}
|
||||
<span class="badge" data-testid="post-detail-status">Draft</span>
|
||||
<span class="badge" data-testid="post-detail-status">{{ _('post.status_draft', current_locale) }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
@@ -54,26 +54,26 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
|
||||
<path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Back to posts
|
||||
{{ _('post.back_to_posts', current_locale) }}
|
||||
</a>
|
||||
|
||||
{% if can_edit or can_delete %}
|
||||
<div class="flex gap-2" data-testid="post-detail-edit-actions">
|
||||
{% if can_edit %}
|
||||
<a href="/web/posts/{{ post.slug }}/edit" class="btn" data-testid="btn-edit-post">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
|
||||
<path d="M11 2L14 5M2 14L3 10L12 1L15 4L6 13L2 14Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Edit
|
||||
</a>
|
||||
<a href="/web/posts/{{ post.slug }}/edit" class="btn" data-testid="btn-edit-post">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
|
||||
<path d="M11 2L14 5M2 14L3 10L12 1L15 4L6 13L2 14Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
{{ _('post.edit', current_locale) }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if can_delete %}
|
||||
<form action="/web/posts/{{ post.slug }}/delete" method="POST" style="display: inline;" data-testid="form-delete-post">
|
||||
<button type="submit" class="btn btn-danger" data-testid="btn-delete-post" onclick="return confirm('Are you sure you want to delete this post?');">
|
||||
<button type="submit" class="btn btn-danger" data-testid="btn-delete-post" onclick="return confirm('{{ _('post.delete_confirm', current_locale) }}');">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
|
||||
<path d="M2 4h12M6 4V2a2 2 0 012-2h0a2 2 0 012 2v2m3 0v10a2 2 0 01-2 2H5a2 2 0 01-2-2V4h9z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Delete
|
||||
{{ _('post.delete', current_locale) }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% if is_edit %}Edit Post{% else %}New Post{% endif %} - Blog{% endblock %}
|
||||
{% block title %}{% if is_edit %}{{ _('post_form.title_edit', current_locale) }}{% else %}{{ _('post_form.title_new', current_locale) }}{% endif %} - {{ _('base.default_title', current_locale) }}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="/static/css/easymde.min.css" data-testid="easymde-stylesheet">
|
||||
@@ -9,7 +9,7 @@
|
||||
{% block content %}
|
||||
<section class="page-header" data-testid="page-header-form">
|
||||
<h1 class="page-title" data-testid="page-title-form">
|
||||
{% if is_edit %}Edit Post{% else %}Create New Post{% endif %}
|
||||
{% if is_edit %}{{ _('post_form.page_title_edit', current_locale) }}{% else %}{{ _('post_form.page_title_new', current_locale) }}{% endif %}
|
||||
</h1>
|
||||
</section>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<div class="card-body" data-testid="form-post-body">
|
||||
<div class="form-group" data-testid="form-group-title">
|
||||
<label for="title" class="form-label form-label-required" data-testid="label-title">
|
||||
Title
|
||||
{{ _('post_form.label_title', current_locale) }}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -30,31 +30,31 @@
|
||||
name="title"
|
||||
class="input input-lg"
|
||||
value="{% if post %}{{ post.title }}{% endif %}"
|
||||
placeholder="Enter post title"
|
||||
placeholder="{{ _('post_form.placeholder_title', current_locale) }}"
|
||||
required
|
||||
data-testid="input-title"
|
||||
>
|
||||
<span class="form-hint" data-testid="hint-title">A catchy title for your post</span>
|
||||
<span class="form-hint" data-testid="hint-title">{{ _('post_form.hint_title', current_locale) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group" data-testid="form-group-content">
|
||||
<label for="content" class="form-label form-label-required" data-testid="label-content">
|
||||
Content
|
||||
{{ _('post_form.label_content', current_locale) }}
|
||||
</label>
|
||||
<textarea
|
||||
id="content"
|
||||
name="content"
|
||||
rows="12"
|
||||
placeholder="Write your post content here..."
|
||||
placeholder="{{ _('post_form.placeholder_content', current_locale) }}"
|
||||
required
|
||||
data-testid="textarea-content"
|
||||
>{% if post %}{{ post.content }}{% endif %}</textarea>
|
||||
<span class="form-hint" data-testid="hint-content">The main content of your post. Markdown is supported.</span>
|
||||
<span class="form-hint" data-testid="hint-content">{{ _('post_form.hint_content', current_locale) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group" data-testid="form-group-tags">
|
||||
<label for="tags" class="form-label" data-testid="label-tags">
|
||||
Tags
|
||||
{{ _('post_form.label_tags', current_locale) }}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -62,10 +62,10 @@
|
||||
name="tags"
|
||||
class="input"
|
||||
value="{% if post %}{{ post.tags|join(', ') }}{% endif %}"
|
||||
placeholder="python, fastapi, tutorial"
|
||||
placeholder="{{ _('post_form.placeholder_tags', current_locale) }}"
|
||||
data-testid="input-tags"
|
||||
>
|
||||
<span class="form-hint" data-testid="hint-tags">Comma-separated list of tags</span>
|
||||
<span class="form-hint" data-testid="hint-tags">{{ _('post_form.hint_tags', current_locale) }}</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -73,15 +73,15 @@
|
||||
<div class="card-footer" data-testid="form-post-footer">
|
||||
<div class="flex justify-between items-center" data-testid="form-actions">
|
||||
<a href="{% if is_edit %}/web/posts/{{ post.slug }}{% else %}/web/{% endif %}" class="btn" data-testid="btn-cancel">
|
||||
Cancel
|
||||
{{ _('post_form.cancel', current_locale) }}
|
||||
</a>
|
||||
|
||||
<div class="flex gap-2" data-testid="form-submit-actions">
|
||||
<button type="submit" name="action" value="draft" class="btn" data-testid="btn-save-draft">
|
||||
Save as Draft
|
||||
{{ _('post_form.save_draft', current_locale) }}
|
||||
</button>
|
||||
<button type="submit" name="action" value="publish" class="btn btn-primary" data-testid="btn-publish-post">
|
||||
{% if is_edit %}Update Post{% else %}Publish Post{% endif %}
|
||||
{% if is_edit %}{{ _('post_form.update_post', current_locale) }}{% else %}{{ _('post_form.publish_post', current_locale) }}{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -99,7 +99,7 @@
|
||||
spellChecker: false,
|
||||
status: false,
|
||||
minHeight: '300px',
|
||||
placeholder: 'Write your post content here...',
|
||||
placeholder: '{{ _('post_form.placeholder_content', current_locale) }}',
|
||||
toolbar: [
|
||||
'bold', 'italic', 'heading', '|',
|
||||
'code', 'quote', 'unordered-list', 'ordered-list', '|',
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header" data-testid="page-header-profile">
|
||||
<h1 class="page-title" data-testid="page-title-profile">User Profile</h1>
|
||||
<h1 class="page-title" data-testid="page-title-profile">{{ _('profile.title', current_locale) }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="card" data-testid="profile-card">
|
||||
@@ -25,18 +25,18 @@
|
||||
|
||||
<div class="profile-details" data-testid="profile-details">
|
||||
<div class="profile-field" data-testid="profile-field-email">
|
||||
<span class="profile-label" data-testid="profile-label-email">Email:</span>
|
||||
<span class="profile-value" data-testid="profile-value-email">{{ user.email or 'Not provided' }}</span>
|
||||
<span class="profile-label" data-testid="profile-label-email">{{ _('profile.email', current_locale) }}</span>
|
||||
<span class="profile-value" data-testid="profile-value-email">{{ user.email or _('profile.not_provided', current_locale) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="profile-field" data-testid="profile-field-userid">
|
||||
<span class="profile-label" data-testid="profile-label-userid">User ID:</span>
|
||||
<span class="profile-label" data-testid="profile-label-userid">{{ _('profile.user_id', current_locale) }}</span>
|
||||
<span class="profile-value" data-testid="profile-value-userid">{{ user.user_id }}</span>
|
||||
</div>
|
||||
|
||||
{% if user.first_name or user.last_name %}
|
||||
<div class="profile-field" data-testid="profile-field-name">
|
||||
<span class="profile-label" data-testid="profile-label-name">Name:</span>
|
||||
<span class="profile-label" data-testid="profile-label-name">{{ _('profile.name', current_locale) }}</span>
|
||||
<span class="profile-value" data-testid="profile-value-name">
|
||||
{{ user.first_name or '' }} {{ user.last_name or '' }}
|
||||
</span>
|
||||
@@ -51,7 +51,7 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
|
||||
<path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Back to Home
|
||||
{{ _('profile.back_home', current_locale) }}
|
||||
</a>
|
||||
|
||||
{% if can_create %}
|
||||
@@ -59,7 +59,7 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
|
||||
<path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
New Post
|
||||
{{ _('profile.new_post', current_locale) }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<footer class="site-footer" data-testid="site-footer">
|
||||
<div class="container" data-testid="footer-container">
|
||||
<div class="footer-copyright" data-testid="footer-copyright">
|
||||
<span data-testid="copyright-text">© 2026 Blog. All rights reserved.</span>
|
||||
<span data-testid="copyright-text">{{ _('footer.copyright', current_locale) }}</span>
|
||||
</div>
|
||||
|
||||
<nav class="footer-links" data-testid="footer-nav" aria-label="Footer navigation">
|
||||
<a href="/about" class="footer-link" data-testid="footer-link-about">About</a>
|
||||
<a href="/privacy" class="footer-link" data-testid="footer-link-privacy">Privacy</a>
|
||||
<a href="/terms" class="footer-link" data-testid="footer-link-terms">Terms</a>
|
||||
<a href="/api/docs" class="footer-link" data-testid="footer-link-api">API</a>
|
||||
<a href="/about" class="footer-link" data-testid="footer-link-about">{{ _('footer.about', current_locale) }}</a>
|
||||
<a href="/privacy" class="footer-link" data-testid="footer-link-privacy">{{ _('footer.privacy', current_locale) }}</a>
|
||||
<a href="/terms" class="footer-link" data-testid="footer-link-terms">{{ _('footer.terms', current_locale) }}</a>
|
||||
<a href="/api/docs" class="footer-link" data-testid="footer-link-api">{{ _('footer.api', current_locale) }}</a>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<rect width="32" height="32" rx="6" fill="var(--color-primary)"/>
|
||||
<path d="M8 12h16M8 16h12M8 20h8" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span data-testid="logo-text">Blog</span>
|
||||
<span data-testid="logo-text">{{ _('header.logo', current_locale) }}</span>
|
||||
</a>
|
||||
|
||||
{% include "partials/nav.html" %}
|
||||
@@ -15,7 +15,7 @@
|
||||
type="button"
|
||||
class="mobile-menu-btn"
|
||||
data-testid="mobile-menu-toggle"
|
||||
aria-label="Toggle menu"
|
||||
aria-label="{{ _('header.toggle_menu', current_locale) }}"
|
||||
aria-expanded="false"
|
||||
aria-controls="mobile-nav"
|
||||
>
|
||||
@@ -30,8 +30,8 @@
|
||||
type="button"
|
||||
class="theme-toggle"
|
||||
data-testid="theme-toggle"
|
||||
aria-label="Toggle dark mode"
|
||||
title="Toggle dark mode"
|
||||
aria-label="{{ _('header.toggle_theme', current_locale) }}"
|
||||
title="{{ _('header.toggle_theme', current_locale) }}"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" data-testid="theme-light-icon" style="display: none;">
|
||||
<path d="M10 2v2M10 16v2M4.22 4.22l1.42 1.42M14.36 14.36l1.42 1.42M2 10h2M16 10h2M4.22 15.78l1.42-1.42M14.36 5.64l1.42-1.42" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
@@ -41,7 +41,27 @@
|
||||
<path d="M17.293 13.293A8 8 0 116.707 2.707a8.003 8.003 0 0010.586 10.586z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
|
||||
<div class="lang-switcher" data-testid="lang-switcher">
|
||||
<button
|
||||
type="button"
|
||||
class="lang-switcher-toggle"
|
||||
data-testid="lang-switcher-toggle"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
title="{{ _('header.lang_switcher', current_locale) }}"
|
||||
>
|
||||
<span data-testid="current-lang-code">{{ current_locale|upper }}</span>
|
||||
</button>
|
||||
<div class="lang-switcher-dropdown" data-testid="lang-switcher-dropdown">
|
||||
{% for code in ('en', 'ru', 'fr', 'de') %}
|
||||
<a href="/web/lang/{{ code }}" class="lang-switcher-item {% if code == current_locale %}active{% endif %}" data-testid="lang-option-{{ code }}">
|
||||
{{ _('lang.' + code, current_locale) }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if user %}
|
||||
<div class="user-menu" data-testid="user-menu">
|
||||
<button
|
||||
@@ -63,14 +83,14 @@
|
||||
<circle cx="8" cy="6" r="3" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M2 14c0-3 3-5 6-5s6 2 6 5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Profile
|
||||
{{ _('header.profile', current_locale) }}
|
||||
</a>
|
||||
{% if can_create %}
|
||||
<a href="/web/posts/new" class="user-menu-item" data-testid="user-menu-new-post">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
|
||||
<path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
New Post
|
||||
{{ _('header.new_post', current_locale) }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="user-menu-divider" data-testid="user-menu-divider"></div>
|
||||
@@ -78,13 +98,13 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
|
||||
<path d="M10 12h2a2 2 0 002-2V6a2 2 0 00-2-2h-2M6 12l-3-3m0 0l3-3m-3 3h8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Sign Out
|
||||
{{ _('header.sign_out', current_locale) }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<a href="/auth/login" class="btn btn-primary btn-sm" data-testid="btn-login">
|
||||
Sign In
|
||||
{{ _('header.sign_in', current_locale) }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -241,6 +261,84 @@
|
||||
}
|
||||
}
|
||||
|
||||
.lang-switcher {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.lang-switcher-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.lang-switcher-toggle:hover {
|
||||
background-color: var(--color-hover);
|
||||
border-color: var(--color-secondary-dark-2);
|
||||
}
|
||||
|
||||
.lang-switcher-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 0.5rem;
|
||||
min-width: 140px;
|
||||
background-color: var(--color-box-body);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px var(--color-shadow);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-10px);
|
||||
transition: all 0.2s ease;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.lang-switcher:hover .lang-switcher-dropdown,
|
||||
.lang-switcher-toggle:focus + .lang-switcher-dropdown,
|
||||
.lang-switcher-dropdown:hover {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.lang-switcher-item {
|
||||
display: block;
|
||||
padding: 0.6rem 1rem;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.lang-switcher-item:first-child {
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.lang-switcher-item:last-child {
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
|
||||
.lang-switcher-item:hover {
|
||||
background-color: var(--color-hover);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.lang-switcher-item.active {
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.mobile-menu-btn {
|
||||
display: none;
|
||||
@@ -255,13 +353,13 @@
|
||||
<!-- Mobile Navigation Menu -->
|
||||
<nav class="mobile-nav" id="mobile-nav" data-testid="mobile-nav" aria-label="Mobile navigation">
|
||||
<a href="/web/" class="nav-link {% if active_page == 'home' %}active{% endif %}" data-testid="mobile-nav-link-home">
|
||||
Home
|
||||
{{ _('nav.home', current_locale) }}
|
||||
</a>
|
||||
<a href="/web/posts" class="nav-link {% if active_page == 'posts' %}active{% endif %}" data-testid="mobile-nav-link-posts">
|
||||
Posts
|
||||
{{ _('nav.posts', current_locale) }}
|
||||
</a>
|
||||
<a href="/web/about" class="nav-link {% if active_page == 'about' %}active{% endif %}" data-testid="mobile-nav-link-about">
|
||||
About
|
||||
{{ _('nav.about', current_locale) }}
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<nav class="main-nav" data-testid="main-nav" aria-label="Main navigation">
|
||||
<a href="/web/" class="nav-link {% if active_page == 'home' %}active{% endif %}" data-testid="nav-link-home">
|
||||
Home
|
||||
{{ _('nav.home', current_locale) }}
|
||||
</a>
|
||||
<a href="/web/posts" class="nav-link {% if active_page == 'posts' %}active{% endif %}" data-testid="nav-link-posts">
|
||||
Posts
|
||||
{{ _('nav.posts', current_locale) }}
|
||||
</a>
|
||||
<a href="/web/about" class="nav-link {% if active_page == 'about' %}active{% endif %}" data-testid="nav-link-about">
|
||||
About
|
||||
{{ _('nav.about', current_locale) }}
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
Reference in New Issue
Block a user