From 850ce3111e5997e9eb475b6a667554470f6a6240 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Wed, 22 Jul 2026 13:25:48 +0200 Subject: [PATCH 01/16] =?UTF-8?q?Update=20logo,=20add=20translations=20and?= =?UTF-8?q?=20fix=20comments=20:=20Replace=20the=20favicon=20with=20a=20DJ?= =?UTF-8?q?=E2=80=91style=20logo.=20The=20old=20SVG=20is=20removed=20and?= =?UTF-8?q?=20replaced=20by=20a=20JetBrains=20Mono=20=E2=80=9CDJ=E2=80=9D?= =?UTF-8?q?=20glyph=20on=20a=20#7C3AED=20background,=20giving=20the=20site?= =?UTF-8?q?=20a=20clean=20high=E2=80=91contrast=20square=20icon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/static/images/favicon.svg | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/static/images/favicon.svg b/frontend/static/images/favicon.svg index 769d6028..71271178 100644 --- a/frontend/static/images/favicon.svg +++ b/frontend/static/images/favicon.svg @@ -1,4 +1,6 @@ - - - D + + + DJ From c45ebee4d3d51165dc51fb27c02d582e252d9b64 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Wed, 22 Jul 2026 20:34:10 +0200 Subject: [PATCH 02/16] =?UTF-8?q?Update=20logo,=20add=20translations=20and?= =?UTF-8?q?=20fix=20comments=20:=20Full=20French=20i18n=20added:=20BlockNo?= =?UTF-8?q?te=20runs=20in=20`fr`,=20backend=20errors=20use=20`gettext()`,?= =?UTF-8?q?=20and=20all=20frontend=20strings=20go=20through=20`=5F()`.=20D?= =?UTF-8?q?ates=20are=20fully=20locale=E2=80=91aware=20via=20`format=5Fdat?= =?UTF-8?q?etime=5Flocale`,=20replacing=20old=20English=20DTO=20formats.?= =?UTF-8?q?=20French=20`.po/.mo`=20files=20are=20stored=20under=20`transla?= =?UTF-8?q?tions/fr=20LC=5FMESSAGES/`,=20with=20tests=20for=20`=5F()`,=20t?= =?UTF-8?q?he=20file=20adapter,=20and=20localized=20datetime=20formatting,?= =?UTF-8?q?=20plus=20final=20template/styletouch=E2=80=91ups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- babel.cfg | 2 + blog_comment_application.py | 4 + frontend/core/__tests__/i18n.test.js | 50 ++ frontend/core/components/ArticleForm.jsx | 39 +- frontend/core/components/ArticleViewer.jsx | 7 +- frontend/core/components/CopyBlockButton.jsx | 6 +- .../core/components/CustomDragHandleMenu.jsx | 7 +- frontend/core/components/ErrorBoundary.jsx | 3 +- frontend/core/components/JustifyButton.jsx | 3 +- frontend/core/entry.jsx | 3 +- frontend/core/hooks/useArticle.js | 3 +- frontend/core/utils/i18n.js | 8 + frontend/core/utils/video-override-spec.js | 6 +- frontend/templates/article_create.html | 40 +- frontend/templates/article_detail.html | 61 +- frontend/templates/article_edit.html | 40 +- frontend/templates/article_list.html | 30 +- frontend/templates/base.html | 48 +- frontend/templates/error.html | 2 +- frontend/templates/login.html | 24 +- frontend/templates/macros/pagination.html | 4 +- frontend/templates/profile.html | 62 +- frontend/templates/registration.html | 34 +- frontend/templates/user_list.html | 62 +- poetry.lock | 49 +- pyproject.toml | 3 +- .../input_adapters/dto/article_response.py | 38 +- .../input_adapters/dto/comment_response.py | 59 +- .../flask/flask_account_session_adapter.py | 54 +- .../flask/flask_article_adapter.py | 43 +- .../flask/flask_comment_adapter.py | 43 +- .../flask/flask_file_adapter.py | 6 +- .../flask/flask_login_adapter.py | 7 +- .../flask/flask_registration_adapter.py | 7 +- tests/conftest.py | 1 + tests/test_template_helpers.py | 51 +- .../dto/test_article_response.py | 10 +- .../dto/test_comment_response.py | 5 +- .../tests_flask/flask_test_utils.py | 6 +- .../tests_flask/test_flask_file_adapter.py | 109 +++ translations/fr/LC_MESSAGES/messages.mo | Bin 0 -> 10855 bytes translations/fr/LC_MESSAGES/messages.po | 849 ++++++++++++++++++ translations/messages.pot | 810 +++++++++++++++++ utils/template_helpers.py | 28 + 44 files changed, 2364 insertions(+), 362 deletions(-) create mode 100644 babel.cfg create mode 100644 frontend/core/__tests__/i18n.test.js create mode 100644 frontend/core/utils/i18n.js create mode 100644 tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_file_adapter.py create mode 100644 translations/fr/LC_MESSAGES/messages.mo create mode 100644 translations/fr/LC_MESSAGES/messages.po create mode 100644 translations/messages.pot diff --git a/babel.cfg b/babel.cfg new file mode 100644 index 00000000..759e805a --- /dev/null +++ b/babel.cfg @@ -0,0 +1,2 @@ +[python: **.py] +[jinja2: **/templates/**.html] diff --git a/blog_comment_application.py b/blog_comment_application.py index dbcbfeb7..1ed7581b 100644 --- a/blog_comment_application.py +++ b/blog_comment_application.py @@ -2,6 +2,7 @@ from datetime import timedelta from flask import Flask, render_template +from flask_babel import Babel from flask_compress import Compress from sqlalchemy.orm import Session @@ -31,6 +32,7 @@ ViteManifest, date_format_filter, date_iso_filter, + format_datetime_locale, inject_current_year, inject_vite_assets, nl2br_filter, @@ -171,6 +173,7 @@ def _init_template_utils(app: Flask) -> None: app.jinja_env.filters["date_format"] = date_format_filter app.jinja_env.filters["date_iso"] = date_iso_filter app.jinja_env.filters["prosemirror_to_html"] = prosemirror_to_html + app.jinja_env.filters["format_datetime_locale"] = format_datetime_locale app.context_processor(inject_current_year) app.context_processor(inject_vite_assets) @@ -196,6 +199,7 @@ def create_app(db_session=None) -> Flask: services = _create_services(repositories) app = _init_web_facade_flask() Compress(app) + Babel(app, locale_selector=lambda: "fr") init_web_security(app) _init_template_utils(app) web_adapters = _init_web_adapters(services) diff --git a/frontend/core/__tests__/i18n.test.js b/frontend/core/__tests__/i18n.test.js new file mode 100644 index 00000000..b432078a --- /dev/null +++ b/frontend/core/__tests__/i18n.test.js @@ -0,0 +1,50 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { _ } from '../utils/i18n.js'; + +function setTranslations(dict) { + const el = document.createElement('script'); + el.id = 'app-translations'; + el.type = 'application/json'; + el.textContent = JSON.stringify(dict); + document.body.appendChild(el); +} + +describe('i18n _()', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('returns translation when key exists', () => { + setTranslations({ hello: 'bonjour', bye: 'au revoir' }); + expect(_('hello')).toBe('bonjour'); + expect(_('bye')).toBe('au revoir'); + }); + + it('returns key when key not in dictionary', () => { + setTranslations({ hello: 'bonjour' }); + expect(_('missing')).toBe('missing'); + }); + + it('returns key when no translations element exists', () => { + expect(document.getElementById('app-translations')).toBeNull(); + expect(_('hello')).toBe('hello'); + }); + + it('returns key when translations JSON is malformed', () => { + const el = document.createElement('script'); + el.id = 'app-translations'; + el.type = 'application/json'; + el.textContent = '{broken json}'; + document.body.appendChild(el); + expect(_('hello')).toBe('hello'); + }); + + it('returns key when dictionary is empty', () => { + setTranslations({}); + expect(_('anything')).toBe('anything'); + }); +}); diff --git a/frontend/core/components/ArticleForm.jsx b/frontend/core/components/ArticleForm.jsx index 6c4068ef..171419d7 100644 --- a/frontend/core/components/ArticleForm.jsx +++ b/frontend/core/components/ArticleForm.jsx @@ -11,19 +11,21 @@ import createHighlighter from '../utils/shiki-highlighter-editor'; import SUPPORTED_LANGUAGES from '../utils/supported-languages'; import { createCustomCodeBlockSpec } from '../utils/custom-code-block-spec'; import { createYouTubeVideoSpec } from '../utils/video-override-spec'; +import { fr } from '@blocknote/core/locales'; +import { _ } from '../utils/i18n'; export function applyVideoDictOverrides(editor) { - editor.dictionary.slash_menu.video.title = 'YouTube'; - editor.dictionary.slash_menu.video.subtext = 'Paste a YouTube video URL'; + editor.dictionary.slash_menu.video.title = _('YouTube'); + editor.dictionary.slash_menu.video.subtext = _('Paste a YouTube video URL'); editor.dictionary.slash_menu.video.aliases = [ 'youtube', 'yt', 'video', 'videoUpload', 'upload', 'film', 'media', 'url', ]; - editor.dictionary.file_panel.embed.title = 'YouTube URL'; - editor.dictionary.file_panel.embed.url_placeholder = 'Paste YouTube video link'; - editor.dictionary.file_panel.embed.embed_button.video = 'Embed YouTube video'; - editor.dictionary.file_blocks.add_button_text.video = 'Add YouTube video URL'; + editor.dictionary.file_panel.embed.title = _('YouTube URL'); + editor.dictionary.file_panel.embed.url_placeholder = _('Paste YouTube video link'); + editor.dictionary.file_panel.embed.embed_button.video = _('Embed YouTube video'); + editor.dictionary.file_blocks.add_button_text.video = _('Add YouTube video URL'); } function CustomFilePanel({ blockId }) { @@ -89,6 +91,7 @@ function BlockNoteEditor({ initialContent, onReady }) { const { audio: _a, file: _f, video: defaultVideoSpec, ...keptSpecs } = defaultBlockSpecs; const editor = useCreateBlockNote({ + dictionary: fr, initialContent, uploadFile: uploadFn, disableExtensions: ['gapCursor'], @@ -117,7 +120,7 @@ function BlockNoteEditor({ initialContent, onReady }) { '[data-content-type="video"] .bn-add-file-button-text', ).forEach((el) => { if (el.textContent === 'Add video') { - el.textContent = 'Add YouTube video URL'; + el.textContent = _('Add YouTube video URL'); } }); } @@ -372,7 +375,7 @@ export default function ArticleForm() { const handleSubmit = async () => { if (!title.trim()) { - setError('Title is required.'); + setError(_('Title is required.')); return; } setSaving(true); @@ -394,17 +397,17 @@ export default function ArticleForm() { window.location.href = `/articles/${data.id || articleId}`; } else { const err = await res.json(); - setError(err.error || 'Failed to save.'); + setError(err.error || _('Failed to save.')); setSaving(false); } } catch { - setError('Network error.'); + setError(_('Network error.')); setSaving(false); } }; if (!loaded) { - return
Loading...
; + return
{_('Loading...')}
; } const displayError = error || loadError; @@ -413,7 +416,7 @@ export default function ArticleForm() { try { initialContent = contentStr ? JSON.parse(contentStr) : undefined; } catch { - return
Unable to parse article content.
; + return
{_('Unable to parse article content.')}
; } return ( @@ -422,22 +425,22 @@ export default function ArticleForm() { setTitle(e.target.value)} onClick={handleDoubleTapSelect} />
- Description + {_('Description')}
- Maximum 300 characters + {_('Maximum 300 characters')} {description.length}/300
- - + +
@@ -161,10 +161,10 @@

{{ article.article_title }}

{% if current_user and not is_deleted %}
{% if depth < 3 %} - + {% endif %} {% if node.replies %} - + {% endif %}
{% if depth < 3 %} @@ -173,18 +173,18 @@

{{ article.article_title }}

- Comment must be between 1 and 5000 characters + {{ _('Comment must be between 1 and 5000 characters') }}
- - + +
{% endif %} {% elif node.replies %}
- +
{% endif %} @@ -200,33 +200,44 @@

{{ article.article_title }}

{% endmacro %}
-

Comments ({{ comment_count }})

+

{{ _('Comments (%(count)s)', count=comment_count) }}

{% for node in nested_comments %} {{ render_comment(node, 0) }} {% else %} -

No comments yet.

+

{{ _('No comments yet.') }}

{% endfor %} {% if current_user %}
-

Join the discussion

- Comment must be between 1 and 5000 characters +

{{ _('Join the discussion') }}

+ {{ _('Comment must be between 1 and 5000 characters') }}
- +
{% else %}

- Please sign in to join the conversation. + {% set sign_in_url = url_for('auth.login') %} + {{ _('Please sign in to join the conversation.', url=sign_in_url) | safe }}

{% endif %}
+{% block translations %} + +{% endblock %} + {% block extra_js %} {% endblock %} diff --git a/frontend/templates/article_edit.html b/frontend/templates/article_edit.html index 9ecdaf18..15890273 100644 --- a/frontend/templates/article_edit.html +++ b/frontend/templates/article_edit.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% block title %}Edit Article - DevJournal{% endblock %} +{% block title %}{{ _('Edit Article - DevJournal') }}{% endblock %} {% from "macros/icons.html" import icon %} {% block extra_head %} @@ -37,7 +37,7 @@
{{ icon('arrow_back') }} - Back to articles + {{ _('Back to articles') }}
@@ -51,6 +51,42 @@ {% endblock %} +{% block translations %} + +{% endblock %} + {% block extra_js %} {% endblock %} diff --git a/frontend/templates/article_list.html b/frontend/templates/article_list.html index d94564da..82d2fbd6 100644 --- a/frontend/templates/article_list.html +++ b/frontend/templates/article_list.html @@ -2,7 +2,7 @@ {% from "macros/pagination.html" import render_pagination_info %} {% extends "base.html" %} -{% block title %}Latest Articles - DevJournal{% endblock %} +{% block title %}{{ _('Latest Articles - DevJournal') }}{% endblock %} {% block extra_head %} {% if vite_vendor_js_url %} @@ -23,12 +23,12 @@ {% block content %}
- {% if query %} × {% endif %} - +
@@ -37,7 +37,7 @@
{{ icon('arrow_back') }} - Previous + {{ _('Previous') }}
@@ -45,7 +45,7 @@
- Next + {{ _('Next') }} {{ icon('arrow_forward') }}
@@ -85,7 +85,7 @@

{% if article.article_edited_at %} - edited at {{ article.article_edited_at_formatted }} + {{ _('edited at %(date)s', date=article.article_edited_at|format_datetime_locale) }} {% endif %} {% endif %} @@ -94,7 +94,7 @@

{% else %}
{{ icon('folder_open', 'empty-state-icon') }} -

{% if query %}No articles match "{{ query }}".{% else %}No articles found in the database.{% endif %}

+

{% if query %}{{ _('No articles match "%(query)s".', query=query) }}{% else %}{{ _('No articles found in the database.') }}{% endif %}

{% endfor %} @@ -104,7 +104,7 @@

@@ -112,7 +112,7 @@

@@ -121,18 +121,18 @@

diff --git a/frontend/templates/base.html b/frontend/templates/base.html index 5ed5f2a9..43ab04e4 100644 --- a/frontend/templates/base.html +++ b/frontend/templates/base.html @@ -40,43 +40,43 @@ DJ {% endblock %} diff --git a/frontend/templates/login.html b/frontend/templates/login.html index 540bf3be..7ba11457 100644 --- a/frontend/templates/login.html +++ b/frontend/templates/login.html @@ -1,7 +1,7 @@ {% from "macros/icons.html" import icon %} {% extends "base.html" %} -{% block title %}Sign In - DevJournal{% endblock %} +{% block title %}{{ _('Sign In - DevJournal') }}{% endblock %} {% block extra_css %} @@ -10,36 +10,36 @@ {% block content %} {% if is_own_profile %} - + {% if user.avatar_file_id %}
- +
{% endif %} {% endif %} @@ -34,10 +34,10 @@

{{ user.account_username }}

- +
{% endif %} @@ -46,11 +46,11 @@

{{ user.account_username }}

{% if is_own_profile or (current_user and current_user.account_role == 'admin') %}
- Email + {{ _('Email') }} {% if is_own_profile %}
{{ user.account_email }} - +
{% else %} {{ user.account_email }} @@ -59,61 +59,61 @@

{{ user.account_username }}

{% endif %} {% if is_own_profile or (current_user and current_user.account_role == 'admin') %}
- Member Since + {{ _('Member Since') }} {% if user.account_created_at %} {{ user.account_created_at|date_format }} {% else %} - N/A + {{ _('N/A') }} {% endif %}
{% endif %}
- Status - {% if user.is_banned %}Banned{% if user.ban_reason %}: {{ user.ban_reason }}{% endif %}{% else %}Active{% endif %} + {{ _('Status') }} + {% if user.is_banned %}{{ _('Banned') }}{% if user.ban_reason %}: {{ user.ban_reason }}{% endif %}{% else %}{{ _('Active') }}{% endif %}
{% if is_own_profile %}
@@ -121,19 +121,19 @@ {% if is_own_profile %}
{% if current_user.account_role == 'admin' %} - Manage Users + {{ _('Manage Users') }} {% endif %} - +
- +
{% if current_user.account_role != 'admin' %}
+ data-confirm-message="{{ _('Are you sure you want to delete your account ? This action is irreversible.') }}">{{ _('Delete Account') }}
{% endif %}
@@ -144,26 +144,26 @@ {% if user.is_banned %}
- +
{% else %} + data-username="{{ user.account_username }}">{{ _('Ban') }} {% endif %}
+ data-confirm-message="{{ _('Delete user \\\"%(username)s\\\" ? This action is irreversible.', username=user.account_username) }}">{{ _('Delete Account') }}
{% endif %} {{ icon('home') }} - Return to home + {{ _('Return to home') }} {% endblock %} diff --git a/frontend/templates/registration.html b/frontend/templates/registration.html index e2ddaace..104cfb18 100644 --- a/frontend/templates/registration.html +++ b/frontend/templates/registration.html @@ -1,7 +1,7 @@ {% from "macros/icons.html" import icon %} {% extends "base.html" %} -{% block title %}Sign Up - DevJournal{% endblock %} +{% block title %}{{ _('Sign Up - DevJournal') }}{% endblock %} {% block extra_css %} @@ -10,47 +10,47 @@ {% block content %}
- - + +
- - + +
- +
- - + +
- +

diff --git a/frontend/templates/user_list.html b/frontend/templates/user_list.html index 5bf9bcc3..4f8719da 100644 --- a/frontend/templates/user_list.html +++ b/frontend/templates/user_list.html @@ -1,7 +1,7 @@ {% from "macros/pagination.html" import render_pagination_info %} {% extends "base.html" %} -{% block title %}Users - DevJournal{% endblock %} +{% block title %}{{ _('Users') }} - DevJournal{% endblock %} {% block extra_css %} @@ -13,16 +13,16 @@ {% block content %}
-

Manage Users ({{ total_count }} users)

+

{{ _('Manage Users (%(count)d users)', count=total_count) }}

{% if query %} × {% endif %} - +
{{ render_pagination_info(page, total_pages, 'auth.list_all_users', query) }} @@ -46,13 +46,13 @@

Manage Users ({{ total_count }} users)

- - - - - - - + + + + + + + @@ -61,9 +61,9 @@

Manage Users ({{ total_count }} users)

- - - + + + - + - +
UsernameEmailRoleStatusReasonMember SinceActions{{ _('Username') }}{{ _('Email') }}{{ _('Role') }}{{ _('Status') }}{{ _('Reason') }}{{ _('Member Since') }}{{ _('Actions') }}
{{ user.account_username }} {{ user.account_email }} {{ user.account_role }}{{ "Banned" if user.is_banned else "Active" }}{{ user.ban_reason or '—' }}{{ user.account_created_at|date_format if user.account_created_at else 'N/A' }}{{ _('Banned') if user.is_banned else _('Active') }}{{ user.ban_reason or _('—') }}{{ user.account_created_at|date_format if user.account_created_at else _('N/A') }} {% if user.account_role == 'admin' %} @@ -72,27 +72,27 @@

Manage Users ({{ total_count }} users)

- +
{% if user.is_banned %}
- +
{% else %} + data-username="{{ user.account_username }}">{{ _('Ban') }} {% endif %}
+ data-confirm-message="{{ _('Delete user \\\"%(username)s\\\" ? This action is irreversible.', username=user.account_username) }}">{{ _('Delete') }}
{% endif %} @@ -107,32 +107,32 @@

Manage Users ({{ total_count }} users)

{{ icon('arrow_back') }} - Previous + {{ _('Previous') }} {{ render_pagination_info(page, total_pages, 'auth.list_all_users', query) }} {{ icon('home') }} - Return to home + {{ _('Return to home') }} {% endblock %} diff --git a/poetry.lock b/poetry.lock index e56caf3a..3428b0f5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -69,6 +69,21 @@ cffi = [ {version = ">=2.0.0b1", markers = "python_version >= \"3.14\""}, ] +[[package]] +name = "babel" +version = "2.18.0" +description = "Internationalization utilities" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35"}, + {file = "babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d"}, +] + +[package.extras] +dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""] + [[package]] name = "backports-zstd" version = "1.6.0" @@ -694,6 +709,24 @@ werkzeug = ">=3.1.0" async = ["asgiref (>=3.2)"] dotenv = ["python-dotenv"] +[[package]] +name = "flask-babel" +version = "4.0.0" +description = "Adds i18n/l10n support for Flask applications." +optional = false +python-versions = ">=3.8,<4.0" +groups = ["main"] +files = [ + {file = "flask_babel-4.0.0-py3-none-any.whl", hash = "sha256:638194cf91f8b301380f36d70e2034c77ee25b98cb5d80a1626820df9a6d4625"}, + {file = "flask_babel-4.0.0.tar.gz", hash = "sha256:dbeab4027a3f4a87678a11686496e98e1492eb793cbdd77ab50f4e9a2602a593"}, +] + +[package.dependencies] +Babel = ">=2.12" +Flask = ">=2.0" +Jinja2 = ">=3.1" +pytz = ">=2022.7" + [[package]] name = "flask-compress" version = "1.24" @@ -1742,6 +1775,18 @@ files = [ [package.extras] dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"] +[[package]] +name = "pytz" +version = "2026.2" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126"}, + {file = "pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a"}, +] + [[package]] name = "ruff" version = "0.14.14" @@ -1938,5 +1983,5 @@ email = ["email-validator"] [metadata] lock-version = "2.1" -python-versions = ">=3.12" -content-hash = "ceeebc4a9521c4473c1a22b28afc7bbbd93de57b76507e11956a61495427ec0a" +python-versions = ">=3.12,<4.0" +content-hash = "c96c454a76a96fd79957e5c99729b3911e596c3a90b9d543f47d6ba28a8fc11e" diff --git a/pyproject.toml b/pyproject.toml index 44df972f..ea09c2ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ authors = [ {name = "LH_cat, Raydev"} ] readme = "README.md" -requires-python = ">=3.12" +requires-python = ">=3.12,<4.0" dependencies = [ "flask (>=3.1.2,<4.0.0)", "flask-sqlalchemy (>=3.1.1,<4.0.0)", @@ -22,6 +22,7 @@ dependencies = [ "pyright (>=1.1.410,<2.0.0)", "flask-compress (>=1.24,<2.0)", "nh3 (>=0.2.21,<1.0.0)", + "flask-babel (>=4.0.0,<5.0.0)", ] diff --git a/src/infrastructure/input_adapters/dto/article_response.py b/src/infrastructure/input_adapters/dto/article_response.py index 9a62b92c..fa97c7af 100644 --- a/src/infrastructure/input_adapters/dto/article_response.py +++ b/src/infrastructure/input_adapters/dto/article_response.py @@ -1,5 +1,4 @@ from datetime import datetime -from zoneinfo import ZoneInfo from pydantic import BaseModel, ConfigDict @@ -9,17 +8,24 @@ class ArticleResponse(BaseModel): Data Transfer Object used to send article data to the UI. Protects the Domain entity from being exposed directly to the templates. + Dates are returned as raw UTC datetimes. + Formatting with locale-aware filters happens in the template layer. + Attributes: + article_id (int): Unique identifier for the article. article_author_id (int | None): Reference to the author's Account. None when the author's account has been deleted. + author_username (str): Display name of the article author. author_avatar_file_id (str | None): UUID of the author's avatar file, or None. + article_title (str): Title of the article. article_description (str): Short description displayed in article list. Empty string when no description was provided. + article_content (str): JSON content for the BlockNote editor. + article_published_at (datetime | None): Publication timestamp in UTC. meta_description (str): Alias for article_description, used for and list view excerpt. - article_edited_at (datetime | None): Last edit timestamp. None if never edited. - article_edited_at_formatted (str): Human-readable edit time in Europe/Paris. - Empty string if never edited. + article_edited_at (datetime | None): Last edit timestamp in UTC. + None if never edited. """ model_config = ConfigDict(from_attributes=True) @@ -33,30 +39,11 @@ class ArticleResponse(BaseModel): article_published_at: datetime | None = None meta_description: str = "" article_edited_at: datetime | None = None - article_edited_at_formatted: str = "" - - @staticmethod - def _to_local_full(dt: datetime) -> str: - """Convert a UTC datetime to a Europe/Paris formatted string for display. - - Args: - dt (datetime): The datetime to convert (assumed UTC; naive treated as UTC). - - Returns: - str: Formatted string like "January 27, 2023 at 13:00". - """ - if dt.tzinfo is None: - dt = dt.replace(tzinfo=ZoneInfo("UTC")) - local = dt.astimezone(ZoneInfo("Europe/Paris")) - return local.strftime("%B %d, %Y at %H:%M") @classmethod def from_domain(cls, article, author_username: str = "Unknown", author_avatar_file_id: str | None = None): """Build an ArticleResponse from a domain Article entity. - Maps all fields from the domain object into the DTO. Formats - article_edited_at into a human-readable Europe/Paris string. - Args: article: The domain Article instance to convert. author_username (str): The author's display name. Defaults to "Unknown". @@ -67,10 +54,6 @@ def from_domain(cls, article, author_username: str = "Unknown", author_avatar_fi """ description = article.article_description or "" - article_edited_at_formatted = "" - if article.article_edited_at: - article_edited_at_formatted = cls._to_local_full(article.article_edited_at) - return cls( article_id=article.article_id, article_author_id=article.article_author_id, @@ -82,5 +65,4 @@ def from_domain(cls, article, author_username: str = "Unknown", author_avatar_fi article_published_at=article.article_published_at, meta_description=description, article_edited_at=article.article_edited_at, - article_edited_at_formatted=article_edited_at_formatted, ) diff --git a/src/infrastructure/input_adapters/dto/comment_response.py b/src/infrastructure/input_adapters/dto/comment_response.py index a528bbaf..d3a7ecd0 100644 --- a/src/infrastructure/input_adapters/dto/comment_response.py +++ b/src/infrastructure/input_adapters/dto/comment_response.py @@ -2,7 +2,6 @@ from dataclasses import dataclass, field from datetime import datetime -from zoneinfo import ZoneInfo from pydantic import BaseModel, ConfigDict @@ -11,7 +10,9 @@ class CommentResponse(BaseModel): """ Data Transfer Object used to send comment data to the UI. Protects the Domain entity from being exposed directly to the templates. - Handles formatting of complex types like dates. + + Dates are returned as raw UTC datetimes. + Formatting with locale-aware filters happens in the template layer. Attributes: comment_id (int): Unique identifier for the comment. @@ -21,10 +22,9 @@ class CommentResponse(BaseModel): author_avatar_file_id (str | None): UUID of the author's avatar file, or None. comment_reply_to (int | None): Reference to a parent comment (for replies). comment_content (str): Text content of the comment. - comment_posted_at_formatted (str): Human-readable posting date in local time. + comment_posted_at (datetime | None): Posting timestamp in UTC. is_deleted (bool): Soft-delete flag. - edited_at (datetime | None): Last edit timestamp. None if never edited. - edited_at_formatted (str): Human-readable edit time in local time. Empty if never edited. + edited_at (datetime | None): Last edit timestamp in UTC. None if never edited. """ model_config = ConfigDict(from_attributes=True) @@ -35,50 +35,16 @@ class CommentResponse(BaseModel): author_avatar_file_id: str | None = None comment_reply_to: int | None comment_content: str - comment_posted_at_formatted: str = "" + comment_posted_at: datetime | None = None is_deleted: bool = False edited_at: datetime | None = None - edited_at_formatted: str = "" - - @staticmethod - def _to_local_full(dt: datetime) -> str: - """ - Converts a UTC datetime to Europe/Paris and returns a full formatted string. - - Args: - dt (datetime): The datetime to convert (assumed UTC if naive). - - Returns: - str: Formatted date like "October 27, 2023 at 16:30". - """ - if dt.tzinfo is None: - dt = dt.replace(tzinfo=ZoneInfo("UTC")) - local = dt.astimezone(ZoneInfo("Europe/Paris")) - return local.strftime("%B %d, %Y at %H:%M") - - @staticmethod - def _to_local_time(dt: datetime) -> str: - """ - Converts a UTC datetime to Europe/Paris and returns just the time. - - Args: - dt (datetime): The datetime to convert (assumed UTC if naive). - - Returns: - str: Formatted time like "16:30". - """ - if dt.tzinfo is None: - dt = dt.replace(tzinfo=ZoneInfo("UTC")) - local = dt.astimezone(ZoneInfo("Europe/Paris")) - return local.strftime("%H:%M") @classmethod def from_domain(cls, comment, author_username: str = "Unknown", author_avatar_file_id: str | None = None): """ Helper factory to create a response DTO from a domain Comment entity. - Formats dates into reader-friendly strings in Europe/Paris local time. - Maps is_deleted, deleted_at, and edited_at from the domain entity. + If the comment's author account has been deleted (comment_written_account_id is None) or the comment has been soft-deleted (is_deleted is True), the author is displayed as "Anonymous" and content shows as 'Comment removed'. @@ -102,14 +68,6 @@ def from_domain(cls, comment, author_username: str = "Unknown", author_avatar_fi author_username = "Anonymous" content = "Comment removed" - formatted_date = "" - if comment.comment_posted_at: - formatted_date = cls._to_local_full(comment.comment_posted_at) - - edited_at_formatted = "" - if comment.edited_at: - edited_at_formatted = cls._to_local_full(comment.edited_at) - return cls( comment_id=comment.comment_id, comment_article_id=comment.comment_article_id, @@ -118,10 +76,9 @@ def from_domain(cls, comment, author_username: str = "Unknown", author_avatar_fi author_avatar_file_id=author_avatar_file_id, comment_reply_to=comment.comment_reply_to, comment_content=content, - comment_posted_at_formatted=formatted_date, + comment_posted_at=comment.comment_posted_at, is_deleted=is_deleted, edited_at=comment.edited_at, - edited_at_formatted=edited_at_formatted, ) @classmethod diff --git a/src/infrastructure/input_adapters/flask/flask_account_session_adapter.py b/src/infrastructure/input_adapters/flask/flask_account_session_adapter.py index 8e211160..5aefbc3e 100644 --- a/src/infrastructure/input_adapters/flask/flask_account_session_adapter.py +++ b/src/infrastructure/input_adapters/flask/flask_account_session_adapter.py @@ -1,6 +1,8 @@ import logging import math +from flask_babel import gettext as _ + from flask import abort, flash, jsonify, redirect, render_template, request, url_for from flask import g as global_request_context from flask.views import MethodView @@ -73,7 +75,7 @@ def logout(self): Response: A Flask redirect response. """ self.session_service.terminate_session() - flash("You have been logged out.", "info") + flash(_("You have been logged out."), "info") return redirect(url_for("article.list_articles")) def display_profile(self): @@ -87,7 +89,7 @@ def display_profile(self): account = self.session_service.get_current_account() if not account: - flash("Please sign in to view your profile.", "error") + flash(_("Please sign in to view your profile."), "error") return redirect(url_for("auth.login")) user_dto = AccountResponse.from_domain(account) @@ -144,11 +146,11 @@ def upload_profile_photo(self): """ current_account = getattr(global_request_context, "current_user", None) if not current_account: - return jsonify({"error": "Authentication required."}), 401 + return jsonify({"error": _("Authentication required.")}), 401 uploaded_file = request.files.get("file") if not uploaded_file or not uploaded_file.filename: - return jsonify({"error": "No file provided."}), 400 + return jsonify({"error": _("No file provided.")}), 400 file_data = uploaded_file.read() try: @@ -191,22 +193,22 @@ def remove_profile_photo(self): """ account = self.session_service.get_current_account() if not account: - flash("Please sign in.", "error") + flash(_("Please sign in."), "error") return redirect(url_for("auth.login")) avatar_file_id = account.avatar_file_id if not avatar_file_id: - flash("No avatar to remove.", "error") + flash(_("No avatar to remove."), "error") return redirect(url_for("auth.profile")) try: self.file_service.delete_file(avatar_file_id) self.session_service.update_avatar(None) except Exception: - flash("Failed to remove profile photo.", "error") + flash(_("Failed to remove profile photo."), "error") return redirect(url_for("auth.profile")) - flash("Profile photo removed.", "success") + flash(_("Profile photo removed."), "success") return redirect(url_for("auth.profile")) def update_email(self): @@ -222,19 +224,19 @@ def update_email(self): """ account = self.session_service.get_current_account() if not account: - flash("Please sign in.", "error") + flash(_("Please sign in."), "error") return redirect(url_for("auth.login")) new_email = request.form.get("email", "").strip() if not new_email: - flash("Email is required.", "error") + flash(_("Email is required."), "error") return redirect(url_for("auth.profile")) result = self.session_service.update_email(new_email) if result is not None: - flash(result, "error") + flash(_(result), "error") else: - flash("Email updated.", "success") + flash(_("Email updated."), "success") return redirect(url_for("auth.profile")) def update_password(self): @@ -250,19 +252,19 @@ def update_password(self): """ account = self.session_service.get_current_account() if not account: - flash("Please sign in.", "error") + flash(_("Please sign in."), "error") return redirect(url_for("auth.login")) new_password = request.form.get("new_password", "") if not new_password: - flash("Password is required.", "error") + flash(_("Password is required."), "error") return redirect(url_for("auth.profile")) result = self.session_service.update_password(new_password) if result is not None: - flash(result, "error") + flash(_(result), "error") else: - flash("Password updated.", "success") + flash(_("Password updated."), "success") return redirect(url_for("auth.profile")) def list_all_users(self): @@ -339,7 +341,7 @@ def delete_account(self): """ current_account = self.session_service.get_current_account() if not current_account: - flash("Please sign in.", "error") + flash(_("Please sign in."), "error") return redirect(url_for("auth.login")) target_id = request.form.get("account_id", type=int) or current_account.account_id @@ -353,7 +355,7 @@ def delete_account(self): abort(403) target = self.session_service.get_account_by_id(target_id) if not target: - flash("Account not found.", "error") + flash(_("Account not found."), "error") return redirect(url_for("auth.list_all_users")) if target.account_role == AccountRole.ADMIN: abort(403) @@ -370,10 +372,10 @@ def delete_account(self): if is_self: self.session_service.terminate_session() - flash("Account deleted.", "success") + flash(_("Account deleted."), "success") return redirect(url_for("article.list_articles")) - flash("Account deleted.", "success") + flash(_("Account deleted."), "success") return redirect(url_for("auth.list_all_users")) def change_role(self, account_id: int): @@ -407,12 +409,12 @@ def change_role(self, account_id: int): target = self.session_service.get_account_by_id(account_id) if result is not None: - flash(result, "error") + flash(_(result), "error") if target: return redirect(url_for("auth.user_profile", username=target.account_username)) return redirect(url_for("auth.list_all_users")) - flash("Role updated.", "success") + flash(_("Role updated."), "success") if target: return redirect(url_for("auth.user_profile", username=target.account_username)) return redirect(url_for("auth.list_all_users")) @@ -445,9 +447,9 @@ def ban_account(self, account_id: int): ) if result is not None: - flash(result, "error") + flash(_(result), "error") else: - flash("Account banned.", "success") + flash(_("Account banned."), "success") return redirect(url_for("auth.list_all_users")) @@ -476,8 +478,8 @@ def unban_account(self, account_id: int): ) if result is not None: - flash(result, "error") + flash(_(result), "error") else: - flash("Account unbanned.", "success") + flash(_("Account unbanned."), "success") return redirect(url_for("auth.list_all_users")) diff --git a/src/infrastructure/input_adapters/flask/flask_article_adapter.py b/src/infrastructure/input_adapters/flask/flask_article_adapter.py index 3968af8c..51845954 100644 --- a/src/infrastructure/input_adapters/flask/flask_article_adapter.py +++ b/src/infrastructure/input_adapters/flask/flask_article_adapter.py @@ -1,6 +1,7 @@ import json import math +from flask_babel import gettext as _ from pydantic import ValidationError from werkzeug.wrappers.response import Response @@ -110,7 +111,7 @@ def read_article(self, article_id: int) -> str | Response: """ result = self.article_service.get_article_with_comments(article_id) if isinstance(result, str): - flash(f"Error: {result}", "error") + flash(_("Error: %(error)s", error=result), "error") return redirect(url_for("article.list_articles")) detail = result @@ -152,11 +153,11 @@ def render_create_page(self) -> str | Response: """ user = global_request_context.get("current_user") if not user: - flash("You must be signed in to author an article.", "error") + flash(_("You must be signed in to author an article."), "error") return redirect(url_for("auth.login")) if user.account_role not in ["admin", "author"]: - flash("Insufficient permissions: Only authors or admins can create articles.", "error") + flash(_("Insufficient permissions: Only authors or admins can create articles."), "error") return redirect(url_for("article.list_articles")) return render_template("article_create.html", current_user=user, page_with_editor=True) @@ -177,7 +178,7 @@ def api_get_article(self, article_id: int) -> Response | tuple[Response, int]: """ article = self.article_service.get_by_id(article_id) if not article: - return jsonify({"error": "Article not found."}), 404 + return jsonify({"error": _("Article not found.")}), 404 content = article.article_content try: @@ -213,13 +214,13 @@ def api_create_article(self) -> Response | tuple[Response, int]: """ user = global_request_context.get("current_user") if not user: - return jsonify({"error": "Unauthorized."}), 401 + return jsonify({"error": _("Unauthorized.")}), 401 if user.account_role not in ["admin", "author"]: - return jsonify({"error": "Insufficient permissions."}), 403 + return jsonify({"error": _("Insufficient permissions.")}), 403 data = request.get_json(silent=True) if not data: - return jsonify({"error": "Invalid JSON body."}), 400 + return jsonify({"error": _("Invalid JSON body.")}), 400 try: req_data = ArticleRequest( @@ -230,7 +231,7 @@ def api_create_article(self) -> Response | tuple[Response, int]: except ValidationError as e: for error in e.errors(): return jsonify({"error": f"({error['loc'][0]}): {error['msg']}"}), 400 - return jsonify({"error": "Validation error."}), 400 + return jsonify({"error": _("Validation error.")}), 400 result = self.article_service.create_article( title=req_data.title, content=req_data.content, @@ -258,13 +259,13 @@ def api_update_article(self, article_id: int) -> Response | tuple[Response, int] """ user = global_request_context.get("current_user") if not user: - return jsonify({"error": "Unauthorized."}), 401 + return jsonify({"error": _("Unauthorized.")}), 401 if user.account_role not in ["admin", "author"]: - return jsonify({"error": "Insufficient permissions."}), 403 + return jsonify({"error": _("Insufficient permissions.")}), 403 data = request.get_json(silent=True) if not data: - return jsonify({"error": "Invalid JSON body."}), 400 + return jsonify({"error": _("Invalid JSON body.")}), 400 try: req_data = ArticleRequest( @@ -275,7 +276,7 @@ def api_update_article(self, article_id: int) -> Response | tuple[Response, int] except ValidationError as e: for error in e.errors(): return jsonify({"error": f"({error['loc'][0]}): {error['msg']}"}), 400 - return jsonify({"error": "Validation error."}), 400 + return jsonify({"error": _("Validation error.")}), 400 result = self.article_service.update_article( article_id=article_id, user_id=user.account_id, @@ -303,9 +304,9 @@ def _api_delete_article(self, article_id: int) -> Response | tuple[Response, int """ user = global_request_context.get("current_user") if not user: - return jsonify({"error": "Unauthorized."}), 401 + return jsonify({"error": _("Unauthorized.")}), 401 if user.account_role not in ["admin", "author"]: - return jsonify({"error": "Insufficient permissions."}), 403 + return jsonify({"error": _("Insufficient permissions.")}), 403 result = self.article_service.delete_article( article_id=article_id, user_id=user.account_id, @@ -329,15 +330,15 @@ def delete_article_html(self, article_id: int) -> Response: """ user = global_request_context.get("current_user") if not user: - flash("You must be logged in to delete articles.", "error") + flash(_("You must be logged in to delete articles."), "error") return redirect(url_for("auth.login")) result = self._api_delete_article(article_id) if isinstance(result, tuple): - flash(result[0].get_json()["error"], "error") + flash(_(result[0].get_json()["error"]), "error") else: - flash("Article deleted successfully.", "success") + flash(_("Article deleted successfully."), "success") return redirect(url_for("article.list_articles")) @@ -354,20 +355,20 @@ def render_edit_page(self, article_id: int) -> str | Response: """ user = global_request_context.get("current_user") if not user: - flash("You must be signed in to edit an article.", "error") + flash(_("You must be signed in to edit an article."), "error") return redirect(url_for("auth.login")) if user.account_role not in ["admin", "author"]: - flash("Insufficient permissions: Only authors or admins can create articles.", "error") + flash(_("Insufficient permissions: Only authors or admins can create articles."), "error") return redirect(url_for("article.list_articles")) domain_article = self.article_service.get_by_id(article_id) if not domain_article: - flash("Error: The requested article could not be found.", "error") + flash(_("Error: The requested article could not be found."), "error") return redirect(url_for("article.list_articles")) if user.account_role != "admin" and domain_article.article_author_id != user.account_id: - flash("You do not have permission to edit this article.", "error") + flash(_("You do not have permission to edit this article."), "error") return redirect(url_for("article.list_articles")) username = self.article_service.get_author_name(domain_article.article_author_id) diff --git a/src/infrastructure/input_adapters/flask/flask_comment_adapter.py b/src/infrastructure/input_adapters/flask/flask_comment_adapter.py index 116fcec0..8ade8c83 100644 --- a/src/infrastructure/input_adapters/flask/flask_comment_adapter.py +++ b/src/infrastructure/input_adapters/flask/flask_comment_adapter.py @@ -1,5 +1,6 @@ import time +from flask_babel import gettext as _ from pydantic import ValidationError from werkzeug.wrappers.response import Response @@ -60,7 +61,7 @@ def create_comment(self, article_id: int) -> Response: """ user = global_request_context.get("current_user") if not user: - flash("You must be signed in to post a comment.", "error") + flash(_("You must be signed in to post a comment."), "error") return redirect(url_for("auth.login")) if request.form.get("hp_comment"): @@ -71,12 +72,12 @@ def create_comment(self, article_id: int) -> Response: except ValidationError as e: for error in e.errors(): msg = error["msg"].removeprefix("Value error, ") - flash(msg, "error") + flash(_(msg), "error") return redirect(url_for("article.read_article", article_id=article_id)) remaining = self._check_comment_rate_limit(user.account_id) if remaining is not None: - flash(f"You're posting too fast. Please wait {remaining}s before posting again.", "warning") + flash(_("You're posting too fast. Please wait %(remaining)ss before posting again.", remaining=remaining), "warning") return redirect(url_for("article.read_article", article_id=article_id)) result = self.comment_service.create_comment( @@ -86,9 +87,9 @@ def create_comment(self, article_id: int) -> Response: ) if isinstance(result, str): - flash(result, "error") + flash(_(result), "error") else: - flash("Comment added.", "success") + flash(_("Comment added."), "success") return redirect(url_for("article.read_article", article_id=article_id)) @@ -105,7 +106,7 @@ def reply_to_comment(self, article_id: int, parent_comment_id: int) -> Response: """ user = global_request_context.get("current_user") if not user: - flash("You must be signed in to reply.", "error") + flash(_("You must be signed in to reply."), "error") return redirect(url_for("auth.login")) if request.form.get("hp_comment"): @@ -116,12 +117,12 @@ def reply_to_comment(self, article_id: int, parent_comment_id: int) -> Response: except ValidationError as e: for error in e.errors(): msg = error["msg"].removeprefix("Value error, ") - flash(msg, "error") + flash(_(msg), "error") return redirect(url_for("article.read_article", article_id=article_id)) remaining = self._check_comment_rate_limit(user.account_id) if remaining is not None: - flash(f"You're posting too fast. Please wait {remaining}s before posting again.", "warning") + flash(_("You're posting too fast. Please wait %(remaining)ss before posting again.", remaining=remaining), "warning") return redirect(url_for("article.read_article", article_id=article_id)) result = self.comment_service.create_reply( @@ -131,9 +132,9 @@ def reply_to_comment(self, article_id: int, parent_comment_id: int) -> Response: ) if isinstance(result, str): - flash(result, "error") + flash(_(result), "error") else: - flash("Reply added.", "success") + flash(_("Reply added."), "success") return redirect(url_for("article.read_article", article_id=article_id)) @@ -150,7 +151,7 @@ def delete_comment(self, article_id: int, comment_id: int) -> Response: """ user = global_request_context.get("current_user") if not user: - flash("You must be signed in to delete comments.", "error") + flash(_("You must be signed in to delete comments."), "error") return redirect(url_for("auth.login")) result = self.comment_service.delete_comment( @@ -159,11 +160,11 @@ def delete_comment(self, article_id: int, comment_id: int) -> Response: ) if isinstance(result, str): - flash(result, "error") + flash(_(result), "error") elif result is True: - flash("Comment deleted.", "success") + flash(_("Comment deleted."), "success") else: - flash("Unauthorized or error.", "error") + flash(_("Unauthorized or error."), "error") return redirect(url_for("article.read_article", article_id=article_id)) @@ -181,7 +182,7 @@ def edit_comment(self, article_id: int, comment_id: int) -> Response: """ user = global_request_context.get("current_user") if not user: - flash("You must be signed in to edit comments.", "error") + flash(_("You must be signed in to edit comments."), "error") return redirect(url_for("auth.login")) content = request.form.get("content", "") @@ -192,9 +193,9 @@ def edit_comment(self, article_id: int, comment_id: int) -> Response: ) if isinstance(result, str): - flash(result, "error") + flash(_(result), "error") else: - flash("Comment updated.", "success") + flash(_("Comment updated."), "success") return redirect(url_for("article.read_article", article_id=article_id)) @@ -212,7 +213,7 @@ def hard_delete_comment(self, article_id: int, comment_id: int) -> Response: """ user = global_request_context.get("current_user") if not user: - flash("You must be signed in to delete comments.", "error") + flash(_("You must be signed in to delete comments."), "error") return redirect(url_for("auth.login")) result = self.comment_service.hard_delete_comment( @@ -221,10 +222,10 @@ def hard_delete_comment(self, article_id: int, comment_id: int) -> Response: ) if isinstance(result, str): - flash(result, "error") + flash(_(result), "error") elif result is True: - flash("Comment permanently deleted.", "success") + flash(_("Comment permanently deleted."), "success") else: - flash("Unauthorized or error.", "error") + flash(_("Unauthorized or error."), "error") return redirect(url_for("article.read_article", article_id=article_id)) diff --git a/src/infrastructure/input_adapters/flask/flask_file_adapter.py b/src/infrastructure/input_adapters/flask/flask_file_adapter.py index 886a0cce..5e744afb 100644 --- a/src/infrastructure/input_adapters/flask/flask_file_adapter.py +++ b/src/infrastructure/input_adapters/flask/flask_file_adapter.py @@ -1,5 +1,7 @@ from io import BytesIO +from flask_babel import gettext as _ + from flask import jsonify, request, send_file from src.application.application_exceptions import FileTooLargeError, FileTypeError from src.application.input_ports.file_management import FileManagementPort @@ -28,7 +30,7 @@ def upload_image(self): """ uploaded_file = request.files.get("file") if uploaded_file is None or not uploaded_file.filename: - return jsonify({"error": "No file provided"}), 400 + return jsonify({"error": _("No file provided")}), 400 file_data = uploaded_file.read() @@ -68,7 +70,7 @@ def serve_file(self, file_id: str, filename: str): """ file_record = self.file_service.get_file(file_id) if file_record is None: - return jsonify({"error": "File not found"}), 404 + return jsonify({"error": _("File not found")}), 404 return send_file( BytesIO(file_record.data), diff --git a/src/infrastructure/input_adapters/flask/flask_login_adapter.py b/src/infrastructure/input_adapters/flask/flask_login_adapter.py index afabf812..5bef17cc 100644 --- a/src/infrastructure/input_adapters/flask/flask_login_adapter.py +++ b/src/infrastructure/input_adapters/flask/flask_login_adapter.py @@ -1,3 +1,4 @@ +from flask_babel import gettext as _ from pydantic import ValidationError from flask import flash, redirect, render_template, request, url_for @@ -51,7 +52,7 @@ def authenticate(self): except ValidationError as e: for error in e.errors(): location = str(error["loc"][0]) if error["loc"] else "Request" - flash(f"Validation Error ({location}): {error['msg']}", "error") + flash(_("Validation Error (%(location)s): %(message)s", location=location, message=error["msg"]), "error") return render_template("login.html", current_user=user, username=submitted_username) result = self.login_service.authenticate_user( @@ -63,7 +64,7 @@ def authenticate(self): return redirect(url_for("article.list_articles")) if result == "This account has been banned.": - flash(result, "error") + flash(_(result), "error") else: - flash("Invalid username or password.", "error") + flash(_("Invalid username or password."), "error") return render_template("login.html", current_user=user, username=login_data.username) diff --git a/src/infrastructure/input_adapters/flask/flask_registration_adapter.py b/src/infrastructure/input_adapters/flask/flask_registration_adapter.py index 3cf00bb7..7e457164 100644 --- a/src/infrastructure/input_adapters/flask/flask_registration_adapter.py +++ b/src/infrastructure/input_adapters/flask/flask_registration_adapter.py @@ -1,3 +1,4 @@ +from flask_babel import gettext as _ from pydantic import ValidationError from flask import flash, redirect, render_template, request, url_for @@ -54,7 +55,7 @@ def register(self): except ValidationError as e: for error in e.errors(): location = str(error["loc"][0]) if error["loc"] else "Request" - flash(f"{location}: {error['msg']}", "error") + flash(_("%(location)s: %(message)s", location=location, message=error["msg"]), "error") return render_template("registration.html", current_user=user, username=submitted_username, email=submitted_email) result = self.registration_service.create_account( @@ -64,8 +65,8 @@ def register(self): ) if isinstance(result, str): - flash(result, "error") + flash(_(result), "error") return render_template("registration.html", current_user=user, username=reg_data.username, email=reg_data.email) - flash("Registration successful. Please sign in.", "success") + flash(_("Registration successful. Please sign in."), "success") return redirect(url_for("auth.login")) diff --git a/tests/conftest.py b/tests/conftest.py index fce3dd4c..8f9f3949 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -43,6 +43,7 @@ def app_with_db(db_session): app = create_app(db_session=db_session) app.config["TESTING"] = True app.config["WTF_CSRF_ENABLED"] = False + app.extensions["babel"].locale_selector = lambda: "en" return app @pytest.fixture(scope="function") diff --git a/tests/test_template_helpers.py b/tests/test_template_helpers.py index 3b7d602c..9894f90e 100644 --- a/tests/test_template_helpers.py +++ b/tests/test_template_helpers.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import UTC, datetime import pytest from flask import render_template_string @@ -118,3 +118,52 @@ def test_pads_single_digit_month_and_day(self): def test_returns_empty_string_for_none(self): result = date_iso_filter(None) assert result == "" + + +class TestFormatDatetimeLocaleFilter: + """Tests for the format_datetime_locale Jinja2 filter.""" + + def test_formats_datetime_in_default_locale(self, app_with_db): + from flask import render_template_string + dt = datetime(2023, 1, 27, 12, 0, 0) + with app_with_db.app_context(): + result = render_template_string( + "{{ dt|format_datetime_locale }}", dt=dt + ) + assert result == "27 January 2023 à 13:00" + + def test_returns_empty_string_for_none(self, app_with_db): + from flask import render_template_string + with app_with_db.app_context(): + result = render_template_string( + "{{ dt|format_datetime_locale }}", dt=None + ) + assert result == "" + + def test_formats_datetime_with_custom_format(self, app_with_db): + from flask import render_template_string + dt = datetime(2023, 6, 15, 8, 30) + with app_with_db.app_context(): + result = render_template_string( + '{{ dt|format_datetime_locale("d MMMM yyyy") }}', dt=dt + ) + assert result == "15 June 2023" + + def test_converts_utc_to_paris_timezone(self, app_with_db): + from flask import render_template_string + dt = datetime(2023, 1, 27, 12, 0, 0, tzinfo=UTC) + with app_with_db.app_context(): + result = render_template_string( + "{{ dt|format_datetime_locale }}", dt=dt + ) + assert result == "27 January 2023 à 13:00" + + def test_formats_in_french_locale(self, app_with_db): + from flask import render_template_string + dt = datetime(2023, 1, 27, 12, 0, 0) + with app_with_db.app_context(): + app_with_db.extensions["babel"].locale_selector = lambda: "fr" + result = render_template_string( + "{{ dt|format_datetime_locale }}", dt=dt + ) + assert result == "27 janvier 2023 à 13:00" diff --git a/tests/tests_infrastructure/tests_input_adapters/dto/test_article_response.py b/tests/tests_infrastructure/tests_input_adapters/dto/test_article_response.py index 761d0128..eb67ddd7 100644 --- a/tests/tests_infrastructure/tests_input_adapters/dto/test_article_response.py +++ b/tests/tests_infrastructure/tests_input_adapters/dto/test_article_response.py @@ -1,4 +1,4 @@ -from datetime import UTC, datetime +from datetime import datetime from src.application.domain.article import Article from src.infrastructure.input_adapters.dto.article_response import ArticleResponse @@ -74,8 +74,8 @@ def test_from_domain_with_description(self): assert result.meta_description == "My short description" def test_from_domain_with_article_edited_at(self): - """article_edited_at formaté en Europe/Paris (UTC+1 en janvier).""" - dt = datetime(2023, 1, 27, 12, 0, 0, tzinfo=UTC) + """article_edited_at conservé tel quel (datetime brut).""" + dt = datetime(2023, 1, 27, 12, 0, 0) domain_article = Article( article_id=1, article_author_id=10, article_title="Title", article_content="Content", article_published_at=datetime.now(), @@ -83,14 +83,12 @@ def test_from_domain_with_article_edited_at(self): ) response = ArticleResponse.from_domain(domain_article, "user") assert response.article_edited_at == dt - assert response.article_edited_at_formatted == "January 27, 2023 at 13:00" def test_from_domain_without_article_edited_at(self): - """article_edited_at_formatted vide si jamais édité.""" + """article_edited_at None si jamais édité.""" domain_article = Article( article_id=1, article_author_id=10, article_title="Title", article_content="Content", article_published_at=datetime.now(), ) response = ArticleResponse.from_domain(domain_article, "user") assert response.article_edited_at is None - assert response.article_edited_at_formatted == "" diff --git a/tests/tests_infrastructure/tests_input_adapters/dto/test_comment_response.py b/tests/tests_infrastructure/tests_input_adapters/dto/test_comment_response.py index 9b020e73..3fcd5ebc 100644 --- a/tests/tests_infrastructure/tests_input_adapters/dto/test_comment_response.py +++ b/tests/tests_infrastructure/tests_input_adapters/dto/test_comment_response.py @@ -22,7 +22,7 @@ def test_comment_response_from_domain_mapping(): assert response.author_username == "johndoe" assert response.comment_reply_to is None assert response.comment_content == "Hello world" - assert response.comment_posted_at_formatted == "October 27, 2023 at 16:30" + assert response.comment_posted_at == posted_at assert response.is_deleted is False assert response.edited_at is None @@ -74,7 +74,6 @@ def test_comment_response_from_domain_edited(): assert response.comment_content == "Edited content" assert response.is_deleted is False assert response.edited_at == edited_at - assert response.edited_at_formatted == "October 28, 2023 at 12:00" def test_comment_response_from_domain_deleted_with_edited_at(): posted_at = datetime(2023, 10, 27, 14, 30) @@ -95,7 +94,6 @@ def test_comment_response_from_domain_deleted_with_edited_at(): assert response.comment_content == "Comment removed" assert response.is_deleted is True assert response.edited_at == edited_at - assert response.edited_at_formatted == "October 28, 2023 at 12:00" def test_comment_response_from_domain_with_all_fields(): posted_at = datetime(2023, 10, 27, 14, 30) @@ -120,7 +118,6 @@ def test_comment_response_from_domain_with_all_fields(): assert response.author_avatar_file_id == "avatar-uuid" assert response.is_deleted is False assert response.edited_at is None - assert response.edited_at_formatted == "" def test_map_nested_tree(): posted_at = datetime(2023, 10, 27, 14, 30) diff --git a/tests/tests_infrastructure/tests_input_adapters/tests_flask/flask_test_utils.py b/tests/tests_infrastructure/tests_input_adapters/tests_flask/flask_test_utils.py index a9d1d718..cd6e4f37 100644 --- a/tests/tests_infrastructure/tests_input_adapters/tests_flask/flask_test_utils.py +++ b/tests/tests_infrastructure/tests_input_adapters/tests_flask/flask_test_utils.py @@ -2,10 +2,11 @@ from flask import Flask, get_flashed_messages, request from flask import g as global_request_context +from flask_babel import Babel from flask_wtf.csrf import CSRFProtect from utils.prosemirror_to_html import prosemirror_to_html -from utils.template_helpers import date_format_filter, date_iso_filter, nl2br_filter +from utils.template_helpers import date_format_filter, date_iso_filter, format_datetime_locale, nl2br_filter class FlaskInputAdapterTestBase: @@ -65,10 +66,13 @@ def setup_method(self) -> None: self.app.jinja_env.filters["date_format"] = date_format_filter self.app.jinja_env.filters["date_iso"] = date_iso_filter self.app.jinja_env.filters["prosemirror_to_html"] = prosemirror_to_html + self.app.jinja_env.filters["format_datetime_locale"] = format_datetime_locale self.app.config["SECRET_KEY"] = "test_secret" self.app.config["SERVER_NAME"] = "localhost" self.app.config["TESTING"] = True self.app.config["WTF_CSRF_ENABLED"] = False + Babel(self.app) + self.app.extensions["babel"].locale_selector = lambda: "en" CSRFProtect(self.app) self._test_user = None self._dummy_labels = {} diff --git a/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_file_adapter.py b/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_file_adapter.py new file mode 100644 index 00000000..8450e65d --- /dev/null +++ b/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_file_adapter.py @@ -0,0 +1,109 @@ +from io import BytesIO +from unittest.mock import Mock + +from src.application.domain.file_record import FileRecord +from src.application.input_ports.file_management import FileManagementPort +from src.infrastructure.input_adapters.flask.flask_file_adapter import FlaskFileAdapter +from tests.tests_infrastructure.tests_input_adapters.tests_flask.flask_test_utils import ( + FlaskInputAdapterTestBase, +) + + +class FlaskFileAdapterTest(FlaskInputAdapterTestBase): + def setup_method(self): + super().setup_method() + self.mock_file_service = Mock(spec=FileManagementPort, autospec=True) + self.adapter = FlaskFileAdapter(file_service=self.mock_file_service) + + self.app.add_url_rule( + "/api/upload/image", + view_func=self.adapter.upload_image, + methods=["POST"], + endpoint="file.upload_image", + ) + + self.app.add_url_rule( + "/uploads//", + view_func=self.adapter.serve_file, + methods=["GET"], + endpoint="file.serve_file", + ) + + +class TestFileUpload(FlaskFileAdapterTest): + def test_upload_image_success(self): + record = FileRecord( + file_id="uuid-123", + original_filename="photo.jpg", + mime_type="image/jpeg", + data=b"fake_image_data", + size=len(b"fake_image_data"), + ) + self.mock_file_service.upload_file.return_value = record + + data = {"file": (BytesIO(b"fake_image_data"), "photo.jpg")} + response = self.client.post( + "/api/upload/image", + data=data, + content_type="multipart/form-data", + ) + + assert response.status_code == 201 + assert response.json is not None + assert response.json["url"] == "/uploads/uuid-123/photo.jpg" + self.mock_file_service.upload_file.assert_called_once_with( + filename="photo.jpg", + data=b"fake_image_data", + mime_type="image/jpeg", + ) + + def test_upload_image_no_file_provided(self): + response = self.client.post( + "/api/upload/image", + data={}, + content_type="multipart/form-data", + ) + + assert response.status_code == 400 + assert b"No file provided" in response.data + self.mock_file_service.upload_file.assert_not_called() + + def test_upload_image_without_filename(self): + data = {"file": (BytesIO(b"data"), "")} + response = self.client.post( + "/api/upload/image", + data=data, + content_type="multipart/form-data", + ) + + assert response.status_code == 400 + assert b"No file provided" in response.data + self.mock_file_service.upload_file.assert_not_called() + + +class TestFileServe(FlaskFileAdapterTest): + def test_serve_file_success(self): + record = FileRecord( + file_id="uuid-456", + original_filename="photo.jpg", + mime_type="image/jpeg", + data=b"fake_image_data", + size=len(b"fake_image_data"), + ) + self.mock_file_service.get_file.return_value = record + + response = self.client.get("/uploads/uuid-456/photo.jpg") + + assert response.status_code == 200 + assert response.mimetype == "image/jpeg" + assert response.data == b"fake_image_data" + self.mock_file_service.get_file.assert_called_once_with("uuid-456") + + def test_serve_file_not_found(self): + self.mock_file_service.get_file.return_value = None + + response = self.client.get("/uploads/uuid-999/missing.jpg") + + assert response.status_code == 404 + assert b"File not found" in response.data + self.mock_file_service.get_file.assert_called_once_with("uuid-999") diff --git a/translations/fr/LC_MESSAGES/messages.mo b/translations/fr/LC_MESSAGES/messages.mo new file mode 100644 index 0000000000000000000000000000000000000000..7adcb3d4e6428668bb90b0c20809f1721b6549e4 GIT binary patch literal 10855 zcmb7}e~et$RmX3dHfbC}el#S7HsmJ6+1(^NUORQ2t`plEdmYwU+pGQIG$bj!oq0R^ zoSFBg@5kC}l>$-`2~t2+OH>L;BUIFEf%XsD6p1P=T@XZ7g+vJakrGNORTZ>UG*KfE zAU@yw?wfhDwqt6|&S&m@_uU`oo^$Rw*RNdj*55Jw`(ysz&EJRKrqbZwH@6w{(-i*+ zj=(qImGH*PjkyZ$fY-nS@M^dKFNGnz1a{$1!5pgHZ$kC=6ubd`$=AOGub}*0_)hpE zsQ0h@iE^Cl;Kh_jpz3e;+yynC_j%61pQQXC)cjWAI9!KX-)DUNU&3oCzXUbkfA#f0 z^yQ10%sZ&R9KIFa2wA$h1*-oYQ0v_X`ItGrG_MapJrChcFo%2LlaM8uzk}-U6(~7; z8%pH=1NoSXd0BG08mgaL_|iP?gg3(bJljy~`wch&KLM|YFZk#G0wtG=_~NIz8vZP_ z@GWpZ)I9Em8uvb^`7FU{m_W_ri%|RYH;|9{4quwjk07El+n9vpdOehU-UB6v{ZP*j zLyfxtuY;-Qi2M0kw|H*^HavRZ!#I z3AMlTkS3<>c^dLDkMm{W@4>gjFGK0+UqL?RWxk|`--nXRB?w~=wD1=AekghTGSs}D zfLi}2q5AtnsP%jfYTeKI@;9L5^AglJ--M&^-=X@so<&I?ZhQc&HF{Dd4AhJ|DJ#TIvl6|G8%7(d!YJ#1Tsa_ zf|}PU_%rY$(85na>HAlq*83{dJpU7FJ(nU}^>YQ(``1Fv<0fC;3AMg`Q1dzn_1-MJ z1I|Oq>sO%O|ETBFQ2O~ta00#nwO{`QC5LO7%mYeh9UWx1mg0$K_D^eHZ+#i;TG& zN{(Nr6E_bixm`)6`o96PC1wYd9v_67*CJHE4?~79%TVK;@q7YmJ9( ztx)4mK=peB@-g##Y1{-#&)1;V`RkrfdVU6~pFe|=$JgKp{0Ddd{x8%%?ME53PxnKu ze-UauIh0(_K)wGMl)Qc)GF9_=sQ$k0`I>+JE}V(>tpVj<9)dgIhoRQ_IjH{r8cM%j zhHk$ZT%*O8;JivcET=R*C`6+mS@^et*ya6TOk)JEu z9e|Sa15o??DAamRLG}A#sCj$(3B$rXB_3nkJqB#uT3s)efVV;6};Ge@g;cIX&9AVS7z9yW6pMZMrMR*(B zHfGE(!QJpu_$1W4p7#7K)VR+4m91NihyOHfT1JBF?jh+Ub{F z4&pd$4a%*s6Xx}bILYk_O>1q6c<`3ypi%Q^Ei_ZDmVGcO7K`Q3u0^ddu}kx_W~vjl zW1EMkb9=JL@@Qq9sx%B*>vlC*3vCcr^RoNRRGLT4PRQKdJZx6D1+5gF&N{R7PwSKN zRe6+mm_d=RhA~|@;n+0%U=gKmIXp;Gb8pam)aHo|%C|BqV*Mclb1;aTVaFU?4dQla z=YlLdm84X!CP^0Br7T2*-OwCNx?P5_L93;Xt72bpRYjMzS_H8i=BL6iwmVs9%kG|- zn6S;&APt&%m}dRg*`;m}({E?JTKk~yqSp%Crue=yJGy<;?cZ27Mi=o)ly)oDW}d`3 zJ@TLzg)J?+*@=3~NuX6Fy><6x??sg!H3!*{JhWads^Va4%%QMJ69myi(I{zqJo;~y zwKiXkvP#0XzOen(3kAZo58u3fR6iuuvFzcS?S8xH-b9&_*wROorl@e5Ma!MA!Mj;A zje2@$rdv_&zHFruq*Hq(eO zb!)b9!SmFP?zH1Fx#09zYwXRRvpLggnxrz(P(LtGU$M9vx`~7t8`~Ead)jGPX*Y`F zt?n>ODR-}v1pTHBGpJ9)Zh{^4(qx6Ewzrz(N&R_-l~LBSzTv2-l`XH^Y7cCZ+E9XP zG#ci<#Qc1Mw$H@%ZJ&v=Vr3<2Mlt~HY?Nhk01eum`^ls|9;4EMGrvqj1g&lqXSRuM zscV$3GDh{X7IdPPJ+g58m|adG(d|k3 zo#M_iAa?1sCSxmWYBP&~qqwd0&nE4p$j#9pM%|t9XG4X71-6_3+3KUg>8M+D?atjp z7G71~Ic)exL#Is(5l+V()&3nyPQ~S~rJgyqW6B)Eb0q1b)~QZ|;;Cxh?swlq>4BG_ zd%#*Hf@~e#>f%$6;d;%nfzvBB%OW`^sUQ!QF>D5?HR%R6(_dNjL(X7e zw*A_^CF}jpmW}NmxcMT*I74n__ru2GJZJQtytQy7cz&;T(R7VvQ;zB*m%DZ zb~JI$FzAd?(~hz{El<6{nc1+z({0eu`HDID2nw2n`>u|&`7kF`(6Ux#{__cks=fwZ zash8*`0|Fb5C&;;)s&DsaR>1o9AbTM?X2E809n=o=WsssxVDZ3iFGD6p2Ox@RpLH! z!iTcaajeumz-Ta*JC9icVO|bAvE-3*LPE~dw_rUDX@4Vd2JZ!GUL=qjl z+)3Kn>?~|1aSLxptDIe1RLn7~n~O=iP2J*Zi1d7+=iL>W!ci7=qI}&f#Q}#}>6TJX zRd2tR0d!_JY7NpbZsDoV)xrriI>YfG->nnIz1Xi-iCE1nxd6h$mZxJ`H;5LNdQKFj zW-M_o8-2NuM?W%iLaw>gFn5T_1rEZ{vEewh*4WSMgv=yGLy0+v~AX7c5!ZKf9ns6Ds zRU&4C>n$^Mk{-&Iw(&YVQPX9=V>B0H{Zo2*}w! zR7Qr6n1>X&Jz~N}yK#p-x;}S+AbTQh-Q;T)dO`ONHZR3u6lBX^lx@jne$izy;kvI; zKyV;D9dvsHhpf{`H0)ELc`9Be*mA08zP3TlI(}*_tDW`@JJspfG-|KrI$Y>&P4WDz zzxr``;gexAAD?NBpHTRj#FKVz{`isUgNydW^!&oi@nhTYn)tEtqggv@jo(|ev+>1b z(oP?pnwdQ?b?DIi^uofnx#NrDF0iJD@k2~$((anr_1^J`J>$D}5hqOU-hKPTu89d6 zj?Z%vXryzrd(Y%f-)@#I8DC6;IO_zt;>yEIv$OWt)X`~s_t4P$w#|~o6u7iWyOQ<` zXk&bt`G)ZL67b~EHFn;Wh0jNN?3t|dg3Nw-IU$MIrY zn;S0u?S%zi@t}L)(%q%<9nZEK{+Im0qGi{6o|3^gFjwt)oVh1sv{uaB5Ito)H{-b!#O<#|W% zbV3(9ZJ68L)+NaB7O~;k5zklOZ*cQpjhxovbEw&fHn6FSF=! z*M`^cRJ@=?FWTI&EfiQg+f!BaNtATHRJLG!dNF2}1tbyY=llSI%83wQ_A4%(;^7qZq36ndGGWF;KtZ zqq+<%rd+-qmbs*v9dS9ORo3M~cy!yfvqL*H6Qeopv=jsZ{N_f|H>hc;#u-V06nt|d zO-kJ)FFPw2zi_)UCr5RiCvgWvnU$ASeZyjmE*CazxMn_Ng`1BzS25LA;e1@XRKvPa zHcZt7OATCNBW1yNINN2X6@I%_xVV37`=D40+uYJ%#D}Bis$5BJ0xorzqAOj zqLb~(X+}hdGJPapy9IEf@YVhbD~*xtU{=Grj5kcntyK_mXmbNk6^EzULvtTGC?T?< znk&rCS3h)BFmRn)ZwzYgZs^LOUeBF@^Z&gf%8c&ANDWe5A81#(Gnm))k`x)4XJJrC zrtAV2zvXFC7K34_p07{Qo=PS944qt?Y>(eF+#gq2S>qQH+}!A3GhVt%AWEfO8XOOdx;56WJ$3(&psi!Y?no?S z|KS^%ExD3E8iwA$0mvuF6ISL-cEEa5mo+GkNhRZ&%O6sr6Y?_g2&u~%WeYBf?Z*_F zT(=4tYw@WDqp`p-+!y3b0${b!{c>mLgYFgU(1{2qgBSE97GH=pge*jo;@d{)0DDr?TVCE~gulFH#V>eocr(s}QR7NxDNlyucuiEfGr4$7;d F{{yT*GJ*gA literal 0 HcmV?d00001 diff --git a/translations/fr/LC_MESSAGES/messages.po b/translations/fr/LC_MESSAGES/messages.po new file mode 100644 index 00000000..0f52e199 --- /dev/null +++ b/translations/fr/LC_MESSAGES/messages.po @@ -0,0 +1,849 @@ +# French translations for PROJECT. +# Copyright (C) 2026 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2026-07-22 15:55+0200\n" +"PO-Revision-Date: 2026-07-22 14:15+0200\n" +"Last-Translator: FULL NAME \n" +"Language: fr\n" +"Language-Team: fr \n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.18.0\n" + +#: frontend/templates/article_create.html:3 +msgid "Write an Article - DevJournal" +msgstr "Écrire un article - DevJournal" + +#: frontend/templates/article_create.html:15 +#: frontend/templates/article_detail.html:47 +#: frontend/templates/article_edit.html:40 +msgid "Back to articles" +msgstr "Retour aux articles" + +#: frontend/templates/article_create.html:28 +#: frontend/templates/article_edit.html:57 +msgid "Title is required." +msgstr "Le titre est requis." + +#: frontend/templates/article_create.html:29 +#: frontend/templates/article_edit.html:58 +msgid "Failed to save." +msgstr "Échec de la sauvegarde." + +#: frontend/templates/article_create.html:30 +#: frontend/templates/article_edit.html:59 +msgid "Network error." +msgstr "Erreur réseau." + +#: frontend/templates/article_create.html:31 +#: frontend/templates/article_detail.html:234 +#: frontend/templates/article_edit.html:60 +msgid "Loading..." +msgstr "Chargement..." + +#: frontend/templates/article_create.html:32 +#: frontend/templates/article_edit.html:61 +msgid "Unable to parse article content." +msgstr "Impossible de lire le contenu de l'article." + +#: frontend/templates/article_create.html:33 +#: frontend/templates/article_edit.html:62 +msgid "Article title" +msgstr "Titre de l'article" + +#: frontend/templates/article_create.html:34 +#: frontend/templates/article_edit.html:63 +msgid "Description" +msgstr "Description" + +#: frontend/templates/article_create.html:35 +#: frontend/templates/article_edit.html:64 +msgid "Maximum 300 characters" +msgstr "Maximum 300 caractères" + +#: frontend/templates/article_create.html:36 +#: frontend/templates/article_edit.html:65 +msgid "Short description (optional)" +msgstr "Description courte (optionnelle)" + +#: frontend/templates/article_create.html:37 +#: frontend/templates/article_edit.html:66 +msgid "Content" +msgstr "Contenu" + +#: frontend/templates/article_create.html:38 +#: frontend/templates/article_edit.html:67 +msgid "Saving..." +msgstr "Sauvegarde..." + +#: frontend/templates/article_create.html:39 +#: frontend/templates/article_edit.html:68 +msgid "Publish" +msgstr "Publier" + +#: frontend/templates/article_create.html:40 +#: frontend/templates/article_detail.html:155 +#: frontend/templates/article_edit.html:69 frontend/templates/profile.html:94 +#: frontend/templates/profile.html:116 +msgid "Save" +msgstr "Enregistrer" + +#: frontend/templates/article_create.html:41 +#: frontend/templates/article_edit.html:70 +msgid "YouTube" +msgstr "YouTube" + +#: frontend/templates/article_create.html:42 +#: frontend/templates/article_edit.html:71 +msgid "Paste a YouTube video URL" +msgstr "Collez une URL YouTube" + +#: frontend/templates/article_create.html:43 +#: frontend/templates/article_edit.html:72 +msgid "YouTube URL" +msgstr "URL YouTube" + +#: frontend/templates/article_create.html:44 +#: frontend/templates/article_edit.html:73 +msgid "Paste YouTube video link" +msgstr "Collez un lien YouTube" + +#: frontend/templates/article_create.html:45 +#: frontend/templates/article_edit.html:74 +msgid "Embed YouTube video" +msgstr "Intégrer la vidéo YouTube" + +#: frontend/templates/article_create.html:46 +#: frontend/templates/article_edit.html:75 +msgid "Add YouTube video URL" +msgstr "Ajouter une URL YouTube" + +#: frontend/templates/article_create.html:47 +#: frontend/templates/article_edit.html:76 +msgid "Only YouTube links are supported" +msgstr "Seules les liens YouTube sont supportés" + +#: frontend/templates/article_create.html:48 +#: frontend/templates/article_edit.html:77 +msgid "Failed to load article." +msgstr "Échec du chargement de l'article." + +#: frontend/templates/article_create.html:49 +#: frontend/templates/article_edit.html:78 +msgid "Copy" +msgstr "Copier" + +#: frontend/templates/article_create.html:50 +#: frontend/templates/article_edit.html:79 +msgid "Copy Block" +msgstr "Copier le bloc" + +#: frontend/templates/article_create.html:51 +#: frontend/templates/article_edit.html:80 +msgid "Copied to clipboard" +msgstr "Copié dans le presse-papier" + +#: frontend/templates/article_create.html:52 +#: frontend/templates/article_edit.html:81 +msgid "Move Up" +msgstr "Monter" + +#: frontend/templates/article_create.html:53 +#: frontend/templates/article_edit.html:82 +msgid "Move Down" +msgstr "Descendre" + +#: frontend/templates/article_create.html:54 +#: frontend/templates/article_detail.html:79 +#: frontend/templates/article_edit.html:83 frontend/templates/base.html:109 +#: frontend/templates/user_list.html:95 +msgid "Delete" +msgstr "Supprimer" + +#: frontend/templates/article_create.html:55 +#: frontend/templates/article_edit.html:84 +msgid "Align text justify" +msgstr "Justifier le texte" + +#: frontend/templates/article_create.html:56 +#: frontend/templates/article_detail.html:236 +#: frontend/templates/article_edit.html:85 +msgid "Something went wrong. Please reload the page." +msgstr "Une erreur est survenue. Veuillez recharger la page." + +#: frontend/templates/article_detail.html:72 +#: frontend/templates/article_detail.html:145 +#: frontend/templates/article_list.html:88 +#, python-format +msgid "edited at %(date)s" +msgstr "édité le %(date)s" + +#: frontend/templates/article_detail.html:78 +msgid "Edit" +msgstr "Modifier" + +#: frontend/templates/article_detail.html:79 +msgid "Delete this article permanently ?" +msgstr "Supprimer définitivement cet article ?" + +#: frontend/templates/article_detail.html:126 +#: frontend/templates/profile.html:53 +msgid "[Edit]" +msgstr "[Modifier]" + +#: frontend/templates/article_detail.html:131 +#: frontend/templates/article_detail.html:137 +msgid "[Delete]" +msgstr "[Supprimer]" + +#: frontend/templates/article_detail.html:135 +msgid "Permanently delete this comment? This cannot be undone." +msgstr "Supprimer définitivement ce commentaire ? Action irréversible." + +#: frontend/templates/article_detail.html:152 +#: frontend/templates/article_detail.html:176 +#: frontend/templates/article_detail.html:213 +msgid "Comment must be between 1 and 5000 characters" +msgstr "Le commentaire doit contenir entre 1 et 5 000 caractères" + +#: frontend/templates/article_detail.html:156 +#: frontend/templates/article_detail.html:180 +#: frontend/templates/article_list.html:143 frontend/templates/base.html:108 +#: frontend/templates/base.html:132 frontend/templates/profile.html:93 +#: frontend/templates/profile.html:115 frontend/templates/user_list.html:143 +msgid "Cancel" +msgstr "Annuler" + +#: frontend/templates/article_detail.html:164 +msgid "Reply" +msgstr "Répondre" + +#: frontend/templates/article_detail.html:167 +#: frontend/templates/article_detail.html:187 +msgid "View replies" +msgstr "Voir les réponses" + +#: frontend/templates/article_detail.html:179 +msgid "Post Reply" +msgstr "Publier la réponse" + +#: frontend/templates/article_detail.html:203 +#, python-format +msgid "Comments (%(count)s)" +msgstr "Commentaires (%(count)s)" + +#: frontend/templates/article_detail.html:207 +msgid "No comments yet." +msgstr "Aucun commentaire pour l'instant." + +#: frontend/templates/article_detail.html:212 +msgid "Join the discussion" +msgstr "Participer à la discussion" + +#: frontend/templates/article_detail.html:219 +msgid "Post Comment" +msgstr "Publier le commentaire" + +#: frontend/templates/article_detail.html:225 +#, python-format +msgid "" +"Please sign in to " +"join the conversation." +msgstr "" + +#: frontend/templates/article_detail.html:235 +msgid "Unable to render article content." +msgstr "Impossible d'afficher le contenu de l'article." + +#: frontend/templates/article_edit.html:3 +msgid "Edit Article - DevJournal" +msgstr "Modifier l'article - DevJournal" + +#: frontend/templates/article_list.html:5 +msgid "Latest Articles - DevJournal" +msgstr "Derniers articles - DevJournal" + +#: frontend/templates/article_list.html:26 +msgid "Search by title, description or author..." +msgstr "Rechercher par titre, description ou auteur..." + +#: frontend/templates/article_list.html:31 frontend/templates/user_list.html:25 +msgid "Search" +msgstr "Rechercher" + +#: frontend/templates/article_list.html:40 +#: frontend/templates/article_list.html:107 +#: frontend/templates/user_list.html:33 frontend/templates/user_list.html:110 +msgid "Previous" +msgstr "Précédent" + +#: frontend/templates/article_list.html:48 +#: frontend/templates/article_list.html:115 +#: frontend/templates/user_list.html:40 frontend/templates/user_list.html:117 +msgid "Next" +msgstr "Suivant" + +#: frontend/templates/article_list.html:97 +#, python-format +msgid "No articles match \"%(query)s\"." +msgstr "Aucun article ne correspond à \"%(query)s\"." + +#: frontend/templates/article_list.html:97 +msgid "No articles found in the database." +msgstr "Aucun article trouvé dans la base de données." + +#: frontend/templates/article_list.html:124 +#: frontend/templates/macros/pagination.html:20 +#: frontend/templates/macros/pagination.html:26 +#: frontend/templates/user_list.html:124 +msgid "Jump to page" +msgstr "Aller à la page" + +#: frontend/templates/article_list.html:128 +#, python-format +msgid "Enter page number (1 - %(max)s)" +msgstr "Entrez le numéro de page (1 - %(max)s)" + +#: frontend/templates/article_list.html:132 +#: frontend/templates/user_list.html:132 +msgid "Increase page" +msgstr "Augmenter la page" + +#: frontend/templates/article_list.html:135 +#: frontend/templates/user_list.html:135 +msgid "Decrease page" +msgstr "Diminuer la page" + +#: frontend/templates/article_list.html:144 +#: frontend/templates/user_list.html:144 +msgid "Go" +msgstr "Aller" + +#: frontend/templates/base.html:43 +msgid "Toggle Theme" +msgstr "Changer de thème" + +#: frontend/templates/base.html:48 frontend/templates/base.html:50 +msgid "Home" +msgstr "Accueil" + +#: frontend/templates/base.html:55 +msgid "New Article" +msgstr "Nouvel article" + +#: frontend/templates/base.html:57 +msgid "New article" +msgstr "Nouvel article" + +#: frontend/templates/base.html:61 frontend/templates/base.html:63 +#: frontend/templates/profile.html:4 +msgid "Profile" +msgstr "Profil" + +#: frontend/templates/base.html:67 frontend/templates/base.html:69 +msgid "Logout" +msgstr "Déconnexion" + +#: frontend/templates/base.html:73 frontend/templates/base.html:75 +#: frontend/templates/login.html:30 +msgid "Sign In" +msgstr "Connexion" + +#: frontend/templates/base.html:77 frontend/templates/base.html:79 +msgid "Sign Up" +msgstr "Inscription" + +#: frontend/templates/base.html:104 +msgid "Confirm" +msgstr "Confirmer" + +#: frontend/templates/base.html:115 frontend/templates/base.html:133 +#: frontend/templates/profile.html:152 frontend/templates/user_list.html:88 +msgid "Ban" +msgstr "Bannir" + +#: frontend/templates/base.html:121 +msgid "Reason (optional)" +msgstr "Raison (optionnelle)" + +#: frontend/templates/base.html:123 +msgid "Maximum 150 characters" +msgstr "Maximum 150 caractères" + +#: frontend/templates/base.html:127 +msgid "e.g., Spam, harassment..." +msgstr "Ex. : Spam, harcèlement..." + +#: frontend/templates/base.html:144 +#, python-format +msgid "© %(year)s DevJournal. All rights reserved." +msgstr "© %(year)s DevJournal. Tous droits réservés." + +#: frontend/templates/error.html:11 frontend/templates/login.html:42 +#: frontend/templates/profile.html:166 frontend/templates/registration.html:53 +#: frontend/templates/user_list.html:149 +msgid "Return to home" +msgstr "Retour à l'accueil" + +#: frontend/templates/login.html:4 +msgid "Sign In - DevJournal" +msgstr "Connexion - DevJournal" + +#: frontend/templates/login.html:13 +msgid "Welcome Back" +msgstr "Bon retour" + +#: frontend/templates/login.html:14 +msgid "Access your developer journal accounts." +msgstr "Accédez à vos comptes de développeur." + +#: frontend/templates/login.html:20 frontend/templates/user_list.html:49 +msgid "Username" +msgstr "Nom d'utilisateur" + +#: frontend/templates/login.html:21 frontend/templates/registration.html:21 +msgid "e.g., dev_journey" +msgstr "Ex. : dev_journey" + +#: frontend/templates/login.html:24 frontend/templates/registration.html:28 +msgid "Password" +msgstr "Mot de passe" + +#: frontend/templates/login.html:26 frontend/templates/profile.html:109 +#: frontend/templates/registration.html:30 +#: frontend/templates/registration.html:37 +msgid "e.g., MyP@ssw0rd!" +msgstr "Ex. : MyP@ssw0rd!" + +#: frontend/templates/login.html:27 frontend/templates/profile.html:110 +#: frontend/templates/registration.html:31 +#: frontend/templates/registration.html:38 +msgid "Toggle password visibility" +msgstr "Afficher/masquer le mot de passe" + +#: frontend/templates/login.html:36 +msgid "New to the system ?" +msgstr "Nouveau sur le site ?" + +#: frontend/templates/login.html:37 +msgid "Create an account" +msgstr "Créer un compte" + +#: frontend/templates/profile.html:21 +msgid "Upload Photo" +msgstr "Télécharger une photo" + +#: frontend/templates/profile.html:26 +msgid "Remove Photo" +msgstr "Supprimer la photo" + +#: frontend/templates/profile.html:37 frontend/templates/user_list.html:75 +msgid "User" +msgstr "Utilisateur" + +#: frontend/templates/profile.html:38 frontend/templates/user_list.html:76 +msgid "Author" +msgstr "Auteur" + +#: frontend/templates/profile.html:40 frontend/templates/user_list.html:78 +msgid "Update" +msgstr "Mettre à jour" + +#: frontend/templates/profile.html:49 frontend/templates/profile.html:87 +#: frontend/templates/user_list.html:50 +msgid "Email" +msgstr "Email" + +#: frontend/templates/profile.html:62 frontend/templates/user_list.html:54 +msgid "Member Since" +msgstr "Membre depuis" + +#: frontend/templates/profile.html:67 frontend/templates/user_list.html:66 +msgid "N/A" +msgstr "N/R" + +#: frontend/templates/profile.html:73 frontend/templates/user_list.html:52 +msgid "Status" +msgstr "Statut" + +#: frontend/templates/profile.html:74 frontend/templates/user_list.html:64 +msgid "Banned" +msgstr "Banni" + +#: frontend/templates/profile.html:74 frontend/templates/user_list.html:64 +msgid "Active" +msgstr "Actif" + +#: frontend/templates/profile.html:81 +msgid "Update Email" +msgstr "Modifier l'email" + +#: frontend/templates/profile.html:89 frontend/templates/registration.html:25 +msgid "e.g., user@example.com" +msgstr "Ex. : user@example.com" + +#: frontend/templates/profile.html:101 +msgid "Update Password" +msgstr "Modifier le mot de passe" + +#: frontend/templates/profile.html:107 +msgid "New Password" +msgstr "Nouveau mot de passe" + +#: frontend/templates/profile.html:124 +msgid "Manage Users" +msgstr "Gérer les utilisateurs" + +#: frontend/templates/profile.html:126 +msgid "Change Password" +msgstr "Changer le mot de passe" + +#: frontend/templates/profile.html:129 +msgid "Sign Out" +msgstr "Déconnexion" + +#: frontend/templates/profile.html:136 +msgid "" +"Are you sure you want to delete your account ? This action is " +"irreversible." +msgstr "" + +#: frontend/templates/profile.html:136 frontend/templates/profile.html:159 +msgid "Delete Account" +msgstr "Supprimer le compte" + +#: frontend/templates/profile.html:147 frontend/templates/user_list.html:83 +msgid "Unban" +msgstr "Débannir" + +#: frontend/templates/profile.html:159 frontend/templates/user_list.html:95 +#, python-format +msgid "Delete user \\\"%(username)s\\\" ? This action is irreversible." +msgstr "Supprimer l'utilisateur \\\"%(username)s\\\" ? Action irréversible." + +#: frontend/templates/registration.html:4 +msgid "Sign Up - DevJournal" +msgstr "Inscription - DevJournal" + +#: frontend/templates/registration.html:13 +msgid "Join DevJournal" +msgstr "Rejoindre DevJournal" + +#: frontend/templates/registration.html:14 +msgid "Start your technical blog in seconds." +msgstr "Créez votre blog technique en quelques secondes." + +#: frontend/templates/registration.html:20 +msgid "Choose Username" +msgstr "Choisir un nom d'utilisateur" + +#: frontend/templates/registration.html:24 +msgid "Email Address" +msgstr "Adresse email" + +#: frontend/templates/registration.html:35 +msgid "Confirm Password" +msgstr "Confirmer le mot de passe" + +#: frontend/templates/registration.html:41 +msgid "Create Account" +msgstr "Créer un compte" + +#: frontend/templates/registration.html:47 +msgid "Already have an account ?" +msgstr "Déjà un compte ?" + +#: frontend/templates/registration.html:48 +msgid "Sign In instead" +msgstr "Connectez-vous" + +#: frontend/templates/user_list.html:4 +msgid "Users" +msgstr "Utilisateurs" + +#: frontend/templates/user_list.html:16 +#, python-format +msgid "Manage Users (%(count)d users)" +msgstr "Gérer les utilisateurs (%(count)d utilisateurs)" + +#: frontend/templates/user_list.html:20 +msgid "Filter by username or email..." +msgstr "Filtrer par nom d'utilisateur ou email..." + +#: frontend/templates/user_list.html:51 +msgid "Role" +msgstr "Rôle" + +#: frontend/templates/user_list.html:53 +msgid "Reason" +msgstr "Raison" + +#: frontend/templates/user_list.html:55 +msgid "Actions" +msgstr "Actions" + +#: frontend/templates/user_list.html:65 +msgid "—" +msgstr "—" + +#: frontend/templates/user_list.html:128 +#, python-format +msgid "Enter page number (1 - %(max)d)" +msgstr "Entrez le numéro de page (1 - %(max)d)" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:77 +msgid "You have been logged out." +msgstr "Vous avez été déconnecté." + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:91 +msgid "Please sign in to view your profile." +msgstr "Veuillez vous connecter pour voir votre profil." + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:148 +msgid "Authentication required." +msgstr "Authentification requise." + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:152 +msgid "No file provided." +msgstr "Aucun fichier fourni." + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:195 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:226 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:254 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:343 +msgid "Please sign in." +msgstr "Veuillez vous connecter." + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:200 +msgid "No avatar to remove." +msgstr "Aucun avatar à supprimer." + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:207 +msgid "Failed to remove profile photo." +msgstr "Échec de la suppression de la photo de profil." + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:210 +msgid "Profile photo removed." +msgstr "Photo de profil supprimée." + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:231 +msgid "Email is required." +msgstr "L'email est requis." + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:238 +msgid "Email updated." +msgstr "Email mis à jour." + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:259 +msgid "Password is required." +msgstr "Le mot de passe est requis." + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:266 +msgid "Password updated." +msgstr "Mot de passe mis à jour." + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:357 +msgid "Account not found." +msgstr "Compte introuvable." + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:374 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:377 +msgid "Account deleted." +msgstr "Compte supprimé." + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:416 +msgid "Role updated." +msgstr "Rôle mis à jour." + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:451 +msgid "Account banned." +msgstr "Compte banni." + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:482 +msgid "Account unbanned." +msgstr "Compte débanni." + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:114 +#, python-format +msgid "Error: %(error)s" +msgstr "Erreur : %(error)s" + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:156 +msgid "You must be signed in to author an article." +msgstr "Vous devez être connecté pour écrire un article." + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:160 +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:362 +msgid "Insufficient permissions: Only authors or admins can create articles." +msgstr "" +"Permissions insuffisantes : seuls les auteurs ou administrateurs peuvent " +"créer des articles." + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:181 +msgid "Article not found." +msgstr "Article introuvable." + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:217 +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:262 +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:307 +msgid "Unauthorized." +msgstr "Non autorisé." + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:219 +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:264 +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:309 +msgid "Insufficient permissions." +msgstr "Permissions insuffisantes." + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:223 +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:268 +msgid "Invalid JSON body." +msgstr "Corps JSON invalide." + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:234 +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:279 +msgid "Validation error." +msgstr "Erreur de validation." + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:333 +msgid "You must be logged in to delete articles." +msgstr "Vous devez être connecté pour supprimer des articles." + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:339 +msgid "error" +msgstr "erreur" + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:341 +msgid "Article deleted successfully." +msgstr "Article supprimé avec succès." + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:358 +msgid "You must be signed in to edit an article." +msgstr "Vous devez être connecté pour modifier un article." + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:367 +msgid "Error: The requested article could not be found." +msgstr "Erreur : l'article demandé est introuvable." + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:371 +msgid "You do not have permission to edit this article." +msgstr "Vous n'avez pas la permission de modifier cet article." + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:64 +msgid "You must be signed in to post a comment." +msgstr "Vous devez être connecté pour poster un commentaire." + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:80 +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:125 +#, python-format +msgid "You're posting too fast. Please wait %(remaining)ss before posting again." +msgstr "" +"Vous publiez trop vite. Veuillez patienter %(remaining)s seconde(s) avant" +" de publier à nouveau." + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:92 +msgid "Comment added." +msgstr "Commentaire ajouté." + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:109 +msgid "You must be signed in to reply." +msgstr "Vous devez être connecté pour répondre." + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:137 +msgid "Reply added." +msgstr "Réponse ajoutée." + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:154 +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:216 +msgid "You must be signed in to delete comments." +msgstr "Vous devez être connecté pour supprimer des commentaires." + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:165 +msgid "Comment deleted." +msgstr "Commentaire supprimé." + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:167 +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:229 +msgid "Unauthorized or error." +msgstr "Non autorisé ou erreur." + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:185 +msgid "You must be signed in to edit comments." +msgstr "Vous devez être connecté pour modifier des commentaires." + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:198 +msgid "Comment updated." +msgstr "Commentaire mis à jour." + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:227 +msgid "Comment permanently deleted." +msgstr "Commentaire définitivement supprimé." + +#: src/infrastructure/input_adapters/flask/flask_file_adapter.py:32 +msgid "No file provided" +msgstr "Aucun fichier fourni" + +#: src/infrastructure/input_adapters/flask/flask_file_adapter.py:72 +msgid "File not found" +msgstr "Fichier introuvable" + +#: src/infrastructure/input_adapters/flask/flask_login_adapter.py:55 +#, python-format +msgid "Validation Error (%(location)s): %(message)s" +msgstr "Erreur de validation (%(location)s) : %(message)s" + +#: src/infrastructure/input_adapters/flask/flask_login_adapter.py:69 +msgid "Invalid username or password." +msgstr "Nom d'utilisateur ou mot de passe invalide." + +#: src/infrastructure/input_adapters/flask/flask_registration_adapter.py:58 +#, python-format +msgid "%(location)s: %(message)s" +msgstr "%(location)s : %(message)s" + +#: src/infrastructure/input_adapters/flask/flask_registration_adapter.py:71 +msgid "Registration successful. Please sign in." +msgstr "Inscription réussie. Veuillez vous connecter." + +#~ msgid "" +#~ "Please sign in to join the " +#~ "conversation." +#~ msgstr "" +#~ "Veuillez vous connecter pour " +#~ "participer à la conversation." + +#~ msgid "" +#~ "Are you sure you want to delete" +#~ " your account ? This action is " +#~ "irreversible." +#~ msgstr "" +#~ "Êtes-vous sûr de vouloir supprimer " +#~ "votre compte ? Cette action est " +#~ "irréversible." + +#~ msgid "Delete user \\\"%(username)s\\\" ? This action is irreversible." +#~ msgstr "Supprimer l'utilisateur \\\"%(username)s\\\" ? Action irréversible." + +#~ msgid "Insufficient permissions: Only authors or admins can create articles." +#~ msgstr "" +#~ "Permissions insuffisantes : seuls les " +#~ "auteurs et administrateurs peuvent créer " +#~ "des articles." + +#~ msgid "" +#~ "You're posting too fast. Please wait " +#~ "%(remaining)ss before posting again." +#~ msgstr "" +#~ "Vous postez trop vite. Veuillez attendre" +#~ " %(remaining)ss avant de reposter." + diff --git a/translations/messages.pot b/translations/messages.pot new file mode 100644 index 00000000..a34fc469 --- /dev/null +++ b/translations/messages.pot @@ -0,0 +1,810 @@ +# Translations template for PROJECT. +# Copyright (C) 2026 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2026-07-22 15:55+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.18.0\n" + +#: frontend/templates/article_create.html:3 +msgid "Write an Article - DevJournal" +msgstr "" + +#: frontend/templates/article_create.html:15 +#: frontend/templates/article_detail.html:47 +#: frontend/templates/article_edit.html:40 +msgid "Back to articles" +msgstr "" + +#: frontend/templates/article_create.html:28 +#: frontend/templates/article_edit.html:57 +msgid "Title is required." +msgstr "" + +#: frontend/templates/article_create.html:29 +#: frontend/templates/article_edit.html:58 +msgid "Failed to save." +msgstr "" + +#: frontend/templates/article_create.html:30 +#: frontend/templates/article_edit.html:59 +msgid "Network error." +msgstr "" + +#: frontend/templates/article_create.html:31 +#: frontend/templates/article_detail.html:234 +#: frontend/templates/article_edit.html:60 +msgid "Loading..." +msgstr "" + +#: frontend/templates/article_create.html:32 +#: frontend/templates/article_edit.html:61 +msgid "Unable to parse article content." +msgstr "" + +#: frontend/templates/article_create.html:33 +#: frontend/templates/article_edit.html:62 +msgid "Article title" +msgstr "" + +#: frontend/templates/article_create.html:34 +#: frontend/templates/article_edit.html:63 +msgid "Description" +msgstr "" + +#: frontend/templates/article_create.html:35 +#: frontend/templates/article_edit.html:64 +msgid "Maximum 300 characters" +msgstr "" + +#: frontend/templates/article_create.html:36 +#: frontend/templates/article_edit.html:65 +msgid "Short description (optional)" +msgstr "" + +#: frontend/templates/article_create.html:37 +#: frontend/templates/article_edit.html:66 +msgid "Content" +msgstr "" + +#: frontend/templates/article_create.html:38 +#: frontend/templates/article_edit.html:67 +msgid "Saving..." +msgstr "" + +#: frontend/templates/article_create.html:39 +#: frontend/templates/article_edit.html:68 +msgid "Publish" +msgstr "" + +#: frontend/templates/article_create.html:40 +#: frontend/templates/article_detail.html:155 +#: frontend/templates/article_edit.html:69 frontend/templates/profile.html:94 +#: frontend/templates/profile.html:116 +msgid "Save" +msgstr "" + +#: frontend/templates/article_create.html:41 +#: frontend/templates/article_edit.html:70 +msgid "YouTube" +msgstr "" + +#: frontend/templates/article_create.html:42 +#: frontend/templates/article_edit.html:71 +msgid "Paste a YouTube video URL" +msgstr "" + +#: frontend/templates/article_create.html:43 +#: frontend/templates/article_edit.html:72 +msgid "YouTube URL" +msgstr "" + +#: frontend/templates/article_create.html:44 +#: frontend/templates/article_edit.html:73 +msgid "Paste YouTube video link" +msgstr "" + +#: frontend/templates/article_create.html:45 +#: frontend/templates/article_edit.html:74 +msgid "Embed YouTube video" +msgstr "" + +#: frontend/templates/article_create.html:46 +#: frontend/templates/article_edit.html:75 +msgid "Add YouTube video URL" +msgstr "" + +#: frontend/templates/article_create.html:47 +#: frontend/templates/article_edit.html:76 +msgid "Only YouTube links are supported" +msgstr "" + +#: frontend/templates/article_create.html:48 +#: frontend/templates/article_edit.html:77 +msgid "Failed to load article." +msgstr "" + +#: frontend/templates/article_create.html:49 +#: frontend/templates/article_edit.html:78 +msgid "Copy" +msgstr "" + +#: frontend/templates/article_create.html:50 +#: frontend/templates/article_edit.html:79 +msgid "Copy Block" +msgstr "" + +#: frontend/templates/article_create.html:51 +#: frontend/templates/article_edit.html:80 +msgid "Copied to clipboard" +msgstr "" + +#: frontend/templates/article_create.html:52 +#: frontend/templates/article_edit.html:81 +msgid "Move Up" +msgstr "" + +#: frontend/templates/article_create.html:53 +#: frontend/templates/article_edit.html:82 +msgid "Move Down" +msgstr "" + +#: frontend/templates/article_create.html:54 +#: frontend/templates/article_detail.html:79 +#: frontend/templates/article_edit.html:83 frontend/templates/base.html:109 +#: frontend/templates/user_list.html:95 +msgid "Delete" +msgstr "" + +#: frontend/templates/article_create.html:55 +#: frontend/templates/article_edit.html:84 +msgid "Align text justify" +msgstr "" + +#: frontend/templates/article_create.html:56 +#: frontend/templates/article_detail.html:236 +#: frontend/templates/article_edit.html:85 +msgid "Something went wrong. Please reload the page." +msgstr "" + +#: frontend/templates/article_detail.html:72 +#: frontend/templates/article_detail.html:145 +#: frontend/templates/article_list.html:88 +#, python-format +msgid "edited at %(date)s" +msgstr "" + +#: frontend/templates/article_detail.html:78 +msgid "Edit" +msgstr "" + +#: frontend/templates/article_detail.html:79 +msgid "Delete this article permanently ?" +msgstr "" + +#: frontend/templates/article_detail.html:126 +#: frontend/templates/profile.html:53 +msgid "[Edit]" +msgstr "" + +#: frontend/templates/article_detail.html:131 +#: frontend/templates/article_detail.html:137 +msgid "[Delete]" +msgstr "" + +#: frontend/templates/article_detail.html:135 +msgid "Permanently delete this comment? This cannot be undone." +msgstr "" + +#: frontend/templates/article_detail.html:152 +#: frontend/templates/article_detail.html:176 +#: frontend/templates/article_detail.html:213 +msgid "Comment must be between 1 and 5000 characters" +msgstr "" + +#: frontend/templates/article_detail.html:156 +#: frontend/templates/article_detail.html:180 +#: frontend/templates/article_list.html:143 frontend/templates/base.html:108 +#: frontend/templates/base.html:132 frontend/templates/profile.html:93 +#: frontend/templates/profile.html:115 frontend/templates/user_list.html:143 +msgid "Cancel" +msgstr "" + +#: frontend/templates/article_detail.html:164 +msgid "Reply" +msgstr "" + +#: frontend/templates/article_detail.html:167 +#: frontend/templates/article_detail.html:187 +msgid "View replies" +msgstr "" + +#: frontend/templates/article_detail.html:179 +msgid "Post Reply" +msgstr "" + +#: frontend/templates/article_detail.html:203 +#, python-format +msgid "Comments (%(count)s)" +msgstr "" + +#: frontend/templates/article_detail.html:207 +msgid "No comments yet." +msgstr "" + +#: frontend/templates/article_detail.html:212 +msgid "Join the discussion" +msgstr "" + +#: frontend/templates/article_detail.html:219 +msgid "Post Comment" +msgstr "" + +#: frontend/templates/article_detail.html:225 +#, python-format +msgid "" +"Please sign in to " +"join the conversation." +msgstr "" + +#: frontend/templates/article_detail.html:235 +msgid "Unable to render article content." +msgstr "" + +#: frontend/templates/article_edit.html:3 +msgid "Edit Article - DevJournal" +msgstr "" + +#: frontend/templates/article_list.html:5 +msgid "Latest Articles - DevJournal" +msgstr "" + +#: frontend/templates/article_list.html:26 +msgid "Search by title, description or author..." +msgstr "" + +#: frontend/templates/article_list.html:31 frontend/templates/user_list.html:25 +msgid "Search" +msgstr "" + +#: frontend/templates/article_list.html:40 +#: frontend/templates/article_list.html:107 +#: frontend/templates/user_list.html:33 frontend/templates/user_list.html:110 +msgid "Previous" +msgstr "" + +#: frontend/templates/article_list.html:48 +#: frontend/templates/article_list.html:115 +#: frontend/templates/user_list.html:40 frontend/templates/user_list.html:117 +msgid "Next" +msgstr "" + +#: frontend/templates/article_list.html:97 +#, python-format +msgid "No articles match \"%(query)s\"." +msgstr "" + +#: frontend/templates/article_list.html:97 +msgid "No articles found in the database." +msgstr "" + +#: frontend/templates/article_list.html:124 +#: frontend/templates/macros/pagination.html:20 +#: frontend/templates/macros/pagination.html:26 +#: frontend/templates/user_list.html:124 +msgid "Jump to page" +msgstr "" + +#: frontend/templates/article_list.html:128 +#, python-format +msgid "Enter page number (1 - %(max)s)" +msgstr "" + +#: frontend/templates/article_list.html:132 +#: frontend/templates/user_list.html:132 +msgid "Increase page" +msgstr "" + +#: frontend/templates/article_list.html:135 +#: frontend/templates/user_list.html:135 +msgid "Decrease page" +msgstr "" + +#: frontend/templates/article_list.html:144 +#: frontend/templates/user_list.html:144 +msgid "Go" +msgstr "" + +#: frontend/templates/base.html:43 +msgid "Toggle Theme" +msgstr "" + +#: frontend/templates/base.html:48 frontend/templates/base.html:50 +msgid "Home" +msgstr "" + +#: frontend/templates/base.html:55 +msgid "New Article" +msgstr "" + +#: frontend/templates/base.html:57 +msgid "New article" +msgstr "" + +#: frontend/templates/base.html:61 frontend/templates/base.html:63 +#: frontend/templates/profile.html:4 +msgid "Profile" +msgstr "" + +#: frontend/templates/base.html:67 frontend/templates/base.html:69 +msgid "Logout" +msgstr "" + +#: frontend/templates/base.html:73 frontend/templates/base.html:75 +#: frontend/templates/login.html:30 +msgid "Sign In" +msgstr "" + +#: frontend/templates/base.html:77 frontend/templates/base.html:79 +msgid "Sign Up" +msgstr "" + +#: frontend/templates/base.html:104 +msgid "Confirm" +msgstr "" + +#: frontend/templates/base.html:115 frontend/templates/base.html:133 +#: frontend/templates/profile.html:152 frontend/templates/user_list.html:88 +msgid "Ban" +msgstr "" + +#: frontend/templates/base.html:121 +msgid "Reason (optional)" +msgstr "" + +#: frontend/templates/base.html:123 +msgid "Maximum 150 characters" +msgstr "" + +#: frontend/templates/base.html:127 +msgid "e.g., Spam, harassment..." +msgstr "" + +#: frontend/templates/base.html:144 +#, python-format +msgid "© %(year)s DevJournal. All rights reserved." +msgstr "" + +#: frontend/templates/error.html:11 frontend/templates/login.html:42 +#: frontend/templates/profile.html:166 frontend/templates/registration.html:53 +#: frontend/templates/user_list.html:149 +msgid "Return to home" +msgstr "" + +#: frontend/templates/login.html:4 +msgid "Sign In - DevJournal" +msgstr "" + +#: frontend/templates/login.html:13 +msgid "Welcome Back" +msgstr "" + +#: frontend/templates/login.html:14 +msgid "Access your developer journal accounts." +msgstr "" + +#: frontend/templates/login.html:20 frontend/templates/user_list.html:49 +msgid "Username" +msgstr "" + +#: frontend/templates/login.html:21 frontend/templates/registration.html:21 +msgid "e.g., dev_journey" +msgstr "" + +#: frontend/templates/login.html:24 frontend/templates/registration.html:28 +msgid "Password" +msgstr "" + +#: frontend/templates/login.html:26 frontend/templates/profile.html:109 +#: frontend/templates/registration.html:30 +#: frontend/templates/registration.html:37 +msgid "e.g., MyP@ssw0rd!" +msgstr "" + +#: frontend/templates/login.html:27 frontend/templates/profile.html:110 +#: frontend/templates/registration.html:31 +#: frontend/templates/registration.html:38 +msgid "Toggle password visibility" +msgstr "" + +#: frontend/templates/login.html:36 +msgid "New to the system ?" +msgstr "" + +#: frontend/templates/login.html:37 +msgid "Create an account" +msgstr "" + +#: frontend/templates/profile.html:21 +msgid "Upload Photo" +msgstr "" + +#: frontend/templates/profile.html:26 +msgid "Remove Photo" +msgstr "" + +#: frontend/templates/profile.html:37 frontend/templates/user_list.html:75 +msgid "User" +msgstr "" + +#: frontend/templates/profile.html:38 frontend/templates/user_list.html:76 +msgid "Author" +msgstr "" + +#: frontend/templates/profile.html:40 frontend/templates/user_list.html:78 +msgid "Update" +msgstr "" + +#: frontend/templates/profile.html:49 frontend/templates/profile.html:87 +#: frontend/templates/user_list.html:50 +msgid "Email" +msgstr "" + +#: frontend/templates/profile.html:62 frontend/templates/user_list.html:54 +msgid "Member Since" +msgstr "" + +#: frontend/templates/profile.html:67 frontend/templates/user_list.html:66 +msgid "N/A" +msgstr "" + +#: frontend/templates/profile.html:73 frontend/templates/user_list.html:52 +msgid "Status" +msgstr "" + +#: frontend/templates/profile.html:74 frontend/templates/user_list.html:64 +msgid "Banned" +msgstr "" + +#: frontend/templates/profile.html:74 frontend/templates/user_list.html:64 +msgid "Active" +msgstr "" + +#: frontend/templates/profile.html:81 +msgid "Update Email" +msgstr "" + +#: frontend/templates/profile.html:89 frontend/templates/registration.html:25 +msgid "e.g., user@example.com" +msgstr "" + +#: frontend/templates/profile.html:101 +msgid "Update Password" +msgstr "" + +#: frontend/templates/profile.html:107 +msgid "New Password" +msgstr "" + +#: frontend/templates/profile.html:124 +msgid "Manage Users" +msgstr "" + +#: frontend/templates/profile.html:126 +msgid "Change Password" +msgstr "" + +#: frontend/templates/profile.html:129 +msgid "Sign Out" +msgstr "" + +#: frontend/templates/profile.html:136 +msgid "" +"Are you sure you want to delete your account ? This action is " +"irreversible." +msgstr "" + +#: frontend/templates/profile.html:136 frontend/templates/profile.html:159 +msgid "Delete Account" +msgstr "" + +#: frontend/templates/profile.html:147 frontend/templates/user_list.html:83 +msgid "Unban" +msgstr "" + +#: frontend/templates/profile.html:159 frontend/templates/user_list.html:95 +#, python-format +msgid "Delete user \\\"%(username)s\\\" ? This action is irreversible." +msgstr "" + +#: frontend/templates/registration.html:4 +msgid "Sign Up - DevJournal" +msgstr "" + +#: frontend/templates/registration.html:13 +msgid "Join DevJournal" +msgstr "" + +#: frontend/templates/registration.html:14 +msgid "Start your technical blog in seconds." +msgstr "" + +#: frontend/templates/registration.html:20 +msgid "Choose Username" +msgstr "" + +#: frontend/templates/registration.html:24 +msgid "Email Address" +msgstr "" + +#: frontend/templates/registration.html:35 +msgid "Confirm Password" +msgstr "" + +#: frontend/templates/registration.html:41 +msgid "Create Account" +msgstr "" + +#: frontend/templates/registration.html:47 +msgid "Already have an account ?" +msgstr "" + +#: frontend/templates/registration.html:48 +msgid "Sign In instead" +msgstr "" + +#: frontend/templates/user_list.html:4 +msgid "Users" +msgstr "" + +#: frontend/templates/user_list.html:16 +#, python-format +msgid "Manage Users (%(count)d users)" +msgstr "" + +#: frontend/templates/user_list.html:20 +msgid "Filter by username or email..." +msgstr "" + +#: frontend/templates/user_list.html:51 +msgid "Role" +msgstr "" + +#: frontend/templates/user_list.html:53 +msgid "Reason" +msgstr "" + +#: frontend/templates/user_list.html:55 +msgid "Actions" +msgstr "" + +#: frontend/templates/user_list.html:65 +msgid "—" +msgstr "" + +#: frontend/templates/user_list.html:128 +#, python-format +msgid "Enter page number (1 - %(max)d)" +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:77 +msgid "You have been logged out." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:91 +msgid "Please sign in to view your profile." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:148 +msgid "Authentication required." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:152 +msgid "No file provided." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:195 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:226 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:254 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:343 +msgid "Please sign in." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:200 +msgid "No avatar to remove." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:207 +msgid "Failed to remove profile photo." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:210 +msgid "Profile photo removed." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:231 +msgid "Email is required." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:238 +msgid "Email updated." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:259 +msgid "Password is required." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:266 +msgid "Password updated." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:357 +msgid "Account not found." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:374 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:377 +msgid "Account deleted." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:416 +msgid "Role updated." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:451 +msgid "Account banned." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:482 +msgid "Account unbanned." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:114 +#, python-format +msgid "Error: %(error)s" +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:156 +msgid "You must be signed in to author an article." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:160 +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:362 +msgid "Insufficient permissions: Only authors or admins can create articles." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:181 +msgid "Article not found." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:217 +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:262 +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:307 +msgid "Unauthorized." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:219 +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:264 +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:309 +msgid "Insufficient permissions." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:223 +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:268 +msgid "Invalid JSON body." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:234 +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:279 +msgid "Validation error." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:333 +msgid "You must be logged in to delete articles." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:339 +msgid "error" +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:341 +msgid "Article deleted successfully." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:358 +msgid "You must be signed in to edit an article." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:367 +msgid "Error: The requested article could not be found." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_article_adapter.py:371 +msgid "You do not have permission to edit this article." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:64 +msgid "You must be signed in to post a comment." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:80 +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:125 +#, python-format +msgid "You're posting too fast. Please wait %(remaining)ss before posting again." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:92 +msgid "Comment added." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:109 +msgid "You must be signed in to reply." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:137 +msgid "Reply added." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:154 +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:216 +msgid "You must be signed in to delete comments." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:165 +msgid "Comment deleted." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:167 +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:229 +msgid "Unauthorized or error." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:185 +msgid "You must be signed in to edit comments." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:198 +msgid "Comment updated." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_comment_adapter.py:227 +msgid "Comment permanently deleted." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_file_adapter.py:32 +msgid "No file provided" +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_file_adapter.py:72 +msgid "File not found" +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_login_adapter.py:55 +#, python-format +msgid "Validation Error (%(location)s): %(message)s" +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_login_adapter.py:69 +msgid "Invalid username or password." +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_registration_adapter.py:58 +#, python-format +msgid "%(location)s: %(message)s" +msgstr "" + +#: src/infrastructure/input_adapters/flask/flask_registration_adapter.py:71 +msgid "Registration successful. Please sign in." +msgstr "" + diff --git a/utils/template_helpers.py b/utils/template_helpers.py index 58254a5a..fa264eb8 100644 --- a/utils/template_helpers.py +++ b/utils/template_helpers.py @@ -1,7 +1,10 @@ import json import os from datetime import UTC, datetime +from zoneinfo import ZoneInfo +from babel.dates import format_datetime +from flask_babel import get_locale from markupsafe import Markup, escape @@ -133,6 +136,31 @@ def date_format_filter(date: datetime | None, format: str = "%b %d, %Y") -> str: return date.strftime(format) +def format_datetime_locale(date: datetime | None, format: str = "d MMMM yyyy 'à' HH:mm") -> str: + """ + Jinja2 filter that formats a UTC datetime to Europe/Paris local time + using Flask-Babel's locale-aware formatting. + + Respects the current application locale (fr, en, etc.) via + ``flask_babel.get_locale()``. Falls back to UTC if the datetime is naive. + + Args: + date: A UTC datetime object to format, or None. + format: A Babel format string (default: ``"d MMMM yyyy 'à' HH:mm"``). + + Returns: + The formatted date string (e.g. ``"22 juillet 2026 à 10:54"`` in French, + ``"22 July 2026 à 10:54"`` in English). Returns empty string if date is None. + """ + if date is None: + return "" + if date.tzinfo is None: + date = date.replace(tzinfo=ZoneInfo("UTC")) + local = date.astimezone(ZoneInfo("Europe/Paris")) + locale = get_locale() + return format_datetime(local, format=format, locale=locale) + + def date_iso_filter(date: datetime | None) -> str: """ Jinja2 filter that formats a datetime as an ISO 8601 date string. From e9a5dba0e70da3410b9ac2d47da0204be3f76b04 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Wed, 22 Jul 2026 21:04:57 +0200 Subject: [PATCH 03/16] =?UTF-8?q?Update=20logo,=20add=20translations=20and?= =?UTF-8?q?=20fix=20comments=20:=20French=20i18n=20updated:=20role=20label?= =?UTF-8?q?s=20now=20use=20`=5F(user.account=5Frole)`=20with=20new=20`admi?= =?UTF-8?q?n/author/user`=20entries,=20templates=20use=20`format=5Fdatetim?= =?UTF-8?q?e=5Flocale("d=20MMMM=20yyyy")`=20for=20locale=E2=80=91aware=20d?= =?UTF-8?q?ates,=20the=20missing=20self=E2=80=91delete=20translation=20is?= =?UTF-8?q?=20fixed,=20the=20old=20`date=5Fformat=5Ffilter`=20is=20removed?= =?UTF-8?q?=20and=20the=20article=20list=20test=20expects=20"29=20April=20?= =?UTF-8?q?2026"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- blog_comment_application.py | 2 -- frontend/templates/article_detail.html | 2 +- frontend/templates/article_list.html | 2 +- frontend/templates/profile.html | 4 ++-- frontend/templates/user_list.html | 4 ++-- tests/test_template_helpers.py | 17 +---------------- .../tests_flask/flask_test_utils.py | 3 +-- .../tests_flask/test_flask_article_adapter.py | 2 +- translations/fr/LC_MESSAGES/messages.mo | Bin 10855 -> 11131 bytes translations/fr/LC_MESSAGES/messages.po | 14 +++++++++++++- utils/template_helpers.py | 16 ---------------- 11 files changed, 22 insertions(+), 44 deletions(-) diff --git a/blog_comment_application.py b/blog_comment_application.py index 1ed7581b..0437f5a1 100644 --- a/blog_comment_application.py +++ b/blog_comment_application.py @@ -30,7 +30,6 @@ from utils.prosemirror_to_html import prosemirror_to_html from utils.template_helpers import ( ViteManifest, - date_format_filter, date_iso_filter, format_datetime_locale, inject_current_year, @@ -170,7 +169,6 @@ def _init_template_utils(app: Flask) -> None: ViteManifest.init(os.path.join(app.static_folder or "", "dist")) app.jinja_env.filters["nl2br"] = nl2br_filter - app.jinja_env.filters["date_format"] = date_format_filter app.jinja_env.filters["date_iso"] = date_iso_filter app.jinja_env.filters["prosemirror_to_html"] = prosemirror_to_html app.jinja_env.filters["format_datetime_locale"] = format_datetime_locale diff --git a/frontend/templates/article_detail.html b/frontend/templates/article_detail.html index 40e4f2f0..1d23b9bc 100644 --- a/frontend/templates/article_detail.html +++ b/frontend/templates/article_detail.html @@ -66,7 +66,7 @@

{{ article.article_title }}

{% endif %} - + {% if article.article_edited_at %} {{ _('edited at %(date)s', date=article.article_edited_at|format_datetime_locale) }} diff --git a/frontend/templates/article_list.html b/frontend/templates/article_list.html index 82d2fbd6..9460c35a 100644 --- a/frontend/templates/article_list.html +++ b/frontend/templates/article_list.html @@ -82,7 +82,7 @@

{% endif %} {% if article.article_published_at %} - + {% if article.article_edited_at %} {{ _('edited at %(date)s', date=article.article_edited_at|format_datetime_locale) }} diff --git a/frontend/templates/profile.html b/frontend/templates/profile.html index 7dd194fa..7b5a3042 100644 --- a/frontend/templates/profile.html +++ b/frontend/templates/profile.html @@ -29,7 +29,7 @@ {% endif %}

{{ user.account_username }}

- {{ user.account_role }} + {{ _(user.account_role) }} {% if current_user and current_user.account_role == 'admin' and not is_own_profile and user.account_role != 'admin' %}
@@ -62,7 +62,7 @@

{{ user.account_username }}

{{ _('Member Since') }} {% if user.account_created_at %} - {{ user.account_created_at|date_format }} + {{ user.account_created_at|format_datetime_locale("d MMMM yyyy") }} {% else %} {{ _('N/A') }} {% endif %} diff --git a/frontend/templates/user_list.html b/frontend/templates/user_list.html index 4f8719da..d513c846 100644 --- a/frontend/templates/user_list.html +++ b/frontend/templates/user_list.html @@ -60,10 +60,10 @@

{{ _('Manage Users (%(count)d users)', count=total_count) }}

{{ user.account_username }} {{ user.account_email }}{{ user.account_role }}{{ _(user.account_role) }} {{ _('Banned') if user.is_banned else _('Active') }} {{ user.ban_reason or _('—') }}{{ user.account_created_at|date_format if user.account_created_at else _('N/A') }}{{ user.account_created_at|format_datetime_locale("d MMMM yyyy") if user.account_created_at else _('N/A') }} {% if user.account_role == 'admin' %} diff --git a/tests/test_template_helpers.py b/tests/test_template_helpers.py index 9894f90e..5ccf9a15 100644 --- a/tests/test_template_helpers.py +++ b/tests/test_template_helpers.py @@ -5,7 +5,7 @@ from jinja2.exceptions import TemplateNotFound from markupsafe import Markup -from utils.template_helpers import date_format_filter, date_iso_filter, nl2br_filter +from utils.template_helpers import date_iso_filter, nl2br_filter class TestIconMacro: @@ -88,21 +88,6 @@ def test_mixed_content_with_newlines_and_html(self): assert "" not in str(result) -class TestDateFormatFilter: - """Unit tests for the date_format Jinja2 filter.""" - - def test_formats_date_with_default_format(self): - result = date_format_filter(datetime(2026, 4, 29)) - assert result == "Apr 29, 2026" - - def test_returns_recent_for_none_date(self): - result = date_format_filter(None) - assert result == "RECENT" - - def test_uses_custom_format(self): - result = date_format_filter(datetime(2026, 4, 29), "%Y-%m-%d") - assert result == "2026-04-29" - class TestDateIsoFilter: """Tests for the date_iso Jinja2 filter.""" diff --git a/tests/tests_infrastructure/tests_input_adapters/tests_flask/flask_test_utils.py b/tests/tests_infrastructure/tests_input_adapters/tests_flask/flask_test_utils.py index cd6e4f37..73aabc44 100644 --- a/tests/tests_infrastructure/tests_input_adapters/tests_flask/flask_test_utils.py +++ b/tests/tests_infrastructure/tests_input_adapters/tests_flask/flask_test_utils.py @@ -6,7 +6,7 @@ from flask_wtf.csrf import CSRFProtect from utils.prosemirror_to_html import prosemirror_to_html -from utils.template_helpers import date_format_filter, date_iso_filter, format_datetime_locale, nl2br_filter +from utils.template_helpers import date_iso_filter, format_datetime_locale, nl2br_filter class FlaskInputAdapterTestBase: @@ -63,7 +63,6 @@ def setup_method(self) -> None: self.app = Flask(__name__, template_folder=self.TEMPLATE_DIR, static_folder=static_dir) self.app.jinja_env.filters["nl2br"] = nl2br_filter - self.app.jinja_env.filters["date_format"] = date_format_filter self.app.jinja_env.filters["date_iso"] = date_iso_filter self.app.jinja_env.filters["prosemirror_to_html"] = prosemirror_to_html self.app.jinja_env.filters["format_datetime_locale"] = format_datetime_locale diff --git a/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_article_adapter.py b/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_article_adapter.py index 5b540be8..42432ff5 100644 --- a/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_article_adapter.py +++ b/tests/tests_infrastructure/tests_input_adapters/tests_flask/test_flask_article_adapter.py @@ -135,7 +135,7 @@ def test_list_articles_date_rendering(self): self.mock_account_repo.get_by_ids.return_value = [create_test_account(account_id=1, account_username="Author")] response = self.client.get("/") assert response.status_code == 200 - assert b"Apr 29, 2026" in response.data + assert b"29 April 2026" in response.data assert b'datetime="2026-04-29"' in response.data def test_list_articles_no_date_hides_meta(self): diff --git a/translations/fr/LC_MESSAGES/messages.mo b/translations/fr/LC_MESSAGES/messages.mo index 7adcb3d4e6428668bb90b0c20809f1721b6549e4..d1f3b4286262f68a1e6fc21af739a4e79350d6bc 100644 GIT binary patch delta 3118 zcmYM#e@vBC9LMpafQqP?gaSf6A&P(~s1tuC{;2TRswpo2Fum$6t^+Q6?sFmeqidGU zti-&jb7EO$X-T7Sw#H10S&6e2RyHe~D|GrtGqo(SAMX$MY`W`xo%1~RIo}`WJbGzK z_tHq`kofI}Kh6Bjv47J6bNNjW68b5^oGd8J|=U^`O!!p#VeH3;7Nz`Fnhw98WEWtyV zgTJCWl1LUxEFIN>QK)Ydsx6=G2V}=cpI{< z=0nu|FHjYn!=6Kh3e;`q$($?&7>Wv*i3*g1e9UydwB9mQ<<&R^H)gW`qZl0H0>zn& z*8fnaIE{@|;B3^w(~(TRe%Ra|G?WZi{Y=Ur67hcOqw#8SMPMgO&z#r!Lw zy_|vSz=NoYo{bV1&><0?EQf(qAhe#{}3(0aae;YbT4YXk0T6p zDo>*V-b7XU4{A>noY7AF{najqbi?|O1K_5 zhvseM(TMq!ffo1$_26Hq(>-WJEaMVXWphzyVI}Gi)mvY|QsxIxfi9z3oSqZ=-FQ^1 z=b^TGIjW!l4kx~;VW3Q3vNv8uCA1sIVl!&b&!akW&CYM57ET@++qw~`-{qkKO-FUI z4E2A(Qq+1rbZ{#s65o8lKozy3TG)Zws|%=wenVAs3nyXEXaS}d;ym_VpOR#A){hz>~iwn~*omW8>mg7|1jC$|{=HqQ#iMdSr;wDsL zTdj@QpZNh)CtI)&euhf?Bp zp5Iv)3_GE)8ynR5sy)XGI&1uuey`_}>-bi$4u-ir(^*mx2r=~n!D@$5z;*q#em4|Y zRp}qwawO?e(g5F@sz9~zg}sWP8*LmGj0=bS(c=7!mJwcfU0gaZ%dIvyH+9pQ^Ab7|6*WSpnPy6*HoG~{ktya&U^D}Z zS`D){B@D5P{;`~aqIRQ(h|pU8p_yrzlqRV-y3+T%9caA1pL5RhJm>s=zjL0~zZUQ@0)?Y5BWEOf1CT+`~UB3dI&@5evc*CjhQ$yBZMrhz#Oc>Y+Q(`xEfRN8N3}E z(EDCS=WD_I5E9`~ywHUMxo{ct@D@5j=78RX24gb)5_G&gIspx6N^~yXMt><9;2Io@ z8_@*!#_=yOm-Ryz4I};~Ubr6n$wV`V@eJ&XBa!4o5xPJHn(=hx6Bh7gU@Oq;tFaIp za2hrvIfZY~`Oe``)(;nHSoMF9Pe|rzOO=gIRK%BoRN+XR6RkrtdPDT zGD&>V4B2=G7GNKofe9nImxc?^LHFhXoQ3shAP14X3ST3iaEUJix`h-gq!Wdu9fDS9 zB3g+V==Is?!V7UQu8VHVqW<2nn}Ia^2;GW9=w2T~BmW$GN{8NmF7|L;N+$zVV3 z#4L2dD$K=Nt7v6XC`UCGU=c1sEAl)V z*mkr6JJI>xLlfGMX5JqAC(#OZC1@~rIE$tD8#>Vt((+6cq5U$n64hv?v(YV^ANxzu z3a&sWUW22t9^IO^&_LSJiX24)Pju2S(u?tiEAfV#IF|7=?k>h^bmHYmjA0cT*ajSi zTd@Flp{M>hn(+lR&_B?GQYo+V3`Cw!gj^a%QrJ5X#-kZdM+2LQK6oFF!CJIp2{eG$ zqOIs5{QxVm1KqMe(MseJO*u|S`%m`B{=Y$^gn^^z7FBU9;$ihgmq}9n{f&@;V3+b

g32l7xF{ZZ(;l_S`HC!WWE6V~$O!u9BBe-_R3 z#b|SMFFMgDXhlB568sKp@LzPRW^f?fs`+RI7NH3>pq1T}PyL;skpWBg4iaoQfKGTK zdLv#R&aaAlHxB)wS%ww31`Ij zi~S$afNr9d$SUalWh+E8s6pR?`_ZjghMxZQvA-vJ1ik-Tv~m~FN++(+@Tc|$`Wkkl zrBCH0HlV?1zZ`j!!c;Vq1!(3=(107!j6Oy_;S^s!|2z6z3gz-pk3a*Sg(Q{;wKO~o zYtcj07~O?6^xM&ey3rDs+|_$u4O;34(Y<~O&1eH!u`OtzJLCAfXh8dL93I6S)(=-{ zSQ3BGJp<|J#QEsnjgRBi=tA?*QZ7b+>sO%@)?)#-qVpX?6Y4-KcplCC8amJ4n8*5| ze=+}MVj+&l#b_ptXvD9ghh`5JU^`m!)9Av(L5sMbAnL zCXD1D4Hx(njj$698=Rd@rZVF?>=f-7+< z?m(aGET#TsG}1?h@Ce?Gso0DL)*5{uUAP^40!8<@Gxo2crA}fpw=NG;a145OsxTQ> zVkjS=U>V}ZJm Kx~4hh6aNEyb_IX{ diff --git a/translations/fr/LC_MESSAGES/messages.po b/translations/fr/LC_MESSAGES/messages.po index 0f52e199..e1955c16 100644 --- a/translations/fr/LC_MESSAGES/messages.po +++ b/translations/fr/LC_MESSAGES/messages.po @@ -453,6 +453,18 @@ msgstr "Utilisateur" msgid "Author" msgstr "Auteur" +#: frontend/templates/profile.html:32 frontend/templates/user_list.html:63 +msgid "admin" +msgstr "Administrateur" + +#: frontend/templates/profile.html:32 frontend/templates/user_list.html:63 +msgid "author" +msgstr "Auteur" + +#: frontend/templates/profile.html:32 frontend/templates/user_list.html:63 +msgid "user" +msgstr "Utilisateur" + #: frontend/templates/profile.html:40 frontend/templates/user_list.html:78 msgid "Update" msgstr "Mettre à jour" @@ -514,7 +526,7 @@ msgstr "Déconnexion" msgid "" "Are you sure you want to delete your account ? This action is " "irreversible." -msgstr "" +msgstr "Êtes-vous sûr de vouloir supprimer votre compte ? Cette action est irréversible." #: frontend/templates/profile.html:136 frontend/templates/profile.html:159 msgid "Delete Account" diff --git a/utils/template_helpers.py b/utils/template_helpers.py index fa264eb8..f88fb052 100644 --- a/utils/template_helpers.py +++ b/utils/template_helpers.py @@ -119,22 +119,6 @@ def inject_current_year() -> dict[str, int]: return {"current_year": datetime.now(UTC).year} -def date_format_filter(date: datetime | None, format: str = "%b %d, %Y") -> str: - """ - Jinja2 filter that formats a datetime into a human-readable date string. - - Args: - date: A datetime object to format, or None. - format: A strftime format string (default: ``"%b %d, %Y"``). - - Returns: - The formatted date string (e.g. ``"Apr 29, 2026"``). - Returns ``"RECENT"`` if the input is None. - """ - if date is None: - return "RECENT" - return date.strftime(format) - def format_datetime_locale(date: datetime | None, format: str = "d MMMM yyyy 'à' HH:mm") -> str: """ From fc4efa089bcd5989c4efe6b02cdf9c2070878b4a Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Wed, 22 Jul 2026 22:10:55 +0200 Subject: [PATCH 04/16] =?UTF-8?q?Update=20logo,=20add=20translations=20and?= =?UTF-8?q?=20fix=20comments=20:=20French=20i18n=20updated=20with=20full?= =?UTF-8?q?=20coverage=20of=20remaining=20English=20toasts=20and=20code?= =?UTF-8?q?=E2=80=91block=20UI=20strings.=20Code=E2=80=91block=20labels=20?= =?UTF-8?q?now=20use=20`=5F()`,=20comment=E2=80=91reply=20reads=20all=20te?= =?UTF-8?q?xts=20from=20Jinja=20datasets,=20copy=20and=20editor=20scripts?= =?UTF-8?q?=20use=20`=5Ft()`,=20ArticleForm=20wraps=20its=20upload=20error?= =?UTF-8?q?,=20seven=20missing=20keys=20are=20added=20to=20template=20JSON?= =?UTF-8?q?=20blocks=20and=20compiled,=20profile=20CSS=20gains=20`display:?= =?UTF-8?q?contents`,=20and=20profile=E2=80=91avatar=20keeps=20its=20Frenc?= =?UTF-8?q?h=20hardcoded=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/core/components/ArticleForm.jsx | 2 +- frontend/core/utils/custom-code-block-spec.js | 19 ++++++------- frontend/static/css/profile.css | 1 + frontend/static/scripts/code-copy.js | 7 ++++- frontend/static/scripts/comment-reply.js | 20 +++++++------- frontend/static/scripts/profile-avatar.js | 4 +-- frontend/static/scripts/suneditor-init.js | 8 +++++- frontend/templates/article_create.html | 3 +++ frontend/templates/article_detail.html | 13 ++++++--- frontend/templates/article_edit.html | 3 +++ translations/fr/LC_MESSAGES/messages.mo | Bin 11131 -> 11478 bytes translations/fr/LC_MESSAGES/messages.po | 25 ++++++++++++++++++ 12 files changed, 77 insertions(+), 28 deletions(-) diff --git a/frontend/core/components/ArticleForm.jsx b/frontend/core/components/ArticleForm.jsx index 171419d7..1ae1c80c 100644 --- a/frontend/core/components/ArticleForm.jsx +++ b/frontend/core/components/ArticleForm.jsx @@ -82,7 +82,7 @@ function BlockNoteEditor({ initialContent, onReady }) { const res = await fetch('/api/upload/image', { method: 'POST', body: formData }); if (!res.ok) { const err = await res.json(); - throw new Error(err.error || 'Upload failed.'); + throw new Error(err.error || _('Upload failed.')); } const data = await res.json(); return data.url; diff --git a/frontend/core/utils/custom-code-block-spec.js b/frontend/core/utils/custom-code-block-spec.js index d4d85aaf..5cfa456b 100644 --- a/frontend/core/utils/custom-code-block-spec.js +++ b/frontend/core/utils/custom-code-block-spec.js @@ -1,4 +1,5 @@ import { createCodeBlockSpec as createOriginalCodeBlockSpec } from '@blocknote/core'; +import { _ } from './i18n.js'; function createLanguageSelectorWidget(select, block, editor) { try { @@ -7,7 +8,7 @@ function createLanguageSelectorWidget(select, block, editor) { const trigger = document.createElement('button'); trigger.className = 'code-block-lang-trigger'; - trigger.textContent = select.options[select.selectedIndex]?.text || 'Plain Text'; + trigger.textContent = _(select.options[select.selectedIndex]?.text) || _('Plain Text'); trigger.tabIndex = 0; const dropdown = document.createElement('div'); @@ -22,7 +23,7 @@ function createLanguageSelectorWidget(select, block, editor) { const searchInput = document.createElement('input'); searchInput.type = 'text'; - searchInput.placeholder = 'Search languages...'; + searchInput.placeholder = _('Search languages...'); searchInput.className = 'code-block-lang-search'; searchWrap.appendChild(searchIcon); @@ -37,7 +38,7 @@ function createLanguageSelectorWidget(select, block, editor) { const items = []; const noResultsMsg = document.createElement('div'); noResultsMsg.className = 'code-block-lang-no-results'; - noResultsMsg.textContent = 'No languages found'; + noResultsMsg.textContent = _('No languages found'); noResultsMsg.style.display = 'none'; Array.from(select.options).forEach((opt) => { @@ -47,11 +48,11 @@ function createLanguageSelectorWidget(select, block, editor) { item.classList.add('code-block-lang-item--selected'); } item.dataset.lang = opt.value; - item.textContent = opt.text; + item.textContent = _(opt.text); item.addEventListener('click', () => { select.value = opt.value; select.dispatchEvent(new Event('change')); - trigger.textContent = opt.text; + trigger.textContent = _(opt.text); closeDropdown(); }); list.appendChild(item); @@ -176,7 +177,7 @@ function createLanguageSelectorWidget(select, block, editor) { }); select.addEventListener('change', () => { - trigger.textContent = select.options[select.selectedIndex]?.text || 'Plain Text'; + trigger.textContent = _(select.options[select.selectedIndex]?.text) || _('Plain Text'); }); dropdown.appendChild(searchWrap); @@ -222,7 +223,7 @@ export function createCustomCodeBlockSpec(options) { function showCopyTooltip() { const tooltip = document.createElement('div'); tooltip.className = 'code-block-tooltip'; - tooltip.textContent = 'Copy'; + tooltip.textContent = _('Copy'); document.body.appendChild(tooltip); const rect = copyBtn.getBoundingClientRect(); const th = tooltip.offsetHeight; @@ -256,7 +257,7 @@ export function createCustomCodeBlockSpec(options) { function showTooltip() { const tooltip = document.createElement('div'); tooltip.className = 'code-block-tooltip'; - tooltip.textContent = 'Delete'; + tooltip.textContent = _('Delete'); document.body.appendChild(tooltip); const rect = deleteBtn.getBoundingClientRect(); const th = tooltip.offsetHeight; @@ -302,7 +303,7 @@ export function createCustomCodeBlockSpec(options) { const label = document.createElement('span'); label.className = 'code-block-lang-trigger'; label.dataset.readonly = ''; - label.textContent = lang; + label.textContent = _(lang) || _('Plain Text'); container.appendChild(label); result.dom.querySelector('[contenteditable="false"]')?.appendChild(container); } diff --git a/frontend/static/css/profile.css b/frontend/static/css/profile.css index 1049ca31..ed4f2f71 100644 --- a/frontend/static/css/profile.css +++ b/frontend/static/css/profile.css @@ -85,6 +85,7 @@ .logout-form { margin: 0; text-align: center; + display: contents; } .profile-actions { diff --git a/frontend/static/scripts/code-copy.js b/frontend/static/scripts/code-copy.js index 07e17472..1df751bc 100644 --- a/frontend/static/scripts/code-copy.js +++ b/frontend/static/scripts/code-copy.js @@ -2,6 +2,11 @@ 'use strict'; const DEBUG = false; + const _t = key => { + const el = document.getElementById('app-translations'); + if (!el) return key; + try { return JSON.parse(el.textContent)[key] || key; } catch { return key; } + }; document.addEventListener('click', (e) => { const btn = e.target.closest('.code-copy-btn'); @@ -44,7 +49,7 @@ const toast = document.createElement('div'); toast.className = 'toast'; - toast.textContent = 'Copied to clipboard'; + toast.textContent = _t('Copied to clipboard'); document.body.appendChild(toast); diff --git a/frontend/static/scripts/comment-reply.js b/frontend/static/scripts/comment-reply.js index 3a646d9c..e2f07d7b 100644 --- a/frontend/static/scripts/comment-reply.js +++ b/frontend/static/scripts/comment-reply.js @@ -15,7 +15,7 @@ other.style.display = 'none'; const otherId = other.id.replace('reply-form-', ''); const otherToggle = document.querySelector('.reply-toggle[data-comment-id="' + otherId + '"]'); - if (otherToggle) otherToggle.textContent = 'Reply'; + if (otherToggle) otherToggle.textContent = otherToggle.dataset.replyText; } }); @@ -24,7 +24,7 @@ other.style.display = 'none'; const otherId = other.id.replace('edit-form-', ''); const otherToggle = document.querySelector('.comment-edit-toggle[data-comment-id="' + otherId + '"]'); - if (otherToggle) otherToggle.textContent = '[Edit]'; + if (otherToggle) otherToggle.textContent = otherToggle.dataset.editText; const otherBody = document.getElementById('comment-body-' + otherId); if (otherBody) otherBody.style.display = ''; } @@ -32,7 +32,7 @@ const isHidden = container.style.display === 'none'; container.style.display = isHidden ? 'block' : 'none'; - replyToggle.textContent = isHidden ? 'Cancel' : 'Reply'; + replyToggle.textContent = isHidden ? replyToggle.dataset.cancelText : replyToggle.dataset.replyText; if (isHidden && window.initReplyEditor) { window.initReplyEditor(commentId); @@ -46,7 +46,7 @@ if (!commentEl) return; commentEl.classList.toggle('replies-visible'); const isVisible = commentEl.classList.contains('replies-visible'); - viewBtn.textContent = isVisible ? 'Hide replies' : 'View replies'; + viewBtn.textContent = isVisible ? viewBtn.dataset.hideText : viewBtn.dataset.showText; const cascade = (el, visible) => { const replies = el.querySelector(':scope > .comment-content > .comment-replies'); @@ -54,7 +54,7 @@ replies.querySelectorAll(':scope > .comment').forEach(child => { child.classList.toggle('replies-visible', visible); const btn = child.querySelector('.view-replies-btn'); - if (btn) btn.textContent = visible ? 'Hide replies' : 'View replies'; + if (btn) btn.textContent = visible ? btn.dataset.hideText : btn.dataset.showText; cascade(child, visible); }); }; @@ -74,7 +74,7 @@ other.style.display = 'none'; const otherId = other.id.replace('edit-form-', ''); const otherToggle = document.querySelector('.comment-edit-toggle[data-comment-id="' + otherId + '"]'); - if (otherToggle) otherToggle.textContent = '[Edit]'; + if (otherToggle) otherToggle.textContent = otherToggle.dataset.editText; const otherBody = document.getElementById('comment-body-' + otherId); if (otherBody) { otherBody.style.display = ''; @@ -91,13 +91,13 @@ other.style.display = 'none'; const otherId = other.id.replace('reply-form-', ''); const otherToggle = document.querySelector('.reply-toggle[data-comment-id="' + otherId + '"]'); - if (otherToggle) otherToggle.textContent = 'Reply'; + if (otherToggle) otherToggle.textContent = otherToggle.dataset.replyText; } }); const isHidden = container.style.display === 'none'; container.style.display = isHidden ? 'block' : 'none'; - editToggle.textContent = isHidden ? 'Cancel' : '[Edit]'; + editToggle.textContent = isHidden ? editToggle.dataset.cancelText : editToggle.dataset.editText; const bodyText = document.getElementById('comment-body-' + commentId); if (bodyText) { @@ -122,7 +122,7 @@ const commentId = container.id.replace('reply-form-', ''); const toggle = document.querySelector('.reply-toggle[data-comment-id="' + commentId + '"]'); if (toggle) { - toggle.textContent = 'Reply'; + toggle.textContent = toggle.dataset.replyText; } return; } @@ -135,7 +135,7 @@ const commentId = container.id.replace('edit-form-', ''); const toggle = document.querySelector('.comment-edit-toggle[data-comment-id="' + commentId + '"]'); if (toggle) { - toggle.textContent = '[Edit]'; + toggle.textContent = toggle.dataset.editText; } const bodyText = document.getElementById('comment-body-' + commentId); if (bodyText) { diff --git a/frontend/static/scripts/profile-avatar.js b/frontend/static/scripts/profile-avatar.js index 1a9c6ed3..ad70d87f 100644 --- a/frontend/static/scripts/profile-avatar.js +++ b/frontend/static/scripts/profile-avatar.js @@ -22,10 +22,10 @@ location.reload(); } else { const data = await response.json(); - alert(data.error || "Upload failed."); + alert(data.error || "Échec du téléchargement."); } } catch { - alert("Network error during upload."); + alert("Erreur réseau lors du téléchargement."); } }); })(); diff --git a/frontend/static/scripts/suneditor-init.js b/frontend/static/scripts/suneditor-init.js index c065f20a..487a0f88 100644 --- a/frontend/static/scripts/suneditor-init.js +++ b/frontend/static/scripts/suneditor-init.js @@ -1,5 +1,11 @@ 'use strict'; +const _t = key => { + const el = document.getElementById('app-translations'); + if (!el) return key; + try { return JSON.parse(el.textContent)[key] || key; } catch { return key; } +}; + (() => { function createEmojiPlugin(textareaId) { return { @@ -137,7 +143,7 @@ const text = html.replace(/<[^>]+>/g, '').trim(); if (!text) { e.preventDefault(); - showToast('Comment cannot be empty'); + showToast(_t('Comment cannot be empty')); return; } hiddenInput.value = html; diff --git a/frontend/templates/article_create.html b/frontend/templates/article_create.html index ef8ea7e5..ed663d8b 100644 --- a/frontend/templates/article_create.html +++ b/frontend/templates/article_create.html @@ -53,6 +53,9 @@ 'Move Down': _('Move Down'), 'Delete': _('Delete'), 'Align text justify': _('Align text justify'), + 'Plain Text': _('Plain Text'), + 'Search languages...': _('Search languages...'), + 'No languages found': _('No languages found'), 'Something went wrong. Please reload the page.': _('Something went wrong. Please reload the page.') } | tojson | safe }} diff --git a/frontend/templates/article_detail.html b/frontend/templates/article_detail.html index 1d23b9bc..2635923e 100644 --- a/frontend/templates/article_detail.html +++ b/frontend/templates/article_detail.html @@ -123,7 +123,7 @@

{{ article.article_title }}

{% endif %} {{ node.comment.comment_posted_at|format_datetime_locale }} {% if current_user and is_author and not is_deleted %} - + {% endif %} {% if current_user and (is_author or is_admin) and not is_deleted %} @@ -161,10 +161,10 @@

{{ article.article_title }}

{% if current_user and not is_deleted %}
{% if depth < 3 %} - + {% endif %} {% if node.replies %} - + {% endif %}
{% if depth < 3 %} @@ -184,7 +184,7 @@

{{ article.article_title }}

{% endif %} {% elif node.replies %}
- +
{% endif %} @@ -233,6 +233,11 @@

{{ _('Join the discussion') }}

{{ { 'Loading...': _('Loading...'), 'Unable to render article content.': _('Unable to render article content.'), + 'Copied to clipboard': _('Copied to clipboard'), + 'Comment cannot be empty': _('Comment cannot be empty'), + 'Failed to load article.': _('Failed to load article.'), + 'Plain Text': _('Plain Text'), + 'Copy': _('Copy'), 'Something went wrong. Please reload the page.': _('Something went wrong. Please reload the page.') } | tojson | safe }} diff --git a/frontend/templates/article_edit.html b/frontend/templates/article_edit.html index 15890273..e288c9a4 100644 --- a/frontend/templates/article_edit.html +++ b/frontend/templates/article_edit.html @@ -82,6 +82,9 @@ 'Move Down': _('Move Down'), 'Delete': _('Delete'), 'Align text justify': _('Align text justify'), + 'Plain Text': _('Plain Text'), + 'Search languages...': _('Search languages...'), + 'No languages found': _('No languages found'), 'Something went wrong. Please reload the page.': _('Something went wrong. Please reload the page.') } | tojson | safe }} diff --git a/translations/fr/LC_MESSAGES/messages.mo b/translations/fr/LC_MESSAGES/messages.mo index d1f3b4286262f68a1e6fc21af739a4e79350d6bc..3219a303025e311ab8118a5f88078792c9b08274 100644 GIT binary patch delta 3249 zcmYM#eN5F=9LMp)!xfY#K|vGLpCW=JqEjTbmZh}$n2CkfTwS?89_o4_xYyy+Zq`bj z`OuiUoO4#DV*Zic>593O(M(rnDcao74DvCTHdijC)%ycylU?uYob&tr&iQ`7=lre@ zU%Id;d^s(4tKshm|FZb^y+^%Af2$IVd5rFA%*E}Pg2%8gp20M{f~j~9R{;=bJ+kPCg^@=@2x3sIz_iQ-VDOGhr5JX9dXI231D-#}%! z0mtG_ROYAc^{dFoc(_UPq+@spjS)1WaT-!(^BgLmQd9s7QGr!p3BH92=mcu(E}|yB zj|wb~GH9Mm)S1ddRjLG4k$LudSt|8c06qpX@Ez+8d&42@&G^@-EjfwW`yWvmv?1rs zbfOl#WBXmGK;nr~iDcn$9EMtNI%=!S)2Kf|n+68tdgNKN1NoSPd^vaw#1)LALD{Y9wCRG=~sp|)^^?XN>sbR%ls@OB!bXzW7PGAB`c){YA3ALL`ANLv|X zpaLF=x?YI7UW^4e8*^|4YJuHIk<30+z|A-azePvye+Lb{7BQr)%yals#809!n~B|r z1~ow$DiELT2T>WXL1nxJ72sCX^Sf{~?m<=XC)7Nh-Ln68Y3LOHi(@g9cSd_T8&#PK zd>psf{!!~S%w@bU2S8i$1nPD4;tX7as?c|+LwXJs*e|GrZlH(t%^f@N7b?OoQ~=3* zsh@|$W+tOb`2s34A1dS5Q5n{t-j1EPDT>mgN?n-Q6W~l#W$RJ%u0fqY(?~<7e;+F1 zFHs8|N0KxrPzztS{)wgZV>u|wcp>TxG@$O=h^%dPqcUwqt-Sd+b}*oY zZXq8N&3xmbs|F z=b;i@j5-_3F@yC@V|RnSz^PnlK|K(i)3b02s`O8z4&g%77Sy25z&of6H=`EVh6?-( zJKl^6um#891=LyaaI$-|z8OeE1H(}h7oi7d*zxC43oSvFHh|s#xu7Ooiwf`vI(PaUVrV?Yr_bELIUGHMGPR6qr&y?qr`sSv6H>yQ`5 zG~z^zAU^@-78YZCKCdKB#VHs>CHgr|#;f_%Uk^H@!vtt5B-?Y zJ((t>Dm54t_y|-&6K#J!Y72a*tzC(_e=};{ZK*VLx7rbVT=-n#OtNB2dB^*<1mRO_$t`kV@HwcquPiJVIRv{&Q1St%I-*Qs?w zL8sPR@3bBW*166y7LE9`K8Q)5jVsl3WtSLrTw1Hmzoy}9RO{s-#`a=HKj delta 2931 zcmYM#drX&A7{KwvO+`ZxyrED(5k$P?b&|J6YK6B|OP#Kn(q^eEb%dB`) zcIxK9Pc6TB{O(Az_y12`rx0@K=3_CIVkXYREL@7&xEi})HMYk+*bZy38y-ZTYe2_4 zhf_m{hZ{8VxiImzwg)OOgZ>KaiEGe-wxbiig(;Ybu74c)DH_<3$nUT-{buyN>o^$G z7~DihU>5U-2WfQWLIoQ6%GMhpJR9|^Fo)|~F%{p(j#!IMP={vTfE?jRP6m7#z5g5b zK|5oi0LeK#imA*W7SpgKOVR4DM+d4xOSTg^LM}p}9%gZ=W+0csax|bdNOi*&w6wd? z3VnuF;#hRQ5uNxvcE^9Lzm0Cx&jAKuI*vw{Vj{ZMQ_;vDz}C{C&o7SptI$B6M-$nK z1-Jv9upV9FCS>k#EiyHm`qK{GNH2!P*a6GYUHcGv{|R(6K7&?f6PDtBEW}^Xilh^T zf#sqV=!cFs3{7YZn(&0EKPOJZ5>=oREyVs#htW(M(Ixvn>Nlem zyo`=}9ZRqUU78}wU?3CFio~aIHu8CBq)X8Uo<@$an$uw1j74}D9rzNGbhv^BnnJmH zVHU=4AiC?Pp&2hh16_+IRE>-i58G%sz%De9ny7yW&9DIt>;(Ga4>$;$(28|pbl=a9 z9Dr`pQXGmi(ItBkt;8-Ihz+fM*8h*_MvQN!m>W~jC0LGrKHG6J)}fV2VBI%- zz~hixVGe5#E^LYX0Da+0 zv~;J?Qa7QSu^IhLFQKLX3k|3PY1+?6zRWNPO=JR^`4lwZN@O2~caT@(;d2@e@H6`2 zpXl!H+_x>_QZ%zk=w6tQZlcP_jaWv1A3D(`w8XiEZO;uyOFbD~>bYn_i?BEIhh;R3 z^p)tw>u5mR@lLEo*Ze$Mk*iVv7CLZdQQOk>MV~7{CmM%VvK;+iFbf^80%N!V)0sbf zNW+Zk&=NMHYjpt~=vOqOn>Z44_+*A*Ihsi&8t`-Ip4oyi+=rI@C_3@)=s1PNZS#!B zxEW20F3dtV&jR$pmFQ-vLIc^0PLMzYJc0(&h%Q|!>t+Rdp&1WG_HnoiN8xPb%L`Rl ziZ%VI{|Fk*To{YFd Date: Wed, 22 Jul 2026 23:15:51 +0200 Subject: [PATCH 05/16] Update logo, add translations and fix comments : French i18n added for SunEditor toolbar and emoji picker. The French lang pack `fr.js` is included and loaded before the editor init. `suneditor-init.js` adapts flat keys to nested ones, sets the `lang` option and wraps the emoji tooltip with `_t('Insert Emoji')`. The emoji picker now shows French categories, search text, and skin labels. `article_detail.html` includes the `Insert Emoji` key in its JSON block and the translation is added and compiled in `messages.po/.mo` --- frontend/static/scripts/emoji-picker.js | 28 +++ frontend/static/scripts/suneditor-init.js | 79 ++++++- frontend/static/vendor/suneditor/lang/fr.js | 240 ++++++++++++++++++++ frontend/templates/article_detail.html | 1 + frontend/templates/base.html | 1 + translations/fr/LC_MESSAGES/messages.mo | Bin 11478 -> 11531 bytes translations/fr/LC_MESSAGES/messages.po | 5 + 7 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 frontend/static/vendor/suneditor/lang/fr.js diff --git a/frontend/static/scripts/emoji-picker.js b/frontend/static/scripts/emoji-picker.js index bf7b1913..a71ff1da 100644 --- a/frontend/static/scripts/emoji-picker.js +++ b/frontend/static/scripts/emoji-picker.js @@ -28,6 +28,34 @@ maxFrequentRows: 4, previewPosition: 'none', skinTonePosition: 'none', + i18n: { + search: 'Rechercher', + 'search_no_results_1': 'Oh non\u00a0!', + 'search_no_results_2': 'Cet emoji est introuvable', + pick: 'Choisissez un emoji\u2026', + categories: { + activity: 'Activit\u00e9s', + custom: 'Personnalis\u00e9s', + flags: 'Drapeaux', + foods: 'Aliments & Boissons', + frequent: 'Utilis\u00e9s fr\u00e9quemment', + nature: 'Animaux & Nature', + objects: 'Objets', + people: 'Smileys & Personnes', + places: 'Voyages & Lieux', + search: 'R\u00e9sultats de recherche', + symbols: 'Symboles', + }, + skins: { + '1': 'Par d\u00e9faut', + '2': 'Clair', + '3': 'Moyen-Clair', + '4': 'Moyen', + '5': 'Moyen-Fonc\u00e9', + '6': 'Fonc\u00e9', + choose: 'Choisir le teint de peau par d\u00e9faut', + }, + }, onEmojiSelect: function (data) { const editor = window.suneditors[editorId]; if (editor) { diff --git a/frontend/static/scripts/suneditor-init.js b/frontend/static/scripts/suneditor-init.js index 487a0f88..5fe0dccb 100644 --- a/frontend/static/scripts/suneditor-init.js +++ b/frontend/static/scripts/suneditor-init.js @@ -6,13 +6,89 @@ const _t = key => { try { return JSON.parse(el.textContent)[key] || key; } catch { return key; } }; +function resolveLang(flat) { + if (!flat) return null; + const dst = { toolbar: {}, controller: {}, menu: {}, dialogBox: {} }; + const boxMap = { linkBox: {}, imageBox: {}, videoBox: {}, audioBox: {}, mathBox: {} }; + for (const boxKey of Object.keys(boxMap)) dst.dialogBox[boxKey] = {}; + + const mappings = [ + ['bold', 'toolbar'], ['italic', 'toolbar'], ['underline', 'toolbar'], + ['strike', 'toolbar'], ['list', 'toolbar'], ['link', 'toolbar'], + ['align', 'toolbar'], ['font', 'toolbar'], ['fontSize', 'toolbar'], + ['fontColor', 'toolbar'], ['backgroundColor', 'toolbar'], + ['indent', 'toolbar'], ['outdent', 'toolbar'], + ['alignLeft', 'toolbar'], ['alignCenter', 'toolbar'], + ['alignRight', 'toolbar'], ['alignJustify', 'toolbar'], + ['codeView', 'toolbar'], ['fullScreen', 'toolbar'], + ['undo', 'toolbar'], ['redo', 'toolbar'], ['save', 'toolbar'], + ['print', 'toolbar'], ['preview', 'toolbar'], + ['removeFormat', 'toolbar'], ['copyFormat', 'toolbar'], + ['table', 'toolbar'], ['image', 'toolbar'], ['video', 'toolbar'], + ['audio', 'toolbar'], ['math', 'toolbar'], + ['horizontalRule', 'toolbar'], + ['imageGallery', 'toolbar'], ['fileBrowser', 'toolbar'], + ['fileGallery', 'toolbar'], ['mediaGallery', 'toolbar'], + ['videoGallery', 'toolbar'], ['audioGallery', 'toolbar'], + + ['maxSize', 'controller'], ['minSize', 'controller'], + ['resize', 'controller'], ['resize100', 'controller'], + ['resize25', 'controller'], ['resize50', 'controller'], + ['resize75', 'controller'], ['proportion', 'controller'], + ['ratio', 'controller'], ['rotateLeft', 'controller'], + ['rotateRight', 'controller'], + ['mirrorHorizontal', 'controller'], ['mirrorVertical', 'controller'], + ['edit', 'controller'], ['remove', 'controller'], + ['unlink', 'controller'], ['autoSize', 'controller'], + ['deleteColumn', 'controller'], ['deleteRow', 'controller'], + ['fixedColumnWidth', 'controller'], + ['insertColumnAfter', 'controller'], ['insertColumnBefore', 'controller'], + ['insertRowAbove', 'controller'], ['insertRowBelow', 'controller'], + ['mergeCells', 'controller'], ['splitCells', 'controller'], + ['tableHeader', 'controller'], + + ['close', 'dialogBox'], ['caption', 'dialogBox'], + ['submitButton', 'dialogBox'], ['basic', 'dialogBox'], + ['cancel', 'dialogBox'], + ]; + + for (const [key, ...path] of mappings) { + if (flat[key] === undefined) continue; + let obj = dst; + for (let i = 0; i < path.length; i++) obj = obj[path[i]]; + obj[key] = flat[key]; + } + + const menuKeys = ['menu_bordered', 'menu_code', 'menu_neon', 'menu_shadow', 'menu_spaced', 'menu_translucent']; + for (const key of menuKeys) { + if (flat[key] === undefined) continue; + dst.menu[key.slice(5)] = flat[key]; + } + + const modalPrefixes = [ + { prefix: 'link_modal_', box: 'linkBox' }, + { prefix: 'image_modal_', box: 'imageBox' }, + { prefix: 'video_modal_', box: 'videoBox' }, + { prefix: 'audio_modal_', box: 'audioBox' }, + { prefix: 'math_modal_', box: 'mathBox' }, + ]; + for (const { prefix, box } of modalPrefixes) { + for (const key of Object.keys(flat)) { + if (!key.startsWith(prefix)) continue; + dst.dialogBox[box][key.slice(prefix.length)] = flat[key]; + } + } + + return dst; +} + (() => { function createEmojiPlugin(textareaId) { return { name: 'emoji', display: 'command', active: function () { return true; }, - title: 'Insert Emoji', + title: _t('Insert Emoji'), innerHTML: '\uD83D\uDE0A', add: function (core, buttonElement) { buttonElement.dataset.editorId = textareaId; @@ -62,6 +138,7 @@ const _t = key => { ['bold', 'underline', 'italic', 'strike'], ['list', 'link', 'emoji'] ], + lang: resolveLang(SUNEDITOR_LANG && SUNEDITOR_LANG.fr), lineHeight: '1.4', imageUploadUrl: '', imageFileInput: false, diff --git a/frontend/static/vendor/suneditor/lang/fr.js b/frontend/static/vendor/suneditor/lang/fr.js new file mode 100644 index 00000000..e55e6989 --- /dev/null +++ b/frontend/static/vendor/suneditor/lang/fr.js @@ -0,0 +1,240 @@ +// France +// 프랑스어 +(function (global, factory) { + if (typeof module === 'object' && typeof module.exports === 'object') { + module.exports = global.document + ? factory(global, true) + : function (w) { + if (!w.document) { + throw new Error('SUNEDITOR_LANG a window with a document'); + } + return factory(w); + }; + } else { + factory(global); + } +})(typeof window !== 'undefined' ? window : this, function (window, noGlobal) { + const lang = { + code: 'fr', + align: 'Alignement', + alignBottom: 'Aligner en bas', + alignCenter: 'Centré', + alignJustify: 'Justifié', + alignLeft: 'À gauche', + alignMiddle: 'Aligner au milieu', + alignRight: 'À droite', + alignTop: 'Aligner en haut', + anchor: 'Ancre', + asBlock: 'En tant que bloc', + asInline: "En tant qu'élément en ligne", + asLink: 'En tant que lien', + audio: 'Audio', + audioGallery: 'Galerie audio', + audio_modal_file: 'Sélectionner le fichier', + audio_modal_title: 'Insérer un fichier audio', + audio_modal_url: 'Adresse URL du fichier', + autoSize: 'Taille automatique', + backgroundColor: 'Couleur en arrière plan', + basic: 'Basique', + blockStyle: 'Style de bloc', + bold: 'Gras', + border: 'Bordure', + border_all: 'Bordure entière', + border_inside: 'Bordure intérieure', + border_horizontal: 'Bordure horizontale', + border_vertical: 'Bordure verticale', + border_outside: 'Bordure extérieure', + border_left: 'Bordure gauche', + border_top: 'Bordure supérieure', + border_right: 'Bordure droite', + border_bottom: 'Bordure inférieure', + border_none: 'Aucune bordure', + bulletedList: 'Non-ordonnée', + cancel: 'Annuler', + caption: 'Insérer une description', + cellProperties: 'Propriétés de la cellule', + center: 'Centré', + close: 'Fermer', + codeView: 'Voir le code', + color: 'Couleur', + colorPicker: 'Sélecteur de couleurs', + column: 'Colonne', + comment: 'Commentaires', + commentAdd: 'Ajouter un commentaire', + commentShow: 'Afficher les commentaires', + copy: 'Copier', + copyFormat: 'Peindre la mise en forme', + cut: 'Couper', + default: 'Défaut', + deleteColumn: 'Effacer la colonne', + deleteRow: 'Effacer la ligne', + dir_ltr: 'De gauche à droite', + dir_rtl: 'De droite à gauche', + download: 'Télécharger', + drag: 'Glisser', + drawing: 'Dessin', + drawing_modal_title: 'Dessin', + edit: 'Modifier', + embed: 'Intégrer', + embed_modal_title: 'Intégrer', + embed_modal_source: "Intégrer la source / l'URL", + exportPDF: 'Exporter au format PDF', + exportWord: 'Exporter vers Word', + find: 'Rechercher', + decrease: 'Diminuer', + increase: 'Augmenter', + fileBrowser: 'Navigateur de fichiers', + fileGallery: 'Galerie de fichiers', + fileUpload: 'Téléchargement de fichiers', + fixedColumnWidth: 'Largeur de colonne fixe', + font: 'Police', + fontColor: 'Couleur du texte', + fontSize: 'Taille', + formats: 'Formats', + fullScreen: 'Plein écran', + height: 'Hauteur', + horizontalLine: 'Ligne horizontale', + horizontalSplit: 'Scission horizontale', + hr_dashed: 'Tirets', + hr_dotted: 'Points', + hr_solid: 'Solide', + id: 'ID', + image: 'Image', + imageGallery: "Galerie d'images", + image_modal_altText: 'Texte Alternatif', + image_modal_file: 'Sélectionner le fichier', + image_modal_title: 'Insérer une image', + image_modal_url: 'Adresse URL du fichier', + importWord: 'Importer depuis Word', + indent: 'Indenter', + inlineStyle: 'Style en ligne', + insertColumnAfter: 'Insérer une colonne après', + insertColumnBefore: 'Insérer une colonne avant', + insertRowAbove: 'Insérer une ligne en dessous', + insertRowBelow: 'Insérer une ligne au dessus', + insertLine: 'Insérer une ligne', + italic: 'Italique', + layout: 'Mise en page', + left: 'Gauche', + lineHeight: 'Hauteur de la ligne', + link: 'Lien', + link_modal_bookmark: 'Signet', + link_modal_downloadLinkCheck: 'Lien de téléchargement', + link_modal_newWindowCheck: 'Ouvrir dans une nouvelle fenêtre', + link_modal_text: 'Texte à afficher', + link_modal_title: 'Insérer un lien', + link_modal_url: 'Adresse URL du lien', + link_modal_relAttribute: 'Attribut Rel', + list: 'Liste', + markdownView: 'Vue Markdown', + math: 'Math', + math_modal_fontSizeLabel: 'Taille', + math_modal_inputLabel: 'Notation mathématique', + math_modal_previewLabel: 'Prévisualiser', + math_modal_title: 'Math', + maxSize: 'Taille max', + mediaGallery: 'Galerie multimédia', + mention: 'Mention', + menu_bordered: 'Ligne de démarcation', + menu_code: 'Code', + menu_neon: 'Néon', + menu_shadow: 'Ombre', + menu_spaced: 'Espacement', + menu_translucent: 'Translucide', + mergeCells: 'Fusionner les cellules', + minSize: 'Taille min', + mirrorHorizontal: 'Mirroir, Horizontal', + mirrorVertical: 'Mirroir, Vertical', + newDocument: 'Nouveau document', + numberedList: 'Ordonnée', + outdent: 'Désindenter', + pageBreak: 'Saut de page', + pageDown: 'Page suivante', + pageNumber: 'Numéro de page', + pageUp: 'Page précédente', + paragraphStyle: 'Style de paragraphe', + preview: 'Prévisualiser', + print: 'Imprimer', + proportion: 'Maintenir le rapport hauteur/largeur', + ratio: 'Rapport', + redo: 'Rétablir', + remove: 'Effacer', + removeFormat: 'Effacer le formatage', + replace: 'Remplacer', + replaceAll: 'Remplacer tous', + resize100: 'Redimensionner à 100%', + resize25: 'Redimensionner à 25%', + resize50: 'Redimensionner à 50%', + resize75: 'Redimensionner à 75%', + resize: 'Změnit velikost', + revert: 'Revenir en arrière', + revisionHistory: 'Historique des révisions', + right: 'Droite', + rotateLeft: 'Rotation à gauche', + rotateRight: 'Rotation à droite', + row: 'Ligne', + save: 'Sauvegarder', + search: 'Chercher', + selectAll: 'Sélectionner tout', + showBlocks: 'Voir les blocs', + size: 'Taille', + splitCells: 'Diviser les Cellules', + strike: 'Barré', + submitButton: 'Appliquer', + subscript: 'Indice', + superscript: 'Exposant', + table: 'Table', + tableHeader: 'En-tête de table', + tableProperties: 'Propriétés du tableau', + tags: 'Mots clés', + tag_blockquote: 'Citation', + codeBlock: 'Bloc de code', + tag_div: 'Normal (DIV)', + tag_h: 'Titre', + tag_p: 'Paragraphe', + tag_pre: 'Code', + template: 'Template', + textStyle: 'Style de texte', + title: 'Titre', + underline: 'Souligné', + undo: 'Annuler', + unmergeCells: 'Annuler la fusion des cellules', + unlink: 'Supprimer un lien', + verticalSplit: 'Scission verticale', + video: 'Video', + videoGallery: 'Galerie vidéo', + video_modal_file: 'Sélectionner le fichier', + video_modal_title: 'Insérer une vidéo', + video_modal_url: 'URL d’intégration du média, YouTube/Vimeo', + width: 'Largeur', + codeLanguage: 'Langue', + codeLanguage_none: 'Aucun', + finder_matchCase: "Boîte d'allumettes", + finder_wholeWord: 'Mot entier', + finder_regex: 'Expression régulière', + finder_prev: 'Match précédent', + finder_next: 'Prochain match', + message_copy_success: 'Copié dans le presse-papiers', + message_copy_fail: 'Échec de la copie. Veuillez copier manuellement.', + }; + + if (typeof noGlobal === typeof undefined) { + if (!window.SUNEDITOR_LANG) { + Object.defineProperty(window, 'SUNEDITOR_LANG', { + enumerable: true, + writable: false, + configurable: false, + value: {}, + }); + } + + Object.defineProperty(window.SUNEDITOR_LANG, 'fr', { + enumerable: true, + writable: true, + configurable: true, + value: lang, + }); + } + + return lang; +}); diff --git a/frontend/templates/article_detail.html b/frontend/templates/article_detail.html index 2635923e..ae6e79c9 100644 --- a/frontend/templates/article_detail.html +++ b/frontend/templates/article_detail.html @@ -238,6 +238,7 @@

{{ _('Join the discussion') }}

'Failed to load article.': _('Failed to load article.'), 'Plain Text': _('Plain Text'), 'Copy': _('Copy'), + 'Insert Emoji': _('Insert Emoji'), 'Something went wrong. Please reload the page.': _('Something went wrong. Please reload the page.') } | tojson | safe }} diff --git a/frontend/templates/base.html b/frontend/templates/base.html index 43ab04e4..0f4d9304 100644 --- a/frontend/templates/base.html +++ b/frontend/templates/base.html @@ -153,6 +153,7 @@ {% endif %} {% if page_with_comments %} + diff --git a/translations/fr/LC_MESSAGES/messages.mo b/translations/fr/LC_MESSAGES/messages.mo index 3219a303025e311ab8118a5f88078792c9b08274..11469cdcdf3101c5d1eb65339e8b0b92d5905836 100644 GIT binary patch delta 3034 zcmYM#duWzb9Ki8o(=C0c>C)+RoA1;$Z<~tN=FE`R6j8fqOY8!9!=^Z8O)(4KmWn~z z#S)cL0s=zjMyJA7{Px zWa?B_$4!BMP5jU0|M#72{r_)$B7_{88?XpJ!n^Pr?2e}~3om02Ov?-*1AAdQ=3y@! zgg#e}jyDabgpdjgxX7cUF+T7ucBOq5``}e{pah*xn1yLL5bZCDmY{*%8?D4{w5zZ; z&O_gS35#(9<}!ac5O4g1J-P84n!&BupUx<@d*j{MA3Ne`?1E*;Csgod1*RjfhIwd! zwa6#Dz*hmjisP^W6U-mZbK!tj(bD{du0jveaiHF4=?alsp$rXV686WZqA#KuzKbLA z6EyRa@%}aB6MFHG;}l{lpNlbE+<`NYYKJG#faanBJQw?yqXDkQO5BD9b_QL->*xeM zDT4tHLdPpZ_f{oZu{mgE7Wbh3K2S%8fz)F*z8`Hu?;pp`_%pf`=g>XyCz{C>WdF5A zIw}j2W7cPdLg~5`V!v@n3XTr!%_G z<)WLl1g+2{EXCPq##?a^?nEne6bcMzTJ9JFKw=!AoDFqWeO z%|-Xj(%4>(R%SJt`9^dpH^=q|Xhl;ETsZK49D)asxx+bh&2FFpWRotJAs@|PG#YRP zdVgxX|2P)YuEs*#jE;W@tz-)ta4Yup{Ga6_NylyUcqB>N%pc^-h-aahEx@)-gAP!K z22vl}Z=o4)M>F1y2DlG>{{RlhFVPBK#7xh>XT$-!qPw^+j>Ix_EvwPWtj1xuJGM_m z(^&W-`is#esX~wCDx8el(F$EaH|1qCuzxV!`8zR-5oV$pW}y-0AfGTSwkwgK!%Vc4 zHRyZwXvXW%4A-Nl<8yqy1Eohw9j5kc4{!lm**7ueMBBM=&G(?EpaqTi2XumyNV4G! zI`QApZfuYlv~?R$v8=#W!OAQKXvTa2%ttFzjwBnVpcyShPt6K+;I-(f*%sT!qvz4*Z=sdU9N1p^Y-E$BLLL{M;{tRk zMxg;si0vvgqj_jXE73~4f(E<~&FB;od$@tV-;dv0-y0d7h#t?U(ZJVYKhOV0F5D!a zqMPeT^fXSOeI0$Fw6J~RhtMUcL!aM(F2znv;AiOOYC$V_3=RC(*nbHP@H&<-e@L*k z-4x|$iKfN&OmyId*a=s~{x#@Co6*wlK>x_>#S%P-2KWaiu_GJIaSPFaN1&CSh$%Cx z;=%#vpfA?o7+i;=@F1q+MKtnj=%(sIJ(HM=F2#5>fNFG{E$Bph(Ez@P?XS^2bz(5} z_rc%ka1*5sX)jqnG@w#+q6%ya6b)z&y0-6N+Yb#|fkVgv3P_UGTnrQ`Esp)9ov(P=T zD7M$3ORyDP+C2qa_(BUN@EE$gTQLnYiW^rZE_LiYzqT$}xp>LK>bFzfa~fB5f3JJ? hH98varC?roGa)Te zgQ1d%SV?yAk8CJ4Bprk)T`Zl3pk_0#Ng7d-F6#T;4m4ih&pGFLo^yV`-#O3gx<^mW zO`q+NwJGqokAMC7_f5jy|9>lzA>2WCC6?kA%)mFF&bD&v(vU9k-LgwcFif%}n1!fZ6a z`N$_c!`C2OiW6`vCRsn6q~V0EXlX8?yU>YroG2SDT_JKQl%auC<3OAieGbiV9gf6p zXy)I>>lctuNN|($6k@uV#!wm=I0>n8cmNHk77bty8rVXt!B^0L4xw9j3Z3{S8dx@E zaGrkXnJPmoRfAS!X1rckK>ZD%o`E7<9o-sl*n?dd{{r2T!|2|Bk7m$f8ni;Y(Lndd>qpWwoVW$e@C;gs3$fpZmMn{KT`-AdH~`zX3Oy^e zu|F5B%tAEtCFm9|i~Y4|Mc1SArnk@-PGdW=Ryd6A*=aPOzmZSqMA~Lhga$keySD5nsZ>8aDkxANSUc#3V--Bj0726LDIzb&8NPX-t zMl)W8X8a}^;3o9>?KlE=q80oBo#%YJ?EeiKp5lLSB=+N-aWAK%m05^);+wI*KY9^M z8PDecxFvU^uj7+A1y`XJ`Wii?$I-xkLKC`-3DytS$}8{4AQ`BJ}Oph8sFjdbHG){W=1iidOa|blz3y`3ns+JpH@Sh(ALYIEW+}4xtO5 zjsA|c^s_i9W;_Qy1545S)+1|&9cZRa=sL}@e*&HVw73ajas$Xi=O2#FI|>cxA@s~VQcV5Lyq*DF5`7JwXbT$o zJ6MW)aUz~U_q3Sw-P3Zk5~KMtvs$#&bJ6FYLMyWzDPGus&i7F|Hjc#`ZlQbGJ=O7x zH6APJ&qFg^k52eLdN`ZV1%E+aq;M6@*jLIu&Oy&WUz~?kXk{DFL!JI04z!?=T|q06 z;P;XwLlK%u6?Vf(=vK|dk@$4%zZKns-hTwG;E!m@e?|}IW%TvEhHODPw9zo4v556ULwke0 zz=>RFMjyy1=~y@qE&YAyA)JG5!6NhwtVT23h%T@h4gAwM-h>9&jCbKl^eiMe*C(5biy@gfcr3oN72&XKm)#op7w&$j>J;vd=;4X z!ErRk;4B=4Yp^qZjz)eEJxnJsg_qE+$f3RlFb17x0lLr%G=O!nzX?54JJI|0qG#y4 zGU{*1E;3+5860UB%0;&zg$7iC?(O4frIw%-Sc|+cp#jHYBk~gvu3|Ojl=DjBM4W(& z(L_JN@pz$}`ukvtbQlYD_yoR+*?1n4_-phgcBP*=q9fB>v{D1mz=xs+e Date: Thu, 23 Jul 2026 00:19:01 +0200 Subject: [PATCH 06/16] =?UTF-8?q?Update=20logo,=20add=20translations=20and?= =?UTF-8?q?=20fix=20comments=20:=20Refactor=20of=20`suneditor-init.js`=20w?= =?UTF-8?q?ith=20helper=20encapsulation,=20double=E2=80=91tap=20fix=20and?= =?UTF-8?q?=20removal=20of=20debug=20logs.=20`=5Ft`=20and=20`resolveLang`?= =?UTF-8?q?=20are=20moved=20into=20an=20IIFE=20to=20avoid=20globals,=20the?= =?UTF-8?q?=20double=E2=80=91tap=20logic=20is=20split=20between=20desktop?= =?UTF-8?q?=20and=20mobile=20to=20prevent=20the=20line=E2=80=91jump=20caus?= =?UTF-8?q?ed=20by=20corrupted=20selection,=20both=20tap=20timers=20reset?= =?UTF-8?q?=20on=20blur,=20indentation=20in=20the=20mapping=20loop=20is=20?= =?UTF-8?q?corrected=20and=20all=20`console.log`=20calls=20are=20removed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/static/scripts/suneditor-init.js | 178 ++++++++++++---------- 1 file changed, 97 insertions(+), 81 deletions(-) diff --git a/frontend/static/scripts/suneditor-init.js b/frontend/static/scripts/suneditor-init.js index 5fe0dccb..63de4cd1 100644 --- a/frontend/static/scripts/suneditor-init.js +++ b/frontend/static/scripts/suneditor-init.js @@ -1,88 +1,87 @@ 'use strict'; -const _t = key => { - const el = document.getElementById('app-translations'); - if (!el) return key; - try { return JSON.parse(el.textContent)[key] || key; } catch { return key; } -}; - -function resolveLang(flat) { - if (!flat) return null; - const dst = { toolbar: {}, controller: {}, menu: {}, dialogBox: {} }; - const boxMap = { linkBox: {}, imageBox: {}, videoBox: {}, audioBox: {}, mathBox: {} }; - for (const boxKey of Object.keys(boxMap)) dst.dialogBox[boxKey] = {}; - - const mappings = [ - ['bold', 'toolbar'], ['italic', 'toolbar'], ['underline', 'toolbar'], - ['strike', 'toolbar'], ['list', 'toolbar'], ['link', 'toolbar'], - ['align', 'toolbar'], ['font', 'toolbar'], ['fontSize', 'toolbar'], - ['fontColor', 'toolbar'], ['backgroundColor', 'toolbar'], - ['indent', 'toolbar'], ['outdent', 'toolbar'], - ['alignLeft', 'toolbar'], ['alignCenter', 'toolbar'], - ['alignRight', 'toolbar'], ['alignJustify', 'toolbar'], - ['codeView', 'toolbar'], ['fullScreen', 'toolbar'], - ['undo', 'toolbar'], ['redo', 'toolbar'], ['save', 'toolbar'], - ['print', 'toolbar'], ['preview', 'toolbar'], - ['removeFormat', 'toolbar'], ['copyFormat', 'toolbar'], - ['table', 'toolbar'], ['image', 'toolbar'], ['video', 'toolbar'], - ['audio', 'toolbar'], ['math', 'toolbar'], - ['horizontalRule', 'toolbar'], - ['imageGallery', 'toolbar'], ['fileBrowser', 'toolbar'], - ['fileGallery', 'toolbar'], ['mediaGallery', 'toolbar'], - ['videoGallery', 'toolbar'], ['audioGallery', 'toolbar'], - - ['maxSize', 'controller'], ['minSize', 'controller'], - ['resize', 'controller'], ['resize100', 'controller'], - ['resize25', 'controller'], ['resize50', 'controller'], - ['resize75', 'controller'], ['proportion', 'controller'], - ['ratio', 'controller'], ['rotateLeft', 'controller'], - ['rotateRight', 'controller'], - ['mirrorHorizontal', 'controller'], ['mirrorVertical', 'controller'], - ['edit', 'controller'], ['remove', 'controller'], - ['unlink', 'controller'], ['autoSize', 'controller'], - ['deleteColumn', 'controller'], ['deleteRow', 'controller'], - ['fixedColumnWidth', 'controller'], - ['insertColumnAfter', 'controller'], ['insertColumnBefore', 'controller'], - ['insertRowAbove', 'controller'], ['insertRowBelow', 'controller'], - ['mergeCells', 'controller'], ['splitCells', 'controller'], - ['tableHeader', 'controller'], - - ['close', 'dialogBox'], ['caption', 'dialogBox'], - ['submitButton', 'dialogBox'], ['basic', 'dialogBox'], - ['cancel', 'dialogBox'], - ]; - - for (const [key, ...path] of mappings) { - if (flat[key] === undefined) continue; - let obj = dst; +(() => { + const _t = key => { + const el = document.getElementById('app-translations'); + if (!el) return key; + try { return JSON.parse(el.textContent)[key] || key; } catch { return key; } + }; + + function resolveLang(flat) { + if (!flat) return null; + const dst = { toolbar: {}, controller: {}, menu: {}, dialogBox: {} }; + const boxMap = { linkBox: {}, imageBox: {}, videoBox: {}, audioBox: {}, mathBox: {} }; + for (const boxKey of Object.keys(boxMap)) dst.dialogBox[boxKey] = {}; + + const mappings = [ + ['bold', 'toolbar'], ['italic', 'toolbar'], ['underline', 'toolbar'], + ['strike', 'toolbar'], ['list', 'toolbar'], ['link', 'toolbar'], + ['align', 'toolbar'], ['font', 'toolbar'], ['fontSize', 'toolbar'], + ['fontColor', 'toolbar'], ['backgroundColor', 'toolbar'], + ['indent', 'toolbar'], ['outdent', 'toolbar'], + ['alignLeft', 'toolbar'], ['alignCenter', 'toolbar'], + ['alignRight', 'toolbar'], ['alignJustify', 'toolbar'], + ['codeView', 'toolbar'], ['fullScreen', 'toolbar'], + ['undo', 'toolbar'], ['redo', 'toolbar'], ['save', 'toolbar'], + ['print', 'toolbar'], ['preview', 'toolbar'], + ['removeFormat', 'toolbar'], ['copyFormat', 'toolbar'], + ['table', 'toolbar'], ['image', 'toolbar'], ['video', 'toolbar'], + ['audio', 'toolbar'], ['math', 'toolbar'], + ['horizontalRule', 'toolbar'], + ['imageGallery', 'toolbar'], ['fileBrowser', 'toolbar'], + ['fileGallery', 'toolbar'], ['mediaGallery', 'toolbar'], + ['videoGallery', 'toolbar'], ['audioGallery', 'toolbar'], + + ['maxSize', 'controller'], ['minSize', 'controller'], + ['resize', 'controller'], ['resize100', 'controller'], + ['resize25', 'controller'], ['resize50', 'controller'], + ['resize75', 'controller'], ['proportion', 'controller'], + ['ratio', 'controller'], ['rotateLeft', 'controller'], + ['rotateRight', 'controller'], + ['mirrorHorizontal', 'controller'], ['mirrorVertical', 'controller'], + ['edit', 'controller'], ['remove', 'controller'], + ['unlink', 'controller'], ['autoSize', 'controller'], + ['deleteColumn', 'controller'], ['deleteRow', 'controller'], + ['fixedColumnWidth', 'controller'], + ['insertColumnAfter', 'controller'], ['insertColumnBefore', 'controller'], + ['insertRowAbove', 'controller'], ['insertRowBelow', 'controller'], + ['mergeCells', 'controller'], ['splitCells', 'controller'], + ['tableHeader', 'controller'], + + ['close', 'dialogBox'], ['caption', 'dialogBox'], + ['submitButton', 'dialogBox'], ['basic', 'dialogBox'], + ['cancel', 'dialogBox'], + ]; + + for (const [key, ...path] of mappings) { + if (flat[key] === undefined) continue; + let obj = dst; for (let i = 0; i < path.length; i++) obj = obj[path[i]]; obj[key] = flat[key]; - } - - const menuKeys = ['menu_bordered', 'menu_code', 'menu_neon', 'menu_shadow', 'menu_spaced', 'menu_translucent']; - for (const key of menuKeys) { - if (flat[key] === undefined) continue; - dst.menu[key.slice(5)] = flat[key]; - } + } - const modalPrefixes = [ - { prefix: 'link_modal_', box: 'linkBox' }, - { prefix: 'image_modal_', box: 'imageBox' }, - { prefix: 'video_modal_', box: 'videoBox' }, - { prefix: 'audio_modal_', box: 'audioBox' }, - { prefix: 'math_modal_', box: 'mathBox' }, - ]; - for (const { prefix, box } of modalPrefixes) { - for (const key of Object.keys(flat)) { - if (!key.startsWith(prefix)) continue; - dst.dialogBox[box][key.slice(prefix.length)] = flat[key]; + const menuKeys = ['menu_bordered', 'menu_code', 'menu_neon', 'menu_shadow', 'menu_spaced', 'menu_translucent']; + for (const key of menuKeys) { + if (flat[key] === undefined) continue; + dst.menu[key.slice(5)] = flat[key]; } - } - return dst; -} + const modalPrefixes = [ + { prefix: 'link_modal_', box: 'linkBox' }, + { prefix: 'image_modal_', box: 'imageBox' }, + { prefix: 'video_modal_', box: 'videoBox' }, + { prefix: 'audio_modal_', box: 'audioBox' }, + { prefix: 'math_modal_', box: 'mathBox' }, + ]; + for (const { prefix, box } of modalPrefixes) { + for (const key of Object.keys(flat)) { + if (!key.startsWith(prefix)) continue; + dst.dialogBox[box][key.slice(prefix.length)] = flat[key]; + } + } -(() => { + return dst; + } function createEmojiPlugin(textareaId) { return { name: 'emoji', @@ -132,13 +131,15 @@ function resolveLang(flat) { const form = textarea.closest('form'); const hiddenInput = hiddenInputId ? document.getElementById(hiddenInputId) : null; + const resolvedLang = resolveLang(SUNEDITOR_LANG && SUNEDITOR_LANG.fr); + const editor = SUNEDITOR.create(textarea, { plugins: [createEmojiPlugin(textareaId)], buttonList: [ ['bold', 'underline', 'italic', 'strike'], ['list', 'link', 'emoji'] ], - lang: resolveLang(SUNEDITOR_LANG && SUNEDITOR_LANG.fr), + lang: resolvedLang, lineHeight: '1.4', imageUploadUrl: '', imageFileInput: false, @@ -161,7 +162,9 @@ function resolveLang(flat) { suneditors[textareaId] = editor; - const wysiwyg = textarea.nextElementSibling?.querySelector('.sun-editor-editable'); + const root = textarea.nextElementSibling; + + const wysiwyg = root?.querySelector('.sun-editor-editable'); if (wysiwyg) { wysiwyg.addEventListener('paste', (e) => { const items = e.clipboardData?.items; @@ -195,17 +198,30 @@ function resolveLang(flat) { }, true); let lastTap = null; + wysiwyg.addEventListener('blur', () => { + lastTap = null; + touchTap = null; + }); wysiwyg.addEventListener('click', () => { const now = Date.now(); if (lastTap && now - lastTap < 400) { + lastTap = null; + } else { + lastTap = now; + } + }); + let touchTap = null; + wysiwyg.addEventListener('touchend', () => { + const now = Date.now(); + if (touchTap && now - touchTap < 400) { const range = document.createRange(); range.selectNodeContents(wysiwyg); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); - lastTap = null; + touchTap = null; } else { - lastTap = now; + touchTap = now; } }); } From 152d0238319ad8b8c4165bafa5e3c6d45b9ac241 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Thu, 23 Jul 2026 00:41:52 +0200 Subject: [PATCH 07/16] =?UTF-8?q?Update=20logo,=20add=20translations=20and?= =?UTF-8?q?=20fix=20comments=20:=20French=20i18n=20added=20for=20=E2=80=9C?= =?UTF-8?q?Comment=20removed=E2=80=9D=20and=20=E2=80=9CAnonymous=E2=80=9D.?= =?UTF-8?q?=20=20=20Display=20logic=20moves=20from=20the=20DTO=20to=20temp?= =?UTF-8?q?lates=20using=20`=5F()`.=20=20Checks=20for=20anonymous=20author?= =?UTF-8?q?s=20now=20rely=20on=20`*=5Fid=20is=20none`=20instead=20of=20com?= =?UTF-8?q?paring=20the=20English=20string=20and=20templates=20render=20`{?= =?UTF-8?q?{=20=5F('Anonymous')=20}}`.=20Tests=20are=20updated=20according?= =?UTF-8?q?ly=20and=20translation=20files=20(`.po/.mo/.pot`)=20rebuilt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/static/scripts/suneditor-init.js | 9 +- frontend/templates/article_detail.html | 16 +- frontend/templates/article_list.html | 4 +- .../input_adapters/dto/comment_response.py | 9 +- .../dto/test_comment_response.py | 6 +- translations/fr/LC_MESSAGES/messages.mo | Bin 11531 -> 11461 bytes translations/fr/LC_MESSAGES/messages.po | 194 ++++++++++-------- translations/messages.pot | 138 ++++++++----- 8 files changed, 219 insertions(+), 157 deletions(-) diff --git a/frontend/static/scripts/suneditor-init.js b/frontend/static/scripts/suneditor-init.js index 63de4cd1..a583df65 100644 --- a/frontend/static/scripts/suneditor-init.js +++ b/frontend/static/scripts/suneditor-init.js @@ -56,8 +56,8 @@ for (const [key, ...path] of mappings) { if (flat[key] === undefined) continue; let obj = dst; - for (let i = 0; i < path.length; i++) obj = obj[path[i]]; - obj[key] = flat[key]; + for (let i = 0; i < path.length; i++) obj = obj[path[i]]; + obj[key] = flat[key]; } const menuKeys = ['menu_bordered', 'menu_code', 'menu_neon', 'menu_shadow', 'menu_spaced', 'menu_translucent']; @@ -200,11 +200,11 @@ let lastTap = null; wysiwyg.addEventListener('blur', () => { lastTap = null; - touchTap = null; }); wysiwyg.addEventListener('click', () => { const now = Date.now(); if (lastTap && now - lastTap < 400) { + console.log('[debug] double-tap select-all fired'); lastTap = null; } else { lastTap = now; @@ -224,6 +224,9 @@ touchTap = now; } }); + wysiwyg.addEventListener('keydown', () => { + console.log('[debug] wysiwyg keydown'); + }); } editor.insertImage = function () { diff --git a/frontend/templates/article_detail.html b/frontend/templates/article_detail.html index ae6e79c9..873ab7f8 100644 --- a/frontend/templates/article_detail.html +++ b/frontend/templates/article_detail.html @@ -51,8 +51,8 @@

