Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
850ce31
Update logo, add translations and fix comments : Replace the favicon …
raynaldlao Jul 22, 2026
c45ebee
Update logo, add translations and fix comments : Full French i18n add…
raynaldlao Jul 22, 2026
e9a5dba
Update logo, add translations and fix comments : French i18n updated:…
raynaldlao Jul 22, 2026
fc4efa0
Update logo, add translations and fix comments : French i18n updated …
raynaldlao Jul 22, 2026
5cc4ae8
Update logo, add translations and fix comments : French i18n added fo…
raynaldlao Jul 22, 2026
172bf8e
Update logo, add translations and fix comments : Refactor of `sunedit…
raynaldlao Jul 22, 2026
152d023
Update logo, add translations and fix comments : French i18n added fo…
raynaldlao Jul 22, 2026
25163af
Update logo, add translations and fix comments : Code‑block copy and …
raynaldlao Jul 22, 2026
4103603
Update logo, add translations and fix comments : Anonymous comment av…
raynaldlao Jul 23, 2026
ddca2f7
Update logo, add translations and fix comments : French i18n complete…
raynaldlao Jul 23, 2026
375bd8c
Update logo, add translations and fix comments : Missing error stylin…
raynaldlao Jul 23, 2026
f605eaf
Update logo, add translations and fix comments : Reply/Edit now use t…
raynaldlao Jul 23, 2026
095dd2b
Update logo, add translations and fix comments : Language toggle adde…
raynaldlao Jul 23, 2026
a23d66d
Update logo, add translations and fix comments : Translation cleanup …
raynaldlao Jul 23, 2026
4944b45
Update logo, add translations and fix comments : Comment hard‑delete …
raynaldlao Jul 23, 2026
4730c73
Update logo, add translations and fix comments : Dev ergonomics impro…
raynaldlao Jul 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions babel.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[python: **.py]
[jinja2: **/templates/**.html]
26 changes: 19 additions & 7 deletions blog_comment_application.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import glob
import os
from datetime import timedelta

from flask import Flask, render_template
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

Expand Down Expand Up @@ -29,8 +32,8 @@
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,
inject_vite_assets,
nl2br_filter,
Expand Down Expand Up @@ -168,9 +171,9 @@ 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
app.context_processor(inject_current_year)
app.context_processor(inject_vite_assets)

Expand All @@ -196,17 +199,26 @@ def create_app(db_session=None) -> Flask:
services = _create_services(repositories)
app = _init_web_facade_flask()
Compress(app)
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)
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


if __name__ == "__main__":
application = create_app()
application.run(debug=os.getenv("FLASK_DEBUG", "false").lower() == "true")
application.run(
debug=os.getenv("FLASK_DEBUG", "false").lower() == "true",
extra_files=glob.glob("translations/**/*.mo", recursive=True),
)
7 changes: 7 additions & 0 deletions flask_setup/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,13 @@ def _register_auth_routes(app: Flask, adapters: dict) -> None:
endpoint="auth.unban_account",
)

app.add_url_rule(
"/lang/<locale>",
view_func=acc.set_lang,
methods=["POST"],
endpoint="auth.set_lang",
)


def _register_file_routes(app: Flask, adapters: dict) -> None:
fad = adapters["file_adapter"]
Expand Down
33 changes: 16 additions & 17 deletions frontend/core/__tests__/code-copy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ function createDOM(lang) {
}

describe('code-copy.js', () => {
let mockWrite;
let mockWriteText;

beforeAll(() => {
Expand All @@ -42,13 +41,17 @@ describe('code-copy.js', () => {

beforeEach(() => {
document.body.innerHTML = '';
document.execCommand = vi.fn().mockImplementation(() => {
const dt = new DataTransfer();
const ev = new ClipboardEvent('copy', { clipboardData: dt });
document.dispatchEvent(ev);
return true;
});
vi.useFakeTimers();
mockWrite = vi.fn().mockResolvedValue(undefined);
mockWriteText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('navigator', {
clipboard: { write: mockWrite, writeText: mockWriteText },
clipboard: { writeText: mockWriteText },
});
vi.stubGlobal('ClipboardItem', undefined);
});

afterEach(() => {
Expand Down Expand Up @@ -84,24 +87,20 @@ describe('code-copy.js', () => {
expect(document.querySelector('.toast')).toBeNull();
});

it('writes text/plain via writeText as fallback when ClipboardItem absent', () => {
it('writes text/plain via custom copy event handler', () => {
const { btn } = createDOM();
btn.click();

expect(mockWriteText).toHaveBeenCalledWith('print("hello")');
expect(mockWrite).not.toHaveBeenCalled();
expect(document.execCommand).toHaveBeenCalledWith('copy');
expect(document.querySelector('.toast')).not.toBeNull();
});

it('writes text/html + text/plain via ClipboardItem when available', () => {
const mockClipboardItem = vi.fn();
vi.stubGlobal('ClipboardItem', mockClipboardItem);

it('writes text/html + text/plain via custom copy event', () => {
const { btn } = createDOM('python');
btn.click();

expect(mockClipboardItem).toHaveBeenCalledOnce();
expect(mockWrite).toHaveBeenCalledOnce();
expect(mockWriteText).not.toHaveBeenCalled();
expect(document.execCommand).toHaveBeenCalledWith('copy');
expect(document.querySelector('.toast')).not.toBeNull();
});

it('replaces old toast on rapid clicks', () => {
Expand All @@ -113,12 +112,12 @@ describe('code-copy.js', () => {
expect(toasts.length).toBe(1);
});

it('does nothing when code block is empty', () => {
it('shows toast even for empty code block (copied zero-width space)', () => {
const { btn, code } = createDOM('python');
code.textContent = '';
btn.click();

expect(document.querySelector('.toast')).toBeNull();
expect(mockWriteText).not.toHaveBeenCalled();
expect(document.querySelector('.toast')).not.toBeNull();
expect(document.execCommand).toHaveBeenCalledWith('copy');
});
});
50 changes: 50 additions & 0 deletions frontend/core/__tests__/i18n.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
41 changes: 22 additions & 19 deletions frontend/core/components/ArticleForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down Expand Up @@ -80,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;
Expand All @@ -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'],
Expand Down Expand Up @@ -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');
}
});
}
Expand Down Expand Up @@ -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);
Expand All @@ -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 <div className="loading">Loading...</div>;
return <div className="loading">{_('Loading...')}</div>;
}

const displayError = error || loadError;
Expand All @@ -413,7 +416,7 @@ export default function ArticleForm() {
try {
initialContent = contentStr ? JSON.parse(contentStr) : undefined;
} catch {
return <div className="alert alert-error">Unable to parse article content.</div>;
return <div className="alert alert-error">{_('Unable to parse article content.')}</div>;
}

return (
Expand All @@ -422,22 +425,22 @@ export default function ArticleForm() {
<input
type="text"
className="article-editor-title"
placeholder="Article title"
placeholder={_('Article title')}
value={title}
onChange={(e) => setTitle(e.target.value)}
onClick={handleDoubleTapSelect}
/>
<div className="article-editor-description-wrap">
<div className="article-editor-section-header">
<span className="article-editor-label">Description</span>
<span className="article-editor-label">{_('Description')}</span>
</div>
<div className="article-editor-section-header">
<span className="desc-limit-hint">Maximum 300 characters</span>
<span className="desc-limit-hint">{_('Maximum 300 characters')}</span>
<span className="char-counter">{description.length}/300</span>
</div>
<textarea
className="article-editor-description"
placeholder="Short description (optional)"
placeholder={_('Short description (optional)')}
maxLength={300}
value={description}
onChange={(e) => setDescription(e.target.value)}
Expand All @@ -446,13 +449,13 @@ export default function ArticleForm() {
</div>
<div className="article-editor-body-wrap">
<div className="article-editor-section-header">
<span className="article-editor-section-title">Content</span>
<span className="article-editor-section-title">{_('Content')}</span>
</div>
<BlockNoteEditor initialContent={initialContent} onReady={(ed) => { editorRef.current = ed; }} />
</div>
<div className="article-editor-actions">
<button className="btn" onClick={handleSubmit} disabled={saving}>
{saving ? 'Saving...' : page === 'create' ? 'Publish' : 'Save'}
{saving ? _('Saving...') : page === 'create' ? _('Publish') : _('Save')}
</button>
</div>
</div>
Expand Down
7 changes: 5 additions & 2 deletions frontend/core/components/ArticleViewer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import createHighlighter from '../utils/shiki-highlighter-viewer';
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';


function BlockNoteViewer({ initialContent }) {
Expand Down Expand Up @@ -36,6 +38,7 @@ function BlockNoteViewer({ initialContent }) {
const { audio: _a, file: _f, video: defaultVideoSpec, ...keptSpecs } = defaultBlockSpecs;

const editor = useCreateBlockNote({
dictionary: fr,
initialContent,
schema: BlockNoteSchema.create({
blockSpecs: {
Expand Down Expand Up @@ -127,7 +130,7 @@ export default function ArticleViewer() {
const { loaded, contentStr, error } = useArticle(articleId);

if (!loaded) {
return <div className="loading">Loading...</div>;
return <div className="loading">{_('Loading...')}</div>;
}

if (error) {
Expand All @@ -138,7 +141,7 @@ export default function ArticleViewer() {
try {
initialContent = JSON.parse(contentStr);
} catch {
return <div className="alert alert-error">Unable to render article content.</div>;
return <div className="alert alert-error">{_('Unable to render article content.')}</div>;
}

return <BlockNoteViewer initialContent={initialContent} />;
Expand Down
Loading
Loading