{{ article.article_title }}

{{ article.article_description }} {% endif %}
- {% if article.author_username == 'Anonymous' %} - {{ article.author_username }} + {% if article.article_author_id is none %} + {{ _('Anonymous') }} {% else %} {% if article.author_avatar_file_id %} @@ -101,7 +101,7 @@

{{ article.article_title }}

{% if is_deleted %} ? - {% elif node.comment.author_username == 'Anonymous' %} + {% elif node.comment.comment_written_account_id is none %} A {% else %} @@ -116,8 +116,8 @@

{{ article.article_title }}

- {{ node.comment.comment_content | safe }} + {% if is_deleted or node.comment.comment_written_account_id is none %} + {{ _('Comment removed') }} + {% else %} + {{ node.comment.comment_content | safe }} + {% endif %}
{% if node.comment.edited_at and not node.comment.is_deleted %}
{{ _('edited at %(date)s', date=node.comment.edited_at|format_datetime_locale) }}
diff --git a/frontend/templates/article_list.html b/frontend/templates/article_list.html index 9460c35a..d47504fa 100644 --- a/frontend/templates/article_list.html +++ b/frontend/templates/article_list.html @@ -66,8 +66,8 @@

{% endif %}

- {% if article.author_username == 'Anonymous' %} - {{ article.author_username }} + {% if article.article_author_id is none %} + {{ _('Anonymous') }} {% else %} {% if article.author_avatar_file_id %} diff --git a/src/infrastructure/input_adapters/dto/comment_response.py b/src/infrastructure/input_adapters/dto/comment_response.py index d3a7ecd0..762ea68c 100644 --- a/src/infrastructure/input_adapters/dto/comment_response.py +++ b/src/infrastructure/input_adapters/dto/comment_response.py @@ -47,7 +47,7 @@ def from_domain(cls, comment, author_username: str = "Unknown", author_avatar_fi If the comment's author account has been deleted (comment_written_account_id is None) or the comment has been soft-deleted (is_deleted is True), - the author is displayed as "Anonymous" and content shows as 'Comment removed'. + the author is set to "Anonymous". Content display is handled by the template. Args: comment: The domain Comment entity to convert. @@ -60,13 +60,8 @@ def from_domain(cls, comment, author_username: str = "Unknown", author_avatar_fi content = comment.comment_content is_deleted = comment.is_deleted - if comment.comment_written_account_id is None: + if comment.comment_written_account_id is None or is_deleted: author_username = "Anonymous" - content = "Comment removed" - - elif is_deleted: - author_username = "Anonymous" - content = "Comment removed" return cls( comment_id=comment.comment_id, diff --git a/tests/tests_infrastructure/tests_input_adapters/dto/test_comment_response.py b/tests/tests_infrastructure/tests_input_adapters/dto/test_comment_response.py index 3fcd5ebc..b804df43 100644 --- a/tests/tests_infrastructure/tests_input_adapters/dto/test_comment_response.py +++ b/tests/tests_infrastructure/tests_input_adapters/dto/test_comment_response.py @@ -54,7 +54,7 @@ def test_comment_response_from_domain_deleted(): response = CommentResponse.from_domain(domain_comment, author_username="johndoe") assert response.author_username == "Anonymous" - assert response.comment_content == "Comment removed" + assert response.comment_content == "Original content" assert response.is_deleted is True def test_comment_response_from_domain_edited(): @@ -91,7 +91,7 @@ def test_comment_response_from_domain_deleted_with_edited_at(): response = CommentResponse.from_domain(domain_comment, author_username="johndoe") assert response.author_username == "Anonymous" - assert response.comment_content == "Comment removed" + assert response.comment_content == "Edited then deleted" assert response.is_deleted is True assert response.edited_at == edited_at @@ -170,7 +170,7 @@ def test_from_domain_with_none_author_id_maps_to_removed(): result = CommentResponse.from_domain(comment, "some_user") assert result.author_username == "Anonymous" assert result.comment_written_account_id is None - assert result.comment_content == "Comment removed" + assert result.comment_content == "Original content" def test_map_nested_tree_threads_avatar(): posted_at = datetime(2023, 10, 27, 14, 30) diff --git a/translations/fr/LC_MESSAGES/messages.mo b/translations/fr/LC_MESSAGES/messages.mo index 11469cdcdf3101c5d1eb65339e8b0b92d5905836..22a1b36f5818b0f8e448354fd6747e4ad9f7836f 100644 GIT binary patch delta 3042 zcmYM#eN5F=9LMo<0Rclq9^)c-ub=_OGb-i-=$cwfG^e$pqKRSZ@WOE7d|-ELvnIF{ z?Y=rmb+?hx=^9!`lBLJRvEZh;YOjBPbjV4AMV+5a9`(~`}>{q{eI6m*tqJ; zGH-88NVDPJ8Ge)b?Hr@t|Np8YjB(Q4is|?!M&YM80oySKzr$$k$1uEyq4*ERW5~F` zb4jTAvT>|2UbC3S3k)pB6l_6FcpfA18%)IOs0r_(79PQ1jN~DWM_Ut70lKWYIG%n9 zK8Z_F?{CCx);Bw7DDzKHFJ86}e2;PT`%xJ^wBwph{b-CuCx&1)K7j?ukD14ZDzXH5 z)vQGYT7~?WZG22-ee*hvIoOI+%iKUs_y?*~cTl?$L3ElZ8dV_|@|7t-1+oBL_>y%y zD${1n!!}d}dhGYN(95rh;weq!!bzBeL0F1Z*}RAfs2mkQg&nU!1z3*@@f}oPzo54A zHfsLyltBSbLd{ozs(48>^;gN(Frd=BW*_jN0@;lT_@4EY{r(Dac+6GQR`j9H!T>6f zK~yClp%x4!D)nPgfw)i!6~<8iR2s7w(1NQ`d%g<^-W;%gh|Fb9AwQ;r4>w-JaD0F| z<(gg3C7=#%2C6~}un1S9687N~JnE&P5_O;=?Y0lxKutV|%J43#5+k-BOO&eEMAU+* zn1Rz#^OU2`%of|PK~<(6mHB?uR(hN4zz3+3wxT9JiJ5o?S=02P_Uvy|Kodxprb$F4 zkc|p>9_sr=_WPxnO@9MU#U|AJpCd){nlET5;x0_WUUcIyPR1nCR_1g0P{b=xnXSjs zGlP2Hg9>D~?Kh$_J&X#h1@&GVX5k6c`7=M#&;*0lhp5v#hP#r7X{bG1iK>VPpT@(s z-)SAfbjB0ecx}ae)OFm9&*MH+Wx7#^@n;NYeKSBqncPP0c9getuU_5L1Ig$^P`G#`6uXu@{ufPEm! z9oXAc)E}s2n2WWjOg};-Tp;BK& zo$9N$?_gC$n1HHG0g|kF7L`#2>eketCay=_mS)@k%G!&1{ywUbA!&iqkH=u$|9Bd@ zw@%bnWTFC^W&5S5jMktsszp^|7b@W6sEn>5v70-n_hb3{s`oOi#i(n!5*2toPSpKx zprJ$3hB{;ytvxuO{t)Vg+^Kn2ID11maieAH{4V0*}1iP`!@f^al0I0wUt||>ndOM_e5R^_n%By z8{~63J-+?U=CDF{Uf%4&B7a8Gup?~2=v$S3ck*?I|F@Kx5&ojgalyW_tXiH;^xeuz F{tp}*D8>K) delta 3139 zcmYM#e@xVM9LMnwoPu(PB?-vyL%k3^P*Vai8%F4w+7FGK{n$J?fA_#><3uO$sD?fK$9TkgEa_w)IFzn{sM7p}T7|Ksqk&RUrSl-SObIHG1(<`+SYJeC z_%=?*4^f$)wfAo#ACt^O8png-Y%XST5sOQaYMZA}0ac*_sI~oTQ2}nk3fzMV>^y1- z2T>D@qYMgg5^B5>)ZVH=d z8R~h}y;JI2}t+YZ*XQ zW)n`u1Ge37b+GVx^cSF(q!M*3H{v7MiK@_L)TX?K3hW<@)BGcuMG?AC87843OhZ0q zs%=*wL7Qc$QZ}I8Ye8k)ipp>s>U4a9ug6e&q||0nMl`@0RAt}5uqNu{LTi2ybqab> z5&wXi;4G4?Iggt7Z|fK~$Wq$5sEk9XJ+c$^To*F8IgU!K4>iw#ZQs!EuO=9xLlfRX zO%%^MaA|T-13rNF;uEL}ti##3-S(eEs%frSM{~HfyCqcnh_rb4Xunx&T#)$N5rXji^$uMZLcnRheB#5zPl- zE;Qg*)~oh`RCjbOr=WhZmfv#zT8bH{fXZyU5|xo3mC<@sC0;=Vd>EC{01~?yLcO2C@2%dOZk>-h zp3kBJ-+~!B|2w$QCiw`pxlUNmVHxc~)Cp=xP4OQv+7*=MLTxh_Rs23Y>7PjIHJce<21r_;C)TTaR>M(ou@(q@fopkuI|hS70~B<9Kg0Ft^o< z3G|nsGA&0{Y6&Xv<)}Td%C=ikORyWYvg0FKDlG4n&r?*2P4A zO8jh8Vq^UppWpqwFHm3YFN}1gtcZ2_{Hp^E#up0Ku4!r?9KXg9YOZeI?oDsskhUTI zKKG2G(xReBO!`nvBsTL*Oytk39A`$)t5vntRc?RC9qid(-?P7}*4I>1y}G&~SQtsl nPjn>D)1ZOoV3RLc9cnW3LZUs}+dQf=7^n|4`-1Il-je?S=8iz$ diff --git a/translations/fr/LC_MESSAGES/messages.po b/translations/fr/LC_MESSAGES/messages.po index 7e64323c..9909cf46 100644 --- a/translations/fr/LC_MESSAGES/messages.po +++ b/translations/fr/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-07-22 15:55+0200\n" +"POT-Creation-Date: 2026-07-23 00:37+0200\n" "PO-Revision-Date: 2026-07-22 14:15+0200\n" "Last-Translator: FULL NAME \n" "Language: fr\n" @@ -16,7 +16,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.18.0\n" +"Generated-By: Babel 2.10.3\n" #: frontend/templates/article_create.html:3 msgid "Write an Article - DevJournal" @@ -43,21 +43,8 @@ msgstr "Échec de la sauvegarde." msgid "Network error." msgstr "Erreur réseau." -#: frontend/core/components/ArticleForm.jsx:85 -msgid "Upload failed." -msgstr "Échec du téléchargement." - -#: frontend/static/scripts/suneditor-init.js:140 -msgid "Comment cannot be empty" -msgstr "Le commentaire ne peut pas être vide." - -#: frontend/static/scripts/suneditor-init.js:15 -#: frontend/templates/article_detail.html:239 -msgid "Insert Emoji" -msgstr "Insérer un émoticône" - #: frontend/templates/article_create.html:31 -#: frontend/templates/article_detail.html:234 +#: frontend/templates/article_detail.html:238 #: frontend/templates/article_edit.html:60 msgid "Loading..." msgstr "Chargement..." @@ -103,7 +90,7 @@ msgid "Publish" msgstr "Publier" #: frontend/templates/article_create.html:40 -#: frontend/templates/article_detail.html:155 +#: frontend/templates/article_detail.html:159 #: frontend/templates/article_edit.html:69 frontend/templates/profile.html:94 #: frontend/templates/profile.html:116 msgid "Save" @@ -145,33 +132,24 @@ msgid "Only YouTube links are supported" msgstr "Seules les liens YouTube sont supportés" #: frontend/templates/article_create.html:48 +#: frontend/templates/article_detail.html:242 #: frontend/templates/article_edit.html:77 msgid "Failed to load article." msgstr "Échec du chargement de l'article." #: frontend/templates/article_create.html:49 +#: frontend/templates/article_detail.html:244 #: frontend/templates/article_edit.html:78 msgid "Copy" msgstr "Copier" -#: frontend/core/utils/custom-code-block-spec.js -msgid "Plain Text" -msgstr "Texte simple" - -#: frontend/core/utils/custom-code-block-spec.js -msgid "Search languages..." -msgstr "Rechercher un langage..." - -#: frontend/core/utils/custom-code-block-spec.js -msgid "No languages found" -msgstr "Aucun langage trouvé" - #: frontend/templates/article_create.html:50 #: frontend/templates/article_edit.html:79 msgid "Copy Block" msgstr "Copier le bloc" #: frontend/templates/article_create.html:51 +#: frontend/templates/article_detail.html:240 #: frontend/templates/article_edit.html:80 msgid "Copied to clipboard" msgstr "Copié dans le presse-papier" @@ -199,13 +177,35 @@ msgid "Align text justify" msgstr "Justifier le texte" #: frontend/templates/article_create.html:56 -#: frontend/templates/article_detail.html:236 +#: frontend/templates/article_detail.html:243 #: frontend/templates/article_edit.html:85 +msgid "Plain Text" +msgstr "Texte simple" + +#: frontend/templates/article_create.html:57 +#: frontend/templates/article_edit.html:86 +msgid "Search languages..." +msgstr "Rechercher un langage..." + +#: frontend/templates/article_create.html:58 +#: frontend/templates/article_edit.html:87 +msgid "No languages found" +msgstr "Aucun langage trouvé" + +#: frontend/templates/article_create.html:59 +#: frontend/templates/article_detail.html:246 +#: frontend/templates/article_edit.html:88 msgid "Something went wrong. Please reload the page." msgstr "Une erreur est survenue. Veuillez recharger la page." +#: frontend/templates/article_detail.html:55 +#: frontend/templates/article_detail.html:120 +#: frontend/templates/article_list.html:70 +msgid "Anonymous" +msgstr "Anonyme" + #: frontend/templates/article_detail.html:72 -#: frontend/templates/article_detail.html:145 +#: frontend/templates/article_detail.html:149 #: frontend/templates/article_list.html:88 #, python-format msgid "edited at %(date)s" @@ -224,6 +224,16 @@ msgstr "Supprimer définitivement cet article ?" msgid "[Edit]" msgstr "[Modifier]" +#: frontend/templates/article_detail.html:126 +#: frontend/templates/article_detail.html:160 +#: frontend/templates/article_detail.html:168 +#: frontend/templates/article_detail.html:184 +#: frontend/templates/article_list.html:143 frontend/templates/base.html:108 +#: frontend/templates/base.html:132 frontend/templates/profile.html:93 +#: frontend/templates/profile.html:115 frontend/templates/user_list.html:143 +msgid "Cancel" +msgstr "Annuler" + #: frontend/templates/article_detail.html:131 #: frontend/templates/article_detail.html:137 msgid "[Delete]" @@ -233,66 +243,70 @@ msgstr "[Supprimer]" msgid "Permanently delete this comment? This cannot be undone." msgstr "Supprimer définitivement ce commentaire ? Action irréversible." -#: frontend/templates/article_detail.html:152 -#: frontend/templates/article_detail.html:176 -#: frontend/templates/article_detail.html:213 -msgid "Comment must be between 1 and 5000 characters" -msgstr "Le commentaire doit contenir entre 1 et 5 000 caractères" +#: frontend/templates/article_detail.html:143 +msgid "Comment removed" +msgstr "Commentaire supprimé." #: frontend/templates/article_detail.html:156 #: frontend/templates/article_detail.html:180 -#: frontend/templates/article_list.html:143 frontend/templates/base.html:108 -#: frontend/templates/base.html:132 frontend/templates/profile.html:93 -#: frontend/templates/profile.html:115 frontend/templates/user_list.html:143 -msgid "Cancel" -msgstr "Annuler" +#: frontend/templates/article_detail.html:217 +msgid "Comment must be between 1 and 5000 characters" +msgstr "Le commentaire doit contenir entre 1 et 5 000 caractères" -#: frontend/templates/article_detail.html:164 +#: frontend/templates/article_detail.html:168 msgid "Reply" msgstr "Répondre" -#: frontend/templates/article_detail.html:167 -#: frontend/templates/article_detail.html:187 +#: frontend/templates/article_detail.html:171 +#: frontend/templates/article_detail.html:191 msgid "View replies" msgstr "Voir les réponses" -#: frontend/templates/article_detail.html:167 -#: frontend/templates/article_detail.html:187 +#: frontend/templates/article_detail.html:171 +#: frontend/templates/article_detail.html:191 msgid "Hide replies" msgstr "Masquer les réponses" -#: frontend/templates/article_detail.html:179 +#: frontend/templates/article_detail.html:183 msgid "Post Reply" msgstr "Publier la réponse" -#: frontend/templates/article_detail.html:203 +#: frontend/templates/article_detail.html:207 #, python-format msgid "Comments (%(count)s)" msgstr "Commentaires (%(count)s)" -#: frontend/templates/article_detail.html:207 +#: frontend/templates/article_detail.html:211 msgid "No comments yet." msgstr "Aucun commentaire pour l'instant." -#: frontend/templates/article_detail.html:212 +#: frontend/templates/article_detail.html:216 msgid "Join the discussion" msgstr "Participer à la discussion" -#: frontend/templates/article_detail.html:219 +#: frontend/templates/article_detail.html:223 msgid "Post Comment" msgstr "Publier le commentaire" -#: frontend/templates/article_detail.html:225 +#: frontend/templates/article_detail.html:229 #, python-format msgid "" "Please sign in to " "join the conversation." msgstr "" -#: frontend/templates/article_detail.html:235 +#: frontend/templates/article_detail.html:239 msgid "Unable to render article content." msgstr "Impossible d'afficher le contenu de l'article." +#: frontend/templates/article_detail.html:241 +msgid "Comment cannot be empty" +msgstr "Le commentaire ne peut pas être vide." + +#: frontend/templates/article_detail.html:245 +msgid "Insert Emoji" +msgstr "Insérer un émoticône" + #: frontend/templates/article_edit.html:3 msgid "Edit Article - DevJournal" msgstr "Modifier l'article - DevJournal" @@ -483,18 +497,6 @@ msgstr "Utilisateur" msgid "Author" msgstr "Auteur" -#: frontend/templates/profile.html:32 frontend/templates/user_list.html:63 -msgid "admin" -msgstr "Administrateur" - -#: frontend/templates/profile.html:32 frontend/templates/user_list.html:63 -msgid "author" -msgstr "Auteur" - -#: frontend/templates/profile.html:32 frontend/templates/user_list.html:63 -msgid "user" -msgstr "Utilisateur" - #: frontend/templates/profile.html:40 frontend/templates/user_list.html:78 msgid "Update" msgstr "Mettre à jour" @@ -556,7 +558,9 @@ msgstr "Déconnexion" msgid "" "Are you sure you want to delete your account ? This action is " "irreversible." -msgstr "Êtes-vous sûr de vouloir supprimer votre compte ? Cette action est irréversible." +msgstr "" +"Êtes-vous sûr de vouloir supprimer votre compte ? Cette action est " +"irréversible." #: frontend/templates/profile.html:136 frontend/templates/profile.html:159 msgid "Delete Account" @@ -641,75 +645,75 @@ msgstr "—" msgid "Enter page number (1 - %(max)d)" msgstr "Entrez le numéro de page (1 - %(max)d)" -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:77 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:78 msgid "You have been logged out." msgstr "Vous avez été déconnecté." -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:91 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:92 msgid "Please sign in to view your profile." msgstr "Veuillez vous connecter pour voir votre profil." -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:148 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:149 msgid "Authentication required." msgstr "Authentification requise." -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:152 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:153 msgid "No file provided." msgstr "Aucun fichier fourni." -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:195 -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:226 -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:254 -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:343 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:196 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:227 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:255 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:344 msgid "Please sign in." msgstr "Veuillez vous connecter." -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:200 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:201 msgid "No avatar to remove." msgstr "Aucun avatar à supprimer." -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:207 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:208 msgid "Failed to remove profile photo." msgstr "Échec de la suppression de la photo de profil." -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:210 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:211 msgid "Profile photo removed." msgstr "Photo de profil supprimée." -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:231 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:232 msgid "Email is required." msgstr "L'email est requis." -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:238 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:239 msgid "Email updated." msgstr "Email mis à jour." -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:259 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:260 msgid "Password is required." msgstr "Le mot de passe est requis." -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:266 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:267 msgid "Password updated." msgstr "Mot de passe mis à jour." -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:357 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:358 msgid "Account not found." msgstr "Compte introuvable." -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:374 -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:377 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:375 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:378 msgid "Account deleted." msgstr "Compte supprimé." -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:416 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:417 msgid "Role updated." msgstr "Rôle mis à jour." -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:451 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:452 msgid "Account banned." msgstr "Compte banni." -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:482 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:483 msgid "Account unbanned." msgstr "Compte débanni." @@ -829,11 +833,11 @@ msgstr "Commentaire mis à jour." msgid "Comment permanently deleted." msgstr "Commentaire définitivement supprimé." -#: src/infrastructure/input_adapters/flask/flask_file_adapter.py:32 +#: src/infrastructure/input_adapters/flask/flask_file_adapter.py:33 msgid "No file provided" msgstr "Aucun fichier fourni" -#: src/infrastructure/input_adapters/flask/flask_file_adapter.py:72 +#: src/infrastructure/input_adapters/flask/flask_file_adapter.py:73 msgid "File not found" msgstr "Fichier introuvable" @@ -889,3 +893,15 @@ msgstr "Inscription réussie. Veuillez vous connecter." #~ "Vous postez trop vite. Veuillez attendre" #~ " %(remaining)ss avant de reposter." +#~ msgid "Upload failed." +#~ msgstr "Échec du téléchargement." + +#~ msgid "admin" +#~ msgstr "Administrateur" + +#~ msgid "author" +#~ msgstr "Auteur" + +#~ msgid "user" +#~ msgstr "Utilisateur" + diff --git a/translations/messages.pot b/translations/messages.pot index a34fc469..a4225dfa 100644 --- a/translations/messages.pot +++ b/translations/messages.pot @@ -8,14 +8,14 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-07-22 15:55+0200\n" +"POT-Creation-Date: 2026-07-23 00:37+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.18.0\n" +"Generated-By: Babel 2.10.3\n" #: frontend/templates/article_create.html:3 msgid "Write an Article - DevJournal" @@ -43,7 +43,7 @@ msgid "Network error." msgstr "" #: frontend/templates/article_create.html:31 -#: frontend/templates/article_detail.html:234 +#: frontend/templates/article_detail.html:238 #: frontend/templates/article_edit.html:60 msgid "Loading..." msgstr "" @@ -89,7 +89,7 @@ msgid "Publish" msgstr "" #: frontend/templates/article_create.html:40 -#: frontend/templates/article_detail.html:155 +#: frontend/templates/article_detail.html:159 #: frontend/templates/article_edit.html:69 frontend/templates/profile.html:94 #: frontend/templates/profile.html:116 msgid "Save" @@ -131,11 +131,13 @@ msgid "Only YouTube links are supported" msgstr "" #: frontend/templates/article_create.html:48 +#: frontend/templates/article_detail.html:242 #: frontend/templates/article_edit.html:77 msgid "Failed to load article." msgstr "" #: frontend/templates/article_create.html:49 +#: frontend/templates/article_detail.html:244 #: frontend/templates/article_edit.html:78 msgid "Copy" msgstr "" @@ -146,6 +148,7 @@ msgid "Copy Block" msgstr "" #: frontend/templates/article_create.html:51 +#: frontend/templates/article_detail.html:240 #: frontend/templates/article_edit.html:80 msgid "Copied to clipboard" msgstr "" @@ -173,13 +176,35 @@ msgid "Align text justify" msgstr "" #: frontend/templates/article_create.html:56 -#: frontend/templates/article_detail.html:236 +#: frontend/templates/article_detail.html:243 #: frontend/templates/article_edit.html:85 +msgid "Plain Text" +msgstr "" + +#: frontend/templates/article_create.html:57 +#: frontend/templates/article_edit.html:86 +msgid "Search languages..." +msgstr "" + +#: frontend/templates/article_create.html:58 +#: frontend/templates/article_edit.html:87 +msgid "No languages found" +msgstr "" + +#: frontend/templates/article_create.html:59 +#: frontend/templates/article_detail.html:246 +#: frontend/templates/article_edit.html:88 msgid "Something went wrong. Please reload the page." msgstr "" +#: frontend/templates/article_detail.html:55 +#: frontend/templates/article_detail.html:120 +#: frontend/templates/article_list.html:70 +msgid "Anonymous" +msgstr "" + #: frontend/templates/article_detail.html:72 -#: frontend/templates/article_detail.html:145 +#: frontend/templates/article_detail.html:149 #: frontend/templates/article_list.html:88 #, python-format msgid "edited at %(date)s" @@ -198,6 +223,16 @@ msgstr "" msgid "[Edit]" msgstr "" +#: frontend/templates/article_detail.html:126 +#: frontend/templates/article_detail.html:160 +#: frontend/templates/article_detail.html:168 +#: frontend/templates/article_detail.html:184 +#: frontend/templates/article_list.html:143 frontend/templates/base.html:108 +#: frontend/templates/base.html:132 frontend/templates/profile.html:93 +#: frontend/templates/profile.html:115 frontend/templates/user_list.html:143 +msgid "Cancel" +msgstr "" + #: frontend/templates/article_detail.html:131 #: frontend/templates/article_detail.html:137 msgid "[Delete]" @@ -207,61 +242,70 @@ msgstr "" msgid "Permanently delete this comment? This cannot be undone." msgstr "" -#: frontend/templates/article_detail.html:152 -#: frontend/templates/article_detail.html:176 -#: frontend/templates/article_detail.html:213 -msgid "Comment must be between 1 and 5000 characters" +#: frontend/templates/article_detail.html:143 +msgid "Comment removed" msgstr "" #: frontend/templates/article_detail.html:156 #: frontend/templates/article_detail.html:180 -#: frontend/templates/article_list.html:143 frontend/templates/base.html:108 -#: frontend/templates/base.html:132 frontend/templates/profile.html:93 -#: frontend/templates/profile.html:115 frontend/templates/user_list.html:143 -msgid "Cancel" +#: frontend/templates/article_detail.html:217 +msgid "Comment must be between 1 and 5000 characters" msgstr "" -#: frontend/templates/article_detail.html:164 +#: frontend/templates/article_detail.html:168 msgid "Reply" msgstr "" -#: frontend/templates/article_detail.html:167 -#: frontend/templates/article_detail.html:187 +#: frontend/templates/article_detail.html:171 +#: frontend/templates/article_detail.html:191 msgid "View replies" msgstr "" -#: frontend/templates/article_detail.html:179 +#: frontend/templates/article_detail.html:171 +#: frontend/templates/article_detail.html:191 +msgid "Hide replies" +msgstr "" + +#: frontend/templates/article_detail.html:183 msgid "Post Reply" msgstr "" -#: frontend/templates/article_detail.html:203 +#: frontend/templates/article_detail.html:207 #, python-format msgid "Comments (%(count)s)" msgstr "" -#: frontend/templates/article_detail.html:207 +#: frontend/templates/article_detail.html:211 msgid "No comments yet." msgstr "" -#: frontend/templates/article_detail.html:212 +#: frontend/templates/article_detail.html:216 msgid "Join the discussion" msgstr "" -#: frontend/templates/article_detail.html:219 +#: frontend/templates/article_detail.html:223 msgid "Post Comment" msgstr "" -#: frontend/templates/article_detail.html:225 +#: frontend/templates/article_detail.html:229 #, python-format msgid "" "Please sign in to " "join the conversation." msgstr "" -#: frontend/templates/article_detail.html:235 +#: frontend/templates/article_detail.html:239 msgid "Unable to render article content." msgstr "" +#: frontend/templates/article_detail.html:241 +msgid "Comment cannot be empty" +msgstr "" + +#: frontend/templates/article_detail.html:245 +msgid "Insert Emoji" +msgstr "" + #: frontend/templates/article_edit.html:3 msgid "Edit Article - DevJournal" msgstr "" @@ -598,75 +642,75 @@ msgstr "" msgid "Enter page number (1 - %(max)d)" msgstr "" -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:77 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:78 msgid "You have been logged out." msgstr "" -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:91 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:92 msgid "Please sign in to view your profile." msgstr "" -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:148 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:149 msgid "Authentication required." msgstr "" -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:152 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:153 msgid "No file provided." msgstr "" -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:195 -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:226 -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:254 -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:343 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:196 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:227 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:255 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:344 msgid "Please sign in." msgstr "" -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:200 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:201 msgid "No avatar to remove." msgstr "" -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:207 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:208 msgid "Failed to remove profile photo." msgstr "" -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:210 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:211 msgid "Profile photo removed." msgstr "" -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:231 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:232 msgid "Email is required." msgstr "" -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:238 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:239 msgid "Email updated." msgstr "" -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:259 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:260 msgid "Password is required." msgstr "" -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:266 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:267 msgid "Password updated." msgstr "" -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:357 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:358 msgid "Account not found." msgstr "" -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:374 -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:377 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:375 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:378 msgid "Account deleted." msgstr "" -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:416 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:417 msgid "Role updated." msgstr "" -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:451 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:452 msgid "Account banned." msgstr "" -#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:482 +#: src/infrastructure/input_adapters/flask/flask_account_session_adapter.py:483 msgid "Account unbanned." msgstr "" @@ -782,11 +826,11 @@ msgstr "" msgid "Comment permanently deleted." msgstr "" -#: src/infrastructure/input_adapters/flask/flask_file_adapter.py:32 +#: src/infrastructure/input_adapters/flask/flask_file_adapter.py:33 msgid "No file provided" msgstr "" -#: src/infrastructure/input_adapters/flask/flask_file_adapter.py:72 +#: src/infrastructure/input_adapters/flask/flask_file_adapter.py:73 msgid "File not found" msgstr "" From 25163af6b2b44b81d51776241198aa0410a85e22 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Thu, 23 Jul 2026 01:58:22 +0200 Subject: [PATCH 08/16] =?UTF-8?q?Update=20logo,=20add=20translations=20and?= =?UTF-8?q?=20fix=20comments=20:=20Code=E2=80=91block=20copy=20and=20Block?= =?UTF-8?q?Note=20paste=20are=20fixed.=20=20=20Copy=20now=20uses=20a=20cus?= =?UTF-8?q?tom=20`copy`=20event=20with=20`execCommand('copy')`,=20letting?= =?UTF-8?q?=20BlockNote=20read=20`text/html`.=20The=20clipboard=20HTML=20i?= =?UTF-8?q?ncludes=20a=20``=20wrapper,=20trailing=20`
?= =?UTF-8?q?`=20is=20replaced=20with=20a=20zero=E2=80=91width=20space,=20em?= =?UTF-8?q?pty=20blocks=20still=20trigger=20the=20toast,=20a=20`!pre`=20gu?= =?UTF-8?q?ard=20protects=20`cloneNode`,=20copy=20failures=20show=20a=20tr?= =?UTF-8?q?anslated=20=E2=80=9CFailed=20to=20copy=E2=80=9D=20and=20all=20d?= =?UTF-8?q?ebug=20code=20is=20removed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/static/scripts/code-copy.js | 38 +++++++++++-------------- translations/fr/LC_MESSAGES/messages.po | 4 +++ translations/messages.pot | 3 ++ 3 files changed, 24 insertions(+), 21 deletions(-) diff --git a/frontend/static/scripts/code-copy.js b/frontend/static/scripts/code-copy.js index 1df751bc..14a200f3 100644 --- a/frontend/static/scripts/code-copy.js +++ b/frontend/static/scripts/code-copy.js @@ -1,7 +1,6 @@ (() => { 'use strict'; - const DEBUG = false; const _t = key => { const el = document.getElementById('app-translations'); if (!el) return key; @@ -16,40 +15,37 @@ const codeEl = codeBlock?.querySelector('pre code'); if (!codeEl) return; - const text = codeEl.textContent || ''; - if (!text) return; + const text = (codeEl.textContent || '').replace(/\n$/, '') || '\u200B'; const lang = btn.dataset.lang || ''; const pre = codeBlock.querySelector('pre'); + if (!pre) return; const preClone = pre.cloneNode(true); if (lang) preClone.dataset.lang = lang; const codeClone = preClone.querySelector('code'); if (lang && codeClone) codeClone.dataset.language = lang; - const html = preClone.outerHTML || ''; - - const doCopy = () => { - const hasItem = typeof ClipboardItem !== 'undefined'; - if (hasItem && html) { - return navigator.clipboard.write([ - new ClipboardItem({ - 'text/html': new Blob([html], { type: 'text/html' }), - 'text/plain': new Blob([text], { type: 'text/plain' }), - }), - ]); - } - return navigator.clipboard.writeText(text); - }; + const html = (preClone.outerHTML || '').replace(/
/g, '\u200B'); + + const bnContent = text === '\u200B' ? [] : [{ type: 'text', text: text, styles: {} }]; + const bnData = { type: 'codeBlock', props: { language: lang || 'plaintext' }, content: bnContent }; + const bnHtml = ''; + const combinedHtml = bnHtml + html; - doCopy().catch(() => { - if (DEBUG) console.error('Copy failed'); - }); + const handler = (e) => { + e.clipboardData.setData('text/html', combinedHtml); + e.clipboardData.setData('text/plain', text); + e.preventDefault(); + }; + document.addEventListener('copy', handler); + const copyOk = document.execCommand('copy'); + document.removeEventListener('copy', handler); const oldToast = document.querySelector('.toast'); if (oldToast) oldToast.remove(); const toast = document.createElement('div'); toast.className = 'toast'; - toast.textContent = _t('Copied to clipboard'); + toast.textContent = copyOk ? _t('Copied to clipboard') : _t('Failed to copy'); document.body.appendChild(toast); diff --git a/translations/fr/LC_MESSAGES/messages.po b/translations/fr/LC_MESSAGES/messages.po index 9909cf46..25dc108c 100644 --- a/translations/fr/LC_MESSAGES/messages.po +++ b/translations/fr/LC_MESSAGES/messages.po @@ -154,6 +154,10 @@ msgstr "Copier le bloc" msgid "Copied to clipboard" msgstr "Copié dans le presse-papier" +#: frontend/static/scripts/code-copy.js +msgid "Failed to copy" +msgstr "Échec de la copie" + #: frontend/templates/article_create.html:52 #: frontend/templates/article_edit.html:81 msgid "Move Up" diff --git a/translations/messages.pot b/translations/messages.pot index a4225dfa..3d29dc75 100644 --- a/translations/messages.pot +++ b/translations/messages.pot @@ -153,6 +153,9 @@ msgstr "" msgid "Copied to clipboard" msgstr "" +msgid "Failed to copy" +msgstr "" + #: frontend/templates/article_create.html:52 #: frontend/templates/article_edit.html:81 msgid "Move Up" From 41036033dc8442faaad1d08a599fe4649e8d1175 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Thu, 23 Jul 2026 02:08:32 +0200 Subject: [PATCH 09/16] =?UTF-8?q?Update=20logo,=20add=20translations=20and?= =?UTF-8?q?=20fix=20comments=20:=20Anonymous=20comment=20avatar=20updated.?= =?UTF-8?q?=20=20=20The=20hardcoded=20`A`=20initial=20is=20replaced=20with?= =?UTF-8?q?=20`=3F`,=20avoiding=20confusion=20with=20real=20user=20initial?= =?UTF-8?q?s=20and=20matching=20the=20soft=E2=80=91deleted=20comment=20ava?= =?UTF-8?q?tar=20style.=20Change=20applied=20in=20`article=5Fdetail.html`?= =?UTF-8?q?=20at=20line=20105?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/templates/article_detail.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/templates/article_detail.html b/frontend/templates/article_detail.html index 873ab7f8..66e9ed42 100644 --- a/frontend/templates/article_detail.html +++ b/frontend/templates/article_detail.html @@ -102,7 +102,7 @@

{{ article.article_title }}

{% if is_deleted %} ? {% elif node.comment.comment_written_account_id is none %} - A + ? {% else %} {% if node.comment.author_avatar_file_id %} From ddca2f7f124c66c54324a47ac77f4fe3480f0140 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Thu, 23 Jul 2026 02:21:13 +0200 Subject: [PATCH 10/16] =?UTF-8?q?Update=20logo,=20add=20translations=20and?= =?UTF-8?q?=20fix=20comments=20:=20French=20i18n=20completed=20for=20remai?= =?UTF-8?q?ning=20English=20strings.=20The=20403/404/500=20error=20message?= =?UTF-8?q?s=20are=20now=20wrapped=20in=20`=5F()`=20and=20translated.=20Ro?= =?UTF-8?q?le=20labels=20(`admin`/`author`/`user`)=20are=20restored=20from?= =?UTF-8?q?=20obsolete=20entries=20so=20`{{=20=5F(role)=20}}`=20resolves?= =?UTF-8?q?=20correctly.=20=E2=80=9CPlease=20sign=20in=20to=20join=20the?= =?UTF-8?q?=20conversation=E2=80=9D=20receives=20its=20missing=20French=20?= =?UTF-8?q?msgstr.=20Obsolete=20blocks=20are=20removed,=20six=20new=20msgi?= =?UTF-8?q?ds=20added=20to=20the=20`.pot`,=20and=20`.mo`=20recompiled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- blog_comment_application.py | 8 ++--- translations/fr/LC_MESSAGES/messages.mo | Bin 11461 -> 12184 bytes translations/fr/LC_MESSAGES/messages.po | 39 +++++++++++++++--------- translations/messages.pot | 24 +++++++++++++++ 4 files changed, 52 insertions(+), 19 deletions(-) diff --git a/blog_comment_application.py b/blog_comment_application.py index 0437f5a1..982c9bb3 100644 --- a/blog_comment_application.py +++ b/blog_comment_application.py @@ -2,7 +2,7 @@ from datetime import timedelta from flask import Flask, render_template -from flask_babel import Babel +from flask_babel import Babel, gettext as _ from flask_compress import Compress from sqlalchemy.orm import Session @@ -203,9 +203,9 @@ def create_app(db_session=None) -> Flask: web_adapters = _init_web_adapters(services) register_web_routes(app, web_adapters) web_adapters["account_session_adapter"].register_before_request_handler(app) - app.errorhandler(403)(lambda e: _error_page(403, "You do not have permission to access this page.")) - app.errorhandler(404)(lambda e: _error_page(404, "The page you are looking for does not exist.")) - app.errorhandler(500)(lambda e: _error_page(500, "An unexpected error occurred. Please try again later.")) + app.errorhandler(403)(lambda e: _error_page(403, _("You do not have permission to access this page."))) + app.errorhandler(404)(lambda e: _error_page(404, _("The page you are looking for does not exist."))) + app.errorhandler(500)(lambda e: _error_page(500, _("An unexpected error occurred. Please try again later."))) return app diff --git a/translations/fr/LC_MESSAGES/messages.mo b/translations/fr/LC_MESSAGES/messages.mo index 22a1b36f5818b0f8e448354fd6747e4ad9f7836f..82605b452bba27088c8d221dd12a1fdffe6ae827 100644 GIT binary patch delta 3641 zcmb8xeT-CB8OQOnv$JfM1iFF8oP|NdOAH)agoE7**+LyWl{r{Qp1 zf{nNm8!&^lSVT>=6G!4M)P2vQ=6e-q8&fjzh^@6LB6cz%L`!Hcz7_ zd>&P@m#_wZikhe&RiU$}g)X83xsDUCp7O{xRHc^lF&)>U?(fEuO7R>G-S{T*tT|(E zxPW)jzlsTLBn@`Sj6?;NLIu)n$7iDgy%#@=Yf%9{kJ{27q2@o03iNyf_1A>g85lTx z+^y10M3tx=b$uo(kWRb<*IT#Q>)og`^ek$NUPPUVBdAQ@Kvnc7sQG?n`@e0Z{)*%R z1Ip+IrtlVOL5GXl`z1&b%p+D0iP>yJZZ&(*!Top}oLXOdpP=sh6tzGdvn$g^R7J+x{tQ&*=A#zw#1{M_YMv)iXK0V@ zm!6`b675G-;1$%KzHa-ysM7unHSs&xil>oe%q`T`H4udYN+U6vIj97dqXJ%IuV?M` z9Cqma{|1f8-0(VTfwS0-zekm}juWcL$KhCPMF*Fm-Wm_N)$B$EegKu>5!8G~QO_Sk z1@sHsKZi-Z|L18a!i%T}KS2d>9aX}+**Hxw$9f;?G_S;I7~pg~fZE$rs0v-eHXOmA z`b(@nHq-CH7S=a!)6na80Uy95X{wYfP^b1$RDc^$8EruY8rgmqD$o)#h1qBOhf$U3 zMOE?)>bXCow)zT|l<8k-=yjQJr!h}e88ZnPGDq219iDelrM-q)C`tO-`#Vr?#Z*+l z3s4K(hZN5|fLhqK#<-0BZq)tnjHmt&(>TL`D$v3^r^EAk)Pw6#8Rb!#e+#wnKHEQp zTJQ*J!lS5#kE0IdZ&3671*c$=MO2YCoR9ZTC{-?e&0cr`**0_BdK&BKUq%IT4Ykm4 zr?S9kRA94FZ_z?jMV4FFTDPL+`8q0rJ=l!Tm1uO*_&I8?YdF%{>jq>kGZvNMJ*X|{ zL`|>)RiO>YZkZx#!hP0b_WDPtE&iMBPvqN8UvRVd(BUmTMnf6zMooMWTk%cQ!XF^7 zxVeDJ{3hxQBqmi#-Gn+T?YIu_N0s~#>hvGC{f|+B{u5Q9QGCm>D<#uHLz&%=dd*g$ z_HYC0wc26(hpm06``<^E^b%?du3|ObM7`&qqPAq%H&71_dwZ=x#owCx{6&0EG& z4UK+#;a${1e?XP^GU^|lo2UtEI2a0W7CQJKYT^JDa2Kk?dr=u4K+Sg;_1s%H3*W;T zSTmLSE2FtnE2nocDua!vL-r&(*p1qn*HD4{4mDvNgIX|!bvVQJ7oZN=7f|=DL7k}> z>+w6N`JZW{{)+S!1{Bd-sJ*>}D&4mJ6CIWR z%FRJ#9N|6q66(1>A~~2bCDQpSjb*64JcRZ5s`VHufRi{3-$PaGLsSMIqt3z&+i&6^ zX)C6n_IMHM{)bTWu10=MmJhuxiSJG8dA$C^nNBhAy7FEo_Ogx_MPcNGnM^T?ylmQ8 zne*I&=fu%=$KB-mfs=D%FG`m`H)MZxG85*vm(SgPs`{aYuCqDvHZDp{ZYf5&)+%b6y4*W*S5;>7P z=M~JwMON*lCuJ*!h Pt71RrvoSs0?eqQvc--kZ delta 3021 zcmYM#3uu;A7{Kwfi}&lCmu)&XZ%u7;IyEhIDxns6NwmOpnX|lMV;CuU;RC@!jntN9 znHIGKMJ)N$1SM}PBBC-=>86t4B+I;C7xw?V1IhON&N<)vp7T7qpT0(=cy;iuRd4`3Vo0WuB8;K&1OpQ>7dN33?!y-N9cJNg=!DnNh3ha4Tkw$M8PO~>z`STNwxmBCJK%Wq z{aIMV`e6|bGyepA@o;?LM{G;~ESk}+IPPrrGcXggF&&GrDV88VVHh7)WGwP(coq$G zKJpV@;-d@ehvhVeU^P;$a0Z?5FSJyb(OqdqbWW6kRwxhoDwLpsjKn;AD*7^->3Zyo zThI!ejNku*34TK>o^ql*?2LUd6~`e}4o{*1m7xKY$MH%uz-2fJ-$4WW9o@=H==?1y zg8_C%=PN-gK0Jf^Te4{kSen`KfeJK`CD*Bx%Xi2NliFaTx+>NXmPNREv6%D90>Cy~YXaYrOz{Ak* zN5}8SV-fwC*aO$0^M8&MEfKz;VZ=u<2Tx%>-o`GNL)vCOln*1Ggl0Aa@1Gg;{R%XY zC9%I6&2$4A*e3M7Em(-#(eoF6rr`t^qPNh~+km^$7rUW*I2o--1wM!yV!tMO5epd4 zV&mP45$JWCi;v?>oqd`K1%}cfvCaxZru@=YIX2FqeIJ087vcOu>h- zGLCPH<42LAh4axA?y`qD2Mwe*J2(6R^pZDs^~^^qU~q~d$0h% z!4Y@`-OK)z!9zA2t;l11nBg3>^cCp)OVJ9gMT!_cPS9||1JT;}K&$-vz3qzrftrBD zxB$)cBXq)j=%M@`UGO$?al$<`<91vj_dFM^a4~u|#$XvH%4t~YgXpO~9{VY*YJ~04 z%9J3iB7x>y)Elw|EuUJ^!XcTCDXgrm%b&YdH>td@ZM&lThR**XmIS0 zLo=F&X0!mU#9}nyZD>X(kl4dz^!-f!zWQFzXeoLvC!>Kc!;aqnRWv*#ThK#xFnSV4 z(7%YjP~4+_;i2dj%txPJfo{e7=vnv-JzV?I3LZfNKOM(w(Eu-EAJz|P9B~gt30k62 zu|ENwxEvec!Z^MZU1$wj+KuRcDqFD+?m`1Ri}_fG&YR0YGvETW(xsR%vvD+>@EP>Q zSvUX_*bjGMLp+B@{x^E4QmJP?wnevMAR537be^^7LR--Q_Qw8U^h_P?N&S8B4+cC$ zchNvHd(|(HhX&XW4Wty^(@L~rucDd1i(H&gjSpcB@~0r&#X*?en=6VFaR{zJ6Fk(L z`aeSB1_Qp>k2Gk8Irtn_VI#bT&9E-oxUhcVwrC~t(7o;#`{U43Pe-?KAzHyz=;2+5 zX?Q55usign in to " "join the conversation." msgstr "" +"Veuillez vous connecter " +"pour participer à la conversation." #: frontend/templates/article_detail.html:239 msgid "Unable to render article content." @@ -863,15 +865,6 @@ msgstr "%(location)s : %(message)s" msgid "Registration successful. Please sign in." msgstr "Inscription réussie. Veuillez vous connecter." -#~ msgid "" -#~ "Please sign in to join the " -#~ "conversation." -#~ msgstr "" -#~ "Veuillez vous connecter pour " -#~ "participer à la conversation." - #~ msgid "" #~ "Are you sure you want to delete" #~ " your account ? This action is " @@ -900,12 +893,28 @@ msgstr "Inscription réussie. Veuillez vous connecter." #~ msgid "Upload failed." #~ msgstr "Échec du téléchargement." -#~ msgid "admin" -#~ msgstr "Administrateur" -#~ msgid "author" -#~ msgstr "Auteur" +#: blog_comment_application.py +msgid "You do not have permission to access this page." +msgstr "Vous n'avez pas la permission d'accéder à cette page." + +#: blog_comment_application.py +msgid "The page you are looking for does not exist." +msgstr "La page que vous cherchez n'existe pas." + +#: blog_comment_application.py +msgid "An unexpected error occurred. Please try again later." +msgstr "Une erreur inattendue s'est produite. Veuillez réessayer plus tard." -#~ msgid "user" -#~ msgstr "Utilisateur" +#: frontend/templates/user_list.html:63 +msgid "admin" +msgstr "Administrateur" + +#: frontend/templates/user_list.html:63 +msgid "author" +msgstr "Auteur" + +#: frontend/templates/user_list.html:63 +msgid "user" +msgstr "Utilisateur" diff --git a/translations/messages.pot b/translations/messages.pot index 3d29dc75..61701eb8 100644 --- a/translations/messages.pot +++ b/translations/messages.pot @@ -855,3 +855,27 @@ msgstr "" msgid "Registration successful. Please sign in." msgstr "" +#: blog_comment_application.py +msgid "You do not have permission to access this page." +msgstr "" + +#: blog_comment_application.py +msgid "The page you are looking for does not exist." +msgstr "" + +#: blog_comment_application.py +msgid "An unexpected error occurred. Please try again later." +msgstr "" + +#: frontend/templates/user_list.html:63 +msgid "admin" +msgstr "" + +#: frontend/templates/user_list.html:63 +msgid "author" +msgstr "" + +#: frontend/templates/user_list.html:63 +msgid "user" +msgstr "" + From 375bd8c9ea5081cbc2f8abc88669c466af6dd522 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Thu, 23 Jul 2026 02:41:55 +0200 Subject: [PATCH 11/16] =?UTF-8?q?Update=20logo,=20add=20translations=20and?= =?UTF-8?q?=20fix=20comments=20:=20Missing=20error=20styling=20added=20to?= =?UTF-8?q?=20the=20user=E2=80=91list=20jump=20input.=20`#jump-page-input.?= =?UTF-8?q?input-error`=20now=20gets=20the=20same=20red=20border=20and=20f?= =?UTF-8?q?ocus=20box=E2=80=91shadow=20as=20the=20stepper=20controls,=20so?= =?UTF-8?q?=20an=20empty=20or=20invalid=20=E2=80=9CGo=E2=80=9D=20action=20?= =?UTF-8?q?shows=20a=20consistent=20error=20state=20in=20the=20user?= =?UTF-8?q?=E2=80=91manager=20modal.=20Rules=20added=20directly=20in=20`us?= =?UTF-8?q?er-list.css`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/static/css/user-list.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/static/css/user-list.css b/frontend/static/css/user-list.css index c8555efa..31fcc3eb 100644 --- a/frontend/static/css/user-list.css +++ b/frontend/static/css/user-list.css @@ -442,6 +442,15 @@ input.search-input { border-bottom-color: var(--error); } +#jump-page-input.input-error { + border-color: var(--error); +} + +#jump-page-input.input-error:focus { + border-color: var(--error); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--error) 20%, transparent); +} + .dialog .stepper-btn { flex: 1; display: flex; From f605eafb53c78081fd26f9f0a85a1f202f6bc36c Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Thu, 23 Jul 2026 16:25:37 +0200 Subject: [PATCH 12/16] =?UTF-8?q?Update=20logo,=20add=20translations=20and?= =?UTF-8?q?=20fix=20comments=20:=20Reply/Edit=20now=20use=20the=20single?= =?UTF-8?q?=20main=20comment=20form=20with=20a=20simple=20toggle.=20All=20?= =?UTF-8?q?inline=20per=E2=80=91comment=20forms=20are=20removed;=20clickin?= =?UTF-8?q?g=20Reply=20or=20Edit=20activates=20the=20shared=20form=20and?= =?UTF-8?q?=20clicking=20again=20cancels=20it.=20Button=20text=20switches?= =?UTF-8?q?=20to=20=E2=80=9CCancel=E2=80=9D=20/=20=E2=80=9CAnnuler?= =?UTF-8?q?=E2=80=9D.=20Old=20CSS/JS=20tied=20to=20inline=20edit=20blocks?= =?UTF-8?q?=20is=20deleted,=20and=20`overflow-x:=20hidden`=20prevents=20th?= =?UTF-8?q?e=20SunEditor=20toolbar=20from=20causing=20horizontal=20scroll?= =?UTF-8?q?=20at=20360=E2=80=AFpx?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/static/css/article.css | 49 +---- frontend/static/scripts/comment-reply.js | 214 ++++++++++---------- frontend/static/scripts/suneditor-init.js | 14 +- frontend/templates/article_detail.html | 59 +++--- src/application/services/comment_service.py | 2 +- translations/fr/LC_MESSAGES/messages.mo | Bin 12184 -> 12278 bytes translations/fr/LC_MESSAGES/messages.po | 8 + 7 files changed, 143 insertions(+), 203 deletions(-) diff --git a/frontend/static/css/article.css b/frontend/static/css/article.css index 00af3cd8..e072039c 100644 --- a/frontend/static/css/article.css +++ b/frontend/static/css/article.css @@ -742,24 +742,6 @@ a.comment-author:hover { text-decoration: underline; } -.reply-form-container { - margin-top: var(--spacing-sm); -} - -.reply-form { - display: flex; - flex-direction: column; - gap: var(--spacing-sm); -} - -.reply-editor { - width: 100%; - box-sizing: border-box; - resize: vertical; - min-height: 4rem; - font-size: var(--font-body-base); -} - .comment-limit-hint, .desc-limit-hint { display: block; @@ -767,11 +749,6 @@ a.comment-author:hover { color: var(--text-secondary); } -.reply-actions { - display: flex; - gap: var(--spacing-xs); -} - .comments-section { margin-top: var(--spacing-xl); } @@ -793,31 +770,10 @@ a.comment-author:hover { font-style: italic; } -.edit-form-container { - margin-top: var(--spacing-sm); -} - -.edit-form { - display: flex; - flex-direction: column; - gap: var(--spacing-sm); -} - -.edit-editor { - width: 100%; - box-sizing: border-box; - resize: vertical; - min-height: 4rem; - font-size: var(--font-body-base); -} - -.edit-actions { +.comment-form-actions { display: flex; gap: var(--spacing-xs); -} - -.cancel-edit { - cursor: pointer; + align-items: center; } .comment-edit-toggle { @@ -877,6 +833,7 @@ a.comment-author:hover { .comment-join-section .sun-editor { margin-bottom: var(--spacing-md); + overflow-x: hidden; } .comment-join-title { diff --git a/frontend/static/scripts/comment-reply.js b/frontend/static/scripts/comment-reply.js index e2f07d7b..7fd80e58 100644 --- a/frontend/static/scripts/comment-reply.js +++ b/frontend/static/scripts/comment-reply.js @@ -1,41 +1,111 @@ 'use strict'; (() => { + const _t = key => { + const el = document.getElementById('app-translations'); + if (!el) return key; + try { return JSON.parse(el.textContent)[key] || key; } catch { return key; } + }; + + const activeMode = { type: null, commentId: null, button: null }; + + function restoreButtonText() { + if (!activeMode.button) return; + if (activeMode.type === 'reply') activeMode.button.textContent = _t('Reply'); + else if (activeMode.type === 'edit') activeMode.button.textContent = _t('[Edit]'); + } + + function resetForm() { + const form = document.getElementById('comment-form'); + form.action = form.dataset.createUrl; + + document.getElementById('comment-submit-btn').textContent = _t('Post Comment'); + document.getElementById('cancel-edit-btn').style.display = 'none'; + document.getElementById('comment-form-title').textContent = _t('Join the discussion'); + + restoreButtonText(); + activeMode.type = null; + activeMode.commentId = null; + activeMode.button = null; + + if (window.clearCommentEditor) { + window.clearCommentEditor(); + } + } + + function enterReplyMode(replyToggle) { + const commentId = replyToggle.dataset.commentId; + const form = document.getElementById('comment-form'); + const replyUrl = form.dataset.replyUrl.replace(/\/comments\/0\/reply$/, '/comments/' + commentId + '/reply'); + form.action = replyUrl; + + document.getElementById('comment-submit-btn').textContent = _t('Reply'); + document.getElementById('cancel-edit-btn').style.display = ''; + document.getElementById('comment-form-title').textContent = _t('Reply to') + ' ' + (replyToggle.dataset.authorUsername || _t('Anonymous')); + + restoreButtonText(); + replyToggle.textContent = _t('Cancel'); + activeMode.type = 'reply'; + activeMode.commentId = commentId; + activeMode.button = replyToggle; + + if (window.clearCommentEditor) { + window.clearCommentEditor(); + } + + document.getElementById('comment-form-title').scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + + function enterEditMode(editToggle) { + const commentId = editToggle.dataset.commentId; + const bodyEl = document.getElementById('comment-body-' + commentId); + if (!bodyEl) return; + + const contentHtml = bodyEl.innerHTML; + + const form = document.getElementById('comment-form'); + const editUrl = form.dataset.editUrl.replace(/\/comments\/0\/edit$/, '/comments/' + commentId + '/edit'); + form.action = editUrl; + + document.getElementById('comment-submit-btn').textContent = _t('Save'); + document.getElementById('cancel-edit-btn').style.display = ''; + document.getElementById('comment-form-title').textContent = _t('Editing comment'); + + restoreButtonText(); + editToggle.textContent = _t('Cancel'); + activeMode.type = 'edit'; + activeMode.commentId = commentId; + activeMode.button = editToggle; + + if (window.setCommentEditorContent) { + window.setCommentEditorContent(contentHtml); + } + + document.getElementById('comment-form-title').scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + document.addEventListener('DOMContentLoaded', () => { document.addEventListener('click', (e) => { const replyToggle = e.target.closest('.reply-toggle'); if (replyToggle) { e.preventDefault(); const commentId = replyToggle.dataset.commentId; - const container = document.getElementById('reply-form-' + commentId); - if (!container) return; - - document.querySelectorAll('.reply-form-container').forEach(other => { - if (other.style.display === 'block' && other.id !== container.id) { - other.style.display = 'none'; - const otherId = other.id.replace('reply-form-', ''); - const otherToggle = document.querySelector('.reply-toggle[data-comment-id="' + otherId + '"]'); - if (otherToggle) otherToggle.textContent = otherToggle.dataset.replyText; - } - }); - - document.querySelectorAll('.edit-form-container').forEach(other => { - if (other.style.display === 'block') { - other.style.display = 'none'; - const otherId = other.id.replace('edit-form-', ''); - const otherToggle = document.querySelector('.comment-edit-toggle[data-comment-id="' + otherId + '"]'); - if (otherToggle) otherToggle.textContent = otherToggle.dataset.editText; - const otherBody = document.getElementById('comment-body-' + otherId); - if (otherBody) otherBody.style.display = ''; - } - }); - - const isHidden = container.style.display === 'none'; - container.style.display = isHidden ? 'block' : 'none'; - replyToggle.textContent = isHidden ? replyToggle.dataset.cancelText : replyToggle.dataset.replyText; - - if (isHidden && window.initReplyEditor) { - window.initReplyEditor(commentId); + if (activeMode.type === 'reply' && activeMode.commentId === commentId) { + resetForm(); + } else { + enterReplyMode(replyToggle); + } + return; + } + + const editToggle = e.target.closest('.comment-edit-toggle'); + if (editToggle) { + e.preventDefault(); + const commentId = editToggle.dataset.commentId; + if (activeMode.type === 'edit' && activeMode.commentId === commentId) { + resetForm(); + } else { + enterEditMode(editToggle); } return; } @@ -62,89 +132,9 @@ return; } - const editToggle = e.target.closest('.comment-edit-toggle'); - if (editToggle) { - e.preventDefault(); - const commentId = editToggle.dataset.commentId; - const container = document.getElementById('edit-form-' + commentId); - if (!container) return; - - document.querySelectorAll('.edit-form-container').forEach(other => { - if (other.style.display === 'block' && other.id !== container.id) { - other.style.display = 'none'; - const otherId = other.id.replace('edit-form-', ''); - const otherToggle = document.querySelector('.comment-edit-toggle[data-comment-id="' + otherId + '"]'); - if (otherToggle) otherToggle.textContent = otherToggle.dataset.editText; - const otherBody = document.getElementById('comment-body-' + otherId); - if (otherBody) { - otherBody.style.display = ''; - const otherEdited = otherBody.nextElementSibling; - if (otherEdited && otherEdited.classList.contains('comment-edited-line')) { - otherEdited.style.display = ''; - } - } - } - }); - - document.querySelectorAll('.reply-form-container').forEach(other => { - if (other.style.display === 'block') { - other.style.display = 'none'; - const otherId = other.id.replace('reply-form-', ''); - const otherToggle = document.querySelector('.reply-toggle[data-comment-id="' + otherId + '"]'); - if (otherToggle) otherToggle.textContent = otherToggle.dataset.replyText; - } - }); - - const isHidden = container.style.display === 'none'; - container.style.display = isHidden ? 'block' : 'none'; - editToggle.textContent = isHidden ? editToggle.dataset.cancelText : editToggle.dataset.editText; - - const bodyText = document.getElementById('comment-body-' + commentId); - if (bodyText) { - bodyText.style.display = isHidden ? 'none' : ''; - const editedLine = bodyText.nextElementSibling; - if (editedLine && editedLine.classList.contains('comment-edited-line')) { - editedLine.style.display = isHidden ? 'none' : ''; - } - } - - if (isHidden && window.initEditEditor) { - window.initEditEditor(commentId); - } - return; - } - - const cancelBtn = e.target.closest('.cancel-reply'); - if (cancelBtn) { - const container = cancelBtn.closest('.reply-form-container'); - if (!container) return; - container.style.display = 'none'; - const commentId = container.id.replace('reply-form-', ''); - const toggle = document.querySelector('.reply-toggle[data-comment-id="' + commentId + '"]'); - if (toggle) { - toggle.textContent = toggle.dataset.replyText; - } - return; - } - - const cancelEdit = e.target.closest('.cancel-edit'); - if (cancelEdit) { - const container = cancelEdit.closest('.edit-form-container'); - if (!container) return; - container.style.display = 'none'; - const commentId = container.id.replace('edit-form-', ''); - const toggle = document.querySelector('.comment-edit-toggle[data-comment-id="' + commentId + '"]'); - if (toggle) { - toggle.textContent = toggle.dataset.editText; - } - const bodyText = document.getElementById('comment-body-' + commentId); - if (bodyText) { - bodyText.style.display = ''; - const editedLine = bodyText.nextElementSibling; - if (editedLine && editedLine.classList.contains('comment-edited-line')) { - editedLine.style.display = ''; - } - } + const cancelEditBtn = e.target.closest('#cancel-edit-btn'); + if (cancelEditBtn) { + resetForm(); return; } }); diff --git a/frontend/static/scripts/suneditor-init.js b/frontend/static/scripts/suneditor-init.js index a583df65..9a198b54 100644 --- a/frontend/static/scripts/suneditor-init.js +++ b/frontend/static/scripts/suneditor-init.js @@ -204,7 +204,6 @@ wysiwyg.addEventListener('click', () => { const now = Date.now(); if (lastTap && now - lastTap < 400) { - console.log('[debug] double-tap select-all fired'); lastTap = null; } else { lastTap = now; @@ -224,9 +223,6 @@ touchTap = now; } }); - wysiwyg.addEventListener('keydown', () => { - console.log('[debug] wysiwyg keydown'); - }); } editor.insertImage = function () { @@ -249,12 +245,14 @@ textarea.dataset.suneditor = 'true'; } - window.initReplyEditor = function (commentId) { - initCommentEditor('reply-editor-' + commentId, 'reply-content-' + commentId); + window.setCommentEditorContent = function (html) { + const editor = suneditors['comment-editor']; + if (editor) editor.setContents(html); }; - window.initEditEditor = function (commentId) { - initCommentEditor('edit-editor-' + commentId, 'edit-content-' + commentId); + window.clearCommentEditor = function () { + const editor = suneditors['comment-editor']; + if (editor) editor.setContents(''); }; document.addEventListener('DOMContentLoaded', () => { diff --git a/frontend/templates/article_detail.html b/frontend/templates/article_detail.html index 66e9ed42..1efe45b4 100644 --- a/frontend/templates/article_detail.html +++ b/frontend/templates/article_detail.html @@ -123,7 +123,7 @@

{{ article.article_title }}

{% endif %} {{ node.comment.comment_posted_at|format_datetime_locale }} {% if current_user and is_author and not is_deleted %} - + {% endif %} {% if current_user and (is_author or is_admin) and not is_deleted %} @@ -148,44 +148,17 @@

{{ article.article_title }}

{% if node.comment.edited_at and not node.comment.is_deleted %}
{{ _('edited at %(date)s', date=node.comment.edited_at|format_datetime_locale) }}
{% endif %} - {% if current_user and is_author and not is_deleted %} - - {% endif %} + {% if current_user and not is_deleted %}
{% if depth < 3 %} - + {% endif %} {% if node.replies %} {% endif %}
- {% if depth < 3 %} - - {% endif %} + {% elif node.replies %}
@@ -213,14 +186,21 @@

{{ _('Comments (%(count)s)', count=comment_count) }}< {% if current_user %}
-

{{ _('Join the discussion') }}

+

{{ _('Join the discussion') }}

{{ _('Comment must be between 1 and 5000 characters') }} -
+ - - + +
+ + +
{% else %} @@ -243,7 +223,14 @@

{{ _('Join the discussion') }}

'Plain Text': _('Plain Text'), 'Copy': _('Copy'), 'Insert Emoji': _('Insert Emoji'), - 'Something went wrong. Please reload the page.': _('Something went wrong. Please reload the page.') + 'Something went wrong. Please reload the page.': _('Something went wrong. Please reload the page.'), + 'Reply': _('Reply'), + 'Save': _('Save'), + 'Reply to': _('Reply to'), + 'Editing comment': _('Editing comment'), + 'Cancel': _('Cancel'), + 'Post Comment': _('Post Comment'), + 'Join the discussion': _('Join the discussion') } | tojson | safe }} {% endblock %} diff --git a/src/application/services/comment_service.py b/src/application/services/comment_service.py index c97e3427..d2ec5943 100644 --- a/src/application/services/comment_service.py +++ b/src/application/services/comment_service.py @@ -22,7 +22,7 @@ class CommentService(CommentManagementPort): """ ALLOWED_TAGS = frozenset({ - "b", "i", "u", "s", "a", "ul", "ol", "li", "br", "p", "em", "strong", + "b", "i", "u", "s", "strike", "del", "a", "ul", "ol", "li", "br", "p", "em", "strong", "blockquote", "pre", "code", "span", "sub", "sup", }) diff --git a/translations/fr/LC_MESSAGES/messages.mo b/translations/fr/LC_MESSAGES/messages.mo index 82605b452bba27088c8d221dd12a1fdffe6ae827..19258a839e64e3b35132d41d9d3ef03032c61748 100644 GIT binary patch delta 3193 zcmYM#eN5F=9LMofmy5vVL7vh|@`B(+d8`mp!ZC(xfX)Y2u@xf0%~;B1S6Ugmn-8rl z7irQ+vz$}Hu~vDPp_@h0Y6EJ`NOL1+DPdtbH0cj*_5N^Ai(P-O-|zhHIp_QRo^!69 zb(3|0^9f-chQH7FH;aEs;d=W2FFM+oJ9&&j4|*^Tm*aG-!vt)>c-)55umd&JeoV$g zsPCOXjrTP!FeYHexQV18CB~R4^k52ZKy}!Ke9R#(Gw~>D;6YRXr_q5IZTpz@4^*Hx ztj<_t_?ToaGcX;eGrxI|n;aUJp(@aZ>exB;1!KA~iRa^}%)YVhXYBJBCenTt!_YzJ zD2zawOd_f>X-F)l0F^+Q-ZQ_c;3gGouo$057al_mcnVdruaJ-ViHj2_Py_yjs+5y* zXu_GOfIK)Gi>;3#AJfJqA9rFv9Zqtik_@3bTtZD0M%a3vg!kYaydBGsRWnOa0j@#? z@~Cb1q5}5g65NRj@HA>^M^WQD5~#l-P2{Zx^q`iY09C@3s8T&{-#4HFX~8?N-P&c} zpG57Y0n}0rqxQxKDv=AQ%3ejycQb+dt096Y6i5;mWt53&n1h;d1!@oYkm8u_)(#{Y z(}j#_deM!87=c$%yZ;92doBjiCQd8ATDNqP~}l znqVO+(=t>=D(!PUs&Y-JiCZuex1z@BLG7VHuWk4gHQ*qsRKuuBTtuzyW&1pdD)CL! z1Wsn1htWuqrVzD+m8ihhA~_fzDv|A|dG>_f2h4u^g~OP`ixZfEmr)bMvNN+W8CBvk zR0a>@UHAmLaWm=^y@PZ$eW<|CqB0#tjW>?!e+}bw{{OKJaV(57o`s4q71eP*Du8@c zDQi&!_^g{yyLt!a;d_{mXHjb$!9^8H!z^5CpP!S=Zw_+f!6DQd{fRn;DWq3{C8$!q zgxaj_r~vn&GCG6`w8uVwgbK758OjXU=S!%{Ork0ob2sa+j&5$0`CL?{S*T-EgRkAj z@k9EUaTZnqhLf&Jn}?dH1hw|dQKzE;6>uwR{7p!a%~sUJd#&%MQ2%lo`e;xGCugl1 zV^9^SM{S-hsE%)!?lXVo;5jiF2`Jb|6%eMj95< z&}LtJj4IU#vW(`sHQF88{TZl0@=z01pe9&}3alA*ik`u#-wSJpwF`BMJ_&H63_eE> zevhU2H)^dHv!%7xl}HSx8kNxoRHm<@mSPuboOe-`3L>j!zC?}tvo(reHNC$NwbX$n zw!x2l%!^#Ki@Q;o|9~2J0=0{sb3zm6AwL0T0jdI3sJ&2)3ebz%Gn=s%J5i;dM4bXx z`qXp46mg?Sm!eAb6tY}qBPz2_)N$)V4IIRL9JJ53tVtQ6?`NY*y9l)u<>|DF zjNsHUl+15pSfEl&M|JSx)96P9a2~Z3v5cyY9@LU7M(vSR7>=t^6M1nOZn5n6bP=W47H}+s84xs`+ zgDU;+sCjP9qy8E&a(<{|G8XWhi}zv!Dzgrx29;ZQ@>omIXH`D}^s7NESLjfhD*7_k-MQSk$TTsWd4U2Fe z7UFlvZ<~qA3AJb9{XDP1V%!tpMj3yLi_l5<>R5^-WuC<~co4Ow4mN}WbXjMh0+@rb zn2V}d2`Yhd)Sh_6KKoHiu^qLo&SueZjrS2V66j`5K#^Z1Vcu>%9McslLhY4K7S=o9602(&B4w P^n2a?2ZBd4e+mB=MT0e% delta 3103 zcmYM$3uu;A7{KwPe%qSU>E^uVa+{Yq=e(rOTehW!*+?ucB^Ap{5SEpwh*lqw;K-)f zMNCb^62VOkXNi{)Wi}{H$qSWXnadO$p_YoF_5ZsAjqUrL^SP^h*>xr({K?w)oa)um!sp> zqx0>+At59~J&klO+{Ow_YaK!^PC_4e1^Erj`RIi2pc7Z40qn*Wcqm@4kA8;+dMf%m zX43x~J7R)PThC0;~+ z!!?T~{eP=J}NABt$?<1ieFPb1Y1Ytad}pe5UeDfk6CQ9W9rQ|Lk$(Lios zH_V_sT8vhzf{y_>9UWhVNlUSjh6DE?&xVt6Km*=K{~9*OY|>ztLVGl@0yL1qczp;O z=xBTxr=tOGLAUf%bp8`)py#uwzZ2fz!rjBiXiJxmmZ&d!e-Ii-Id;IA(Z%t86?%r& zqg(VbdM0YnOg}*@`Z+q^x3T|2HuX1>1}>PJOvi&Y*|45v@=vlMcnaBn>m3ic@hm_P~Q^sm`GhUqQ#+K^I78b~DXJ zE7B$Q2cnf5jxJn|z3~Zjo@MA6s*L^QDjJq(16qOY=$`J1{W`R?U!oHq!6H0?Bol6< zTbD%?2GkFUF_fYSRG z6R|(e#{swr-P>bmg)U(+w&SAxvgq?zNdH~z&HCX04PUO7^HY%Ji73+(S`8PFXQ8jv(%7$#9zw^TK}&iG-GXb_0&k)3`5kmiGJ7@!l#BN7M-zGo z(^x-Dp#bgSN`gr3gy3ns^i7%u7=-fglOyOV{;9$(d$IyxAqX92LOS~G*a1%OTHTv9M z9E`_tAg1)8{$@0^Pt)mr5Y6B@^pGvXJgh>uW+xiRPw0f{Ty((#Oviz-KN3A;kD}wI zp=W9#X5icC{OgLTzmaa|f)VXS_x2K6x__}HX7_D+9dmIg{ZUwoi;(ZGP!q3zgM;Wd zFKPO(Tq&CI0vv(c(C2Hn7&GxWTCrcy3@)H&;b!dT zaFE=JUg#c=LdQRj&N~(P4RiSLZE5yiZF;L8Thv}{b0($sbo&o8YFFkjO{>i=YDoPL Dt8gU% diff --git a/translations/fr/LC_MESSAGES/messages.po b/translations/fr/LC_MESSAGES/messages.po index ced8e811..32f75faf 100644 --- a/translations/fr/LC_MESSAGES/messages.po +++ b/translations/fr/LC_MESSAGES/messages.po @@ -292,6 +292,14 @@ msgstr "Participer à la discussion" msgid "Post Comment" msgstr "Publier le commentaire" +#: frontend/static/scripts/comment-reply.js +msgid "Reply to" +msgstr "Répondre à" + +#: frontend/static/scripts/comment-reply.js +msgid "Editing comment" +msgstr "Modifier le commentaire" + #: frontend/templates/article_detail.html:229 #, python-format msgid "" From 095dd2bda519d47a25334c17d8a86a0f5fc948d5 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Thu, 23 Jul 2026 20:17:58 +0200 Subject: [PATCH 13/16] Update logo, add translations and fix comments : Language toggle added, locale now stored in session and updated via POST `/lang/`, navbar gets a FR/EN switch styled like the theme toggle, session adapter gains `set_lang`, tests cover fr/en/invalid; translation templates updated --- blog_comment_application.py | 12 ++++++-- flask_setup/routes.py | 7 +++++ frontend/static/css/base.css | 22 ++++++++++++-- frontend/templates/base.html | 7 +++++ .../flask/flask_account_session_adapter.py | 20 ++++++++++++- .../tests_flask/flask_test_utils.py | 2 ++ .../test_lang_integration.py | 30 +++++++++++++++++++ 7 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 tests/tests_integration/test_lang_integration.py diff --git a/blog_comment_application.py b/blog_comment_application.py index 982c9bb3..14a94079 100644 --- a/blog_comment_application.py +++ b/blog_comment_application.py @@ -1,8 +1,9 @@ import os from datetime import timedelta -from flask import Flask, render_template -from flask_babel import Babel, gettext as _ +from flask import Flask, render_template, session +from flask_babel import Babel +from flask_babel import gettext as _ from flask_compress import Compress from sqlalchemy.orm import Session @@ -197,7 +198,12 @@ def create_app(db_session=None) -> Flask: services = _create_services(repositories) app = _init_web_facade_flask() Compress(app) - Babel(app, locale_selector=lambda: "fr") + Babel(app, locale_selector=lambda: session.get("lang", "fr")) + + @app.context_processor + def inject_get_locale(): + return {"get_locale": lambda: session.get("lang", "fr")} + init_web_security(app) _init_template_utils(app) web_adapters = _init_web_adapters(services) diff --git a/flask_setup/routes.py b/flask_setup/routes.py index f74819c5..5bfc5591 100644 --- a/flask_setup/routes.py +++ b/flask_setup/routes.py @@ -161,6 +161,13 @@ def _register_auth_routes(app: Flask, adapters: dict) -> None: endpoint="auth.unban_account", ) + app.add_url_rule( + "/lang/", + view_func=acc.set_lang, + methods=["POST"], + endpoint="auth.set_lang", + ) + def _register_file_routes(app: Flask, adapters: dict) -> None: fad = adapters["file_adapter"] diff --git a/frontend/static/css/base.css b/frontend/static/css/base.css index 89ac95f4..342c3819 100644 --- a/frontend/static/css/base.css +++ b/frontend/static/css/base.css @@ -175,7 +175,8 @@ } #theme-toggle-btn, -.nav-links .btn { +.nav-links .btn, +.lang-toggle-btn { border-radius: 0; margin-left: -1px; position: relative; @@ -201,6 +202,10 @@ border-bottom-left-radius: var(--radius-default); } +.lang-toggle-form { + display: flex; +} + .nav-links .btn:last-child { border-top-right-radius: var(--radius-default); border-bottom-right-radius: var(--radius-default); @@ -289,7 +294,8 @@ } .nav-actions>#theme-toggle-btn, - .nav-links .btn { + .nav-links .btn, + .lang-toggle-btn { width: 2.375rem; height: 2.375rem; min-width: 2.375rem; @@ -483,6 +489,18 @@ button, text-align: center; } +.lang-toggle-btn { + display: flex; + align-items: center; + justify-content: center; + height: auto; + padding: var(--spacing-xs) var(--spacing-sm); + line-height: 1; + font-weight: 600; + font-size: 0.75rem; + border-radius: 0; +} + button:hover, .btn:hover { border-color: var(--primary); diff --git a/frontend/templates/base.html b/frontend/templates/base.html index 0f4d9304..51ac2a5d 100644 --- a/frontend/templates/base.html +++ b/frontend/templates/base.html @@ -44,6 +44,13 @@ {{ icon('dark_mode', 'nav-item-icon') }} +
+ + +
+