Compare commits

..

28 Commits

Author SHA1 Message Date
mvarnier 91a38b866b (feature) accessibility on Icon component
Add aria-hidden attribute to Icon component to
improve accessibility.
Add aria-label to DropdownMenu component when
selected.
2025-06-11 15:23:09 +02:00
Anthony LC 0fd16b4371 💄(frontend) add spacing bottom on editor
We add spacing bottom on editor to
avoid the last editing line being to close to
the bottom.
2025-06-11 13:08:33 +02:00
Anthony LC fbb2799050 🔧(git) set LF line endings for all text files
Windows users are by default using CRLF line endings,
which can cause issues with some tools and
environments. This commit sets the `.gitattributes`
file to enforce LF line endings for all text
files in the repository.
2025-06-11 13:08:33 +02:00
Manuel Raynaud afbb4b29dc 🩹(backend) default CORS_ALLOW_ALL_ORIGINS to False
The settings CORS_ALLOW_ALL_ORIGINS was set to True by default.

This error is inherited from a old mistake made back in the days while
working on the initial impress demo.

This is not something we want, this should be only allowed in
development. We change the value in all the manifests in order to have
the desired behavior in non development environments.
2025-06-11 09:55:28 +00:00
Anthony LC db63ebd0c8 🐛(frontend) fix meta title
The meta title was flickering, it was adding the
doc title, then it was coming back to the default
title.
This was due to the way the next Head component
render data.
We now use a more stable way to set the title.
2025-06-11 10:21:53 +02:00
Anthony LC c5f018e03e 💄(frontend) adapt some style
- editor block padding only when background
- increase icon shadow grid
2025-06-11 10:03:32 +02:00
Anthony LC 1c93fbc007 🐛(frontend) fix multiple EmojiPicker
emoji-mart is used to display emojis in the editor.
It is used by the callout block and by
Blocknotes editor. The problem is that the emoji-mart
is a singleton, so if Blocknotes components init
the emoji-mart first, the picker in the callout block
will not display correctly.
This commit fixes the issue by initializing
the emoji-mart in the callout block first.
2025-06-11 10:03:32 +02:00
Anthony LC d811e3c2fc 🐛(frontend) table of content disappearing
The table of content was disappearing when the user
was looking the version history then came back to
the main document.
This commit fixes this issue.
2025-06-11 09:45:42 +02:00
Anthony LC fe5fda5d73 ✏️(project) fix typo
Fix and improve typos in the codebase.
2025-06-11 09:10:22 +02:00
Simon Ser bf66265125 🙈(docker) add .next to .dockerignore
We don't want to copy this over to the Docker daemon, since this
directory can be quite large.
2025-06-11 08:29:28 +02:00
renovate[bot] ce329142dc ⬆️(dependencies) update python dependencies 2025-06-10 07:58:32 +00:00
renovate[bot] f8cff43dac ⬆️(dependencies) update requests to v2.32.4 [SECURITY] 2025-06-10 07:35:04 +00:00
renovate[bot] f5b2c27bd8 ⬆️(dependencies) update django to v5.1.10 [SECURITY] 2025-06-06 17:21:22 +00:00
Anthony LC 62433ef7f1 ♻️(i18n) adapt script to major upgrade of yargs
"yargs" dependency has been updated to version 18.0.0,
which causes breaking changes in the script.
2025-06-05 10:58:59 +02:00
Anthony LC bc0824d110 🚨(frontend) fix linter warning react-query
React-query change the types of some methods, which causes
linter warnings. This commit updates the affected methods
to match the new types.
2025-06-05 10:46:53 +02:00
Anthony LC fa653c6776 🏷️(CI) add automated label to renovate
Renovate provides automated pull requests, so let's
use a label to identify them easily.
2025-06-05 10:46:53 +02:00
Anthony LC d12f942d29 ⬆️(project) bump project to node 22
"yargs" dependency requires node 22, so we
update the project to use it.
Node 22 is the latest LTS version, so this is a
good time to do it.
2025-06-05 10:46:53 +02:00
renovate[bot] 62f85e7d24 ⬆️(dependencies) update js dependencies 2025-06-05 10:46:53 +02:00
Manuel Raynaud 65cc088a17 ⬆️(compose) upgrade node image to version 22
We node service in doecker compose can be a helper to use node locally
without installing it. Docs requires at least node 22 so we upgrade it
to node 22.
2025-06-05 10:28:47 +02:00
rvveber 94e99784f3 (tests) Add & adapt language tests
- Language will only be changed if different from current language
- Added test for custom translations

Signed-off-by: Robin Weber <weber@b1-systems.de>
2025-06-03 17:35:52 +02:00
rvveber fa83955a77 ♻️(frontend) Refactor language-related code
- Refactors "useTranslationsCustomizer" to "useCustomTranslations"
- Refactors "useLanguageSynchronizer" to "useSynchronizedLanguage"
- Refactors "LanguagePicker" to better reflect its component role
- Refactors "LanguagePicker" to use "useSynchronizedLangue"
- Removes unused "useChangeUserLanguage"
- To change the user language, use "useAuthMutation" instead

Signed-off-by: Robin Weber <weber@b1-systems.de>
2025-06-03 17:35:52 +02:00
rvveber 5962f7aae1 ♻️(frontend) Separate mutations from queries for auth logic
Introduces dedicated mutations
(for authentication/user operations)
separating them from queries to align with best practices
for data fetching and state management.

Queries remain responsible for READ operations, while mutations
now handle CREATE, UPDATE, and DELETE actions (for user data)
improving separation of concerns.

Signed-off-by: Robin Weber <weber@b1-systems.de>
2025-06-03 17:35:52 +02:00
rvveber dc06315566 📝(documentation) adds customization for translations
Part of customization PoC

Signed-off-by: Robin Weber <weber@b1-systems.de>
2025-06-03 17:35:52 +02:00
rvveber f4ad26a8fa (frontend) Adds customization for translations
Part of customization PoC

Signed-off-by: Robin Weber <weber@b1-systems.de>
2025-06-03 17:35:52 +02:00
renovate[bot] d952815932 ⬆️(dependencies) update python dependencies 2025-06-02 05:09:03 +00:00
renovate[bot] cde64ed80a ⬆️(dependencies) update js dependencies 2025-05-26 06:39:40 +00:00
renovate[bot] cfd88d0469 ⬆️(dependencies) update python dependencies 2025-05-26 01:55:36 +00:00
virgile-dev 5e45fec296 📝(doc) fix path to env doc on readme (#1007)
The path lead to a 404

Signed-off-by: virgile-dev <virgile.deville@beta.gouv.fr>
2025-05-25 17:01:29 +00:00
78 changed files with 2348 additions and 884 deletions
+1
View File
@@ -34,3 +34,4 @@ db.sqlite3
# Frontend
node_modules
.next
+23
View File
@@ -0,0 +1,23 @@
# Set the default behavior for all files
* text=auto eol=lf
# Binary files (should not be modified)
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.mov binary
*.mp4 binary
*.mp3 binary
*.flv binary
*.fla binary
*.swf binary
*.gz binary
*.zip binary
*.7z binary
*.ttf binary
*.woff binary
*.woff2 binary
*.eot binary
*.pdf binary
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
install-dependencies:
uses: ./.github/workflows/dependencies.yml
with:
node_version: '20.x'
node_version: '22.x'
with-front-dependencies-installation: true
synchronize-with-crowdin:
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
install-dependencies:
uses: ./.github/workflows/dependencies.yml
with:
node_version: '20.x'
node_version: '22.x'
with-front-dependencies-installation: true
with-build_mails: true
+1 -1
View File
@@ -5,7 +5,7 @@ on:
inputs:
node_version:
required: false
default: '20.x'
default: '22.x'
type: string
with-front-dependencies-installation:
type: boolean
+5 -5
View File
@@ -13,7 +13,7 @@ jobs:
install-dependencies:
uses: ./.github/workflows/dependencies.yml
with:
node_version: '20.x'
node_version: '22.x'
with-front-dependencies-installation: true
test-front:
@@ -26,7 +26,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
node-version: "22.x"
- name: Restore the frontend cache
uses: actions/cache@v4
@@ -48,7 +48,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
node-version: "22.x"
- name: Restore the frontend cache
uses: actions/cache@v4
with:
@@ -70,7 +70,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
node-version: "22.x"
- name: Restore the frontend cache
uses: actions/cache@v4
@@ -109,7 +109,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
node-version: "22.x"
- name: Restore the frontend cache
uses: actions/cache@v4
+16
View File
@@ -8,6 +8,22 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(frontend) add customization for translations #857
### Changed
- 🧑‍💻(docker): add .next to .dockerignore #1055
### Fixed
-🐛(frontend) table of content disappearing #982
-🐛(frontend) fix multiple EmojiPicker #1012
-🐛(frontend) fix meta title #1017
-🔧(git) set LF line endings for all text files #1032
## [3.3.0] - 2025-05-06
### Added
-2
View File
@@ -1,2 +0,0 @@
web: bin/buildpack_start.sh
postdeploy: python manage.py migrate
+3 -3
View File
@@ -57,7 +57,7 @@ Available methods: Helm chart, Nix package
In the works: Docker Compose, YunoHost
⚠️ For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under AGPL-3.0 and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/docs/env.md) for more information.
⚠️ For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under AGPL-3.0 and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/env.md) for more information.
## Getting started 🔧
@@ -97,7 +97,7 @@ This command builds the `app` container, installs dependencies, performs databas
Your Docker services should now be up and running 🎉
You can access to the project by going to <http://localhost:3000>.
You can access the project by going to <http://localhost:3000>.
You will be prompted to log in. The default credentials are:
@@ -106,7 +106,7 @@ username: impress
password: impress
```
📝 Note that if you need to run them afterwards, you can use the eponym Make rule:
📝 Note that if you need to run them afterwards, you can use the eponymous Make rule:
```shellscript
$ make run
+1 -1
View File
@@ -18,7 +18,7 @@ the following command inside your docker container:
## [3.3.0] - 2025-05-22
⚠️ For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under AGPL-3.0 and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/docs/env.md) for more information.
⚠️ For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under AGPL-3.0 and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/env.md) for more information.
The footer is now configurable from a customization file. To override the default one, you can
use the `THEME_CUSTOMIZATION_FILE_PATH` environment variable to point to your customization file.
-15
View File
@@ -1,15 +0,0 @@
#!/bin/bash
set -o errexit # always exit on error
set -o pipefail # don't ignore exit codes when piping output
echo "-----> Running post-compile script"
rm -rf docker docs env.d gitlint src/frontend/apps/e2e
rm -rf src/frontend/apps
rm -rf src/frontend/packages
# Remove some of the larger packages required by the frontend only
rm -rf src/frontend/node_modules/@next src/frontend/node_modules/next src/frontend/node_modules/react-icons src/frontend/node_modules/@gouvfr-lasuite
# du -ch | sort -rh | head -n 100
-15
View File
@@ -1,15 +0,0 @@
#!/bin/bash
set -o errexit # always exit on error
set -o pipefail # don't ignore exit codes when piping output
echo "-----> Running post-frontend script"
# Move the frontend build to the nginx root and clean up
mkdir -p build/
mv src/frontend/apps/impress/out build/frontend-out
mv src/backend/* ./
mv src/nginx/* ./
echo "3.13" > .python-version
-18
View File
@@ -1,18 +0,0 @@
#!/bin/bash
# Start the Django backend server
gunicorn -b :8000 impress.wsgi:application --log-file - &
# Start the Y provider service
cd src/frontend/servers/y-provider && PORT=4444 ../../.scalingo/node/bin/node dist/start-server.js &
# Start the Nginx server
bin/run &
# if the current shell is killed, also terminate all its children
trap "pkill SIGTERM -P $$" SIGTERM
# wait for a single child to finish,
wait -n
# then kill all the other tasks
pkill -P $$
+1 -1
View File
@@ -171,7 +171,7 @@ services:
working_dir: /app
node:
image: node:18
image: node:22
user: "${DOCKER_USER:-1000}"
environment:
HOME: /tmp
+1 -1
View File
@@ -40,7 +40,7 @@ These are the environment variables you can set for the `impress-backend` contai
| DJANGO_EMAIL_USE_TLS | use tls for email host connection | false |
| DJANGO_EMAIL_USE_SSL | use sstl for email host connection | false |
| DJANGO_EMAIL_FROM | email address used as sender | from@example.com |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | allow all CORS origins | true |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | allow all CORS origins | false |
| DJANGO_CORS_ALLOWED_ORIGINS | list of origins allowed for CORS | [] |
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | list of origins allowed for CORS using regulair expressions | [] |
| SENTRY_DSN | sentry host | |
+14
View File
@@ -53,4 +53,18 @@ Below is a visual example of a configured footer ⬇️:
![Footer Configuration Example](./assets/footer-configurable.png)
----
# **Custom Translations** 📝
The translations can be partially overridden from the theme customization file.
### Settings 🔧
```shellscript
THEME_CUSTOMIZATION_FILE_PATH=<path>
```
### Example of JSON
The json must follow some rules: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
+1 -1
View File
@@ -1,7 +1,7 @@
{
"extends": ["github>numerique-gouv/renovate-configuration"],
"dependencyDashboard": true,
"labels": ["dependencies", "noChangeLog"],
"labels": ["dependencies", "noChangeLog", "automated"],
"packageRules": [
{
"enabled": false,
+1 -1
View File
@@ -404,7 +404,7 @@ class DocumentViewSet(
Example:
- Ascending: GET /api/v1.0/documents/?ordering=created_at
- Desceding: GET /api/v1.0/documents/?ordering=-title
- Descending: GET /api/v1.0/documents/?ordering=-title
### Filtering:
- `is_creator_me=true`: Returns documents created by the current user.
+1 -1
View File
@@ -747,7 +747,7 @@ class Document(MP_Node, BaseModel):
for ancestor in ancestors_links:
links_definitions[ancestor["link_reach"]].add(ancestor["link_role"])
return dict(links_definitions) # Convert defaultdict back to a normal dict
return dict(links_definitions) # Convert default dict back to a normal dict
def compute_ancestors_links(self, user):
"""
+2 -5
View File
@@ -16,7 +16,6 @@ from socket import gethostbyname, gethostname
from django.utils.translation import gettext_lazy as _
import dj_database_url
import sentry_sdk
from configurations import Configuration, values
from sentry_sdk.integrations.django import DjangoIntegration
@@ -75,9 +74,7 @@ class Base(Configuration):
# Database
DATABASES = {
"default": dj_database_url.config()
if os.environ.get("DATABASE_URL")
else {
"default": {
"ENGINE": values.Value(
"django.db.backends.postgresql_psycopg2",
environ_name="DB_ENGINE",
@@ -399,7 +396,7 @@ class Base(Configuration):
# CORS
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_ALL_ORIGINS = values.BooleanValue(True)
CORS_ALLOW_ALL_ORIGINS = values.BooleanValue(False)
CORS_ALLOWED_ORIGINS = values.ListValue([])
CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([])
+15 -16
View File
@@ -26,10 +26,9 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"beautifulsoup4==4.13.4",
"boto3==1.38.18",
"boto3==1.38.33",
"Brotli==1.1.0",
"celery[redis]==5.5.2",
"dj-database-url==2.3.0",
"celery[redis]==5.5.3",
"django-configurations==2.5.1",
"django-cors-headers==4.7.0",
"django-countries==7.6.1",
@@ -39,7 +38,7 @@ dependencies = [
"django-redis==5.4.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.1.9",
"django==5.1.10",
"django-treebeard==4.7.1",
"djangorestframework==3.16.0",
"drf_spectacular==0.28.0",
@@ -47,19 +46,19 @@ dependencies = [
"easy_thumbnails==2.10",
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jsonschema==4.23.0",
"jsonschema==4.24.0",
"lxml==5.4.0",
"markdown==3.8",
"mozilla-django-oidc==4.0.1",
"nested-multipart-parser==1.5.0",
"openai==1.79.0",
"openai==1.85.0",
"psycopg[binary]==3.2.9",
"pycrdt==0.12.19",
"pycrdt==0.12.20",
"PyJWT==2.10.1",
"python-magic==0.4.27",
"redis<6.0.0",
"requests==2.32.3",
"sentry-sdk==2.28.0",
"requests==2.32.4",
"sentry-sdk==2.29.1",
"whitenoise==6.9.0",
]
@@ -73,21 +72,21 @@ dependencies = [
dev = [
"django-extensions==4.1",
"django-test-migrations==1.5.0",
"drf-spectacular-sidecar==2025.5.1",
"freezegun==1.5.1",
"drf-spectacular-sidecar==2025.6.1",
"freezegun==1.5.2",
"ipdb==0.13.13",
"ipython==9.2.0",
"ipython==9.3.0",
"pyfakefs==5.8.0",
"pylint-django==2.6.1",
"pylint==3.3.7",
"pytest-cov==6.1.1",
"pytest-django==4.11.1",
"pytest==8.3.5",
"pytest==8.4.0",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"pytest-xdist==3.7.0",
"responses==0.25.7",
"ruff==0.11.10",
"types-requests==2.32.0.20250515",
"ruff==0.11.13",
"types-requests==2.32.0.20250602",
]
[tool.setuptools]
@@ -119,9 +119,29 @@ test.describe('Config', () => {
.first(),
).toBeAttached();
});
test('it checks theme_customization.translations config', async ({
page,
}) => {
await overrideConfig(page, {
theme_customization: {
translations: {
en: {
translation: {
Docs: 'MyCustomDocs',
},
},
},
},
});
await page.goto('/');
await expect(page.getByText('MyCustomDocs')).toBeAttached();
});
});
test.describe('Config: Not loggued', () => {
test.describe('Config: Not logged', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('it checks the config api is called', async ({ page }) => {
@@ -31,7 +31,7 @@ test.describe('Doc Create', () => {
});
});
test.describe('Doc Create: Not loggued', () => {
test.describe('Doc Create: Not logged', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('it creates a doc server way', async ({
@@ -95,7 +95,7 @@ test.describe('Doc Editor', () => {
const selectVisibility = page.getByLabel('Visibility', { exact: true });
// When the visibility is changed, the ws should closed the connection (backend signal)
// When the visibility is changed, the ws should close the connection (backend signal)
const wsClosePromise = webSocket.waitForEvent('close');
await selectVisibility.click();
@@ -270,7 +270,7 @@ test.describe('Doc Export', () => {
});
/**
* We cannot assert the line break is visible in the pdf but we can assert the
* We cannot assert the line break is visible in the pdf, but we can assert the
* line break is visible in the editor and that the pdf is generated.
*/
test('it exports the doc with divider', async ({ page, browserName }) => {
@@ -96,7 +96,7 @@ test.describe('Doc Routing', () => {
});
});
test.describe('Doc Routing: Not loggued', () => {
test.describe('Doc Routing: Not logged', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('checks redirect to a doc after login', async ({
@@ -295,7 +295,7 @@ test.describe('Doc Visibility: Public', () => {
).toBeVisible();
await page.getByLabel('Visibility mode').click();
await page.getByLabel('Edition').click();
await page.getByLabel('Editing').click();
await expect(
page.getByText('The document visibility has been updated.').first(),
@@ -333,7 +333,7 @@ test.describe('Doc Visibility: Public', () => {
test.describe('Doc Visibility: Authenticated', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('A doc is not accessible when unauthentified.', async ({
test('A doc is not accessible when unauthenticated.', async ({
page,
browserName,
}) => {
@@ -476,7 +476,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const urlDoc = page.url();
await page.getByLabel('Visibility mode').click();
await page.getByLabel('Edition').click();
await page.getByLabel('Editing').click();
await expect(
page.getByText('The document visibility has been updated.').first(),
@@ -128,8 +128,16 @@ export async function waitForLanguageSwitch(
lang: TestLanguageValue,
) {
const header = page.locator('header').first();
await header.getByRole('button', { name: 'arrow_drop_down' }).click();
const languagePicker = header.locator('.--docs--language-picker-text');
const isAlreadyTargetLanguage = await languagePicker
.innerText()
.then((text) => text.toLowerCase().includes(lang.label.toLowerCase()));
if (isAlreadyTargetLanguage) {
return;
}
await languagePicker.click();
const responsePromise = page.waitForResponse(
(resp) =>
resp.url().includes('/user') && resp.request().method() === 'PATCH',
+19 -19
View File
@@ -16,56 +16,56 @@
},
"dependencies": {
"@ag-media/react-pdf-table": "2.0.3",
"@blocknote/code-block": "0.30.1",
"@blocknote/core": "0.30.1",
"@blocknote/mantine": "0.30.1",
"@blocknote/react": "0.30.1",
"@blocknote/xl-docx-exporter": "0.30.1",
"@blocknote/xl-pdf-exporter": "0.30.1",
"@blocknote/code-block": "0.31.1",
"@blocknote/core": "0.31.1",
"@blocknote/mantine": "0.31.1",
"@blocknote/react": "0.31.1",
"@blocknote/xl-docx-exporter": "0.31.1",
"@blocknote/xl-pdf-exporter": "0.31.1",
"@emoji-mart/data": "1.2.1",
"@emoji-mart/react": "1.1.1",
"@fontsource/material-icons": "5.2.5",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.6.0",
"@gouvfr-lasuite/ui-kit": "0.7.0",
"@hocuspocus/provider": "2.15.2",
"@openfun/cunningham-react": "3.1.0",
"@react-pdf/renderer": "4.3.0",
"@sentry/nextjs": "9.19.0",
"@tanstack/react-query": "5.76.1",
"@sentry/nextjs": "9.26.0",
"@tanstack/react-query": "5.80.5",
"canvg": "4.0.3",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"crisp-sdk-web": "1.0.25",
"docx": "9.5.0",
"emoji-mart": "5.6.0",
"i18next": "25.1.3",
"i18next": "25.2.1",
"i18next-browser-languagedetector": "8.1.0",
"idb": "8.0.3",
"lodash": "4.17.21",
"luxon": "3.6.1",
"next": "15.3.2",
"posthog-js": "1.242.2",
"next": "15.3.3",
"posthog-js": "1.249.3",
"react": "*",
"react-aria-components": "1.8.0",
"react-aria-components": "1.9.0",
"react-dom": "*",
"react-i18next": "15.5.1",
"react-i18next": "15.5.2",
"react-intersection-observer": "9.16.0",
"react-select": "5.10.1",
"styled-components": "6.1.18",
"use-debounce": "10.0.4",
"y-protocols": "1.0.6",
"yjs": "*",
"zustand": "5.0.4"
"zustand": "5.0.5"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.76.1",
"@tanstack/react-query-devtools": "5.80.5",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.16",
"@types/lodash": "4.17.17",
"@types/luxon": "3.6.2",
"@types/node": "*",
"@types/react": "*",
@@ -78,11 +78,11 @@
"jest-environment-jsdom": "29.7.0",
"node-fetch": "2.7.0",
"prettier": "3.5.3",
"stylelint": "16.19.1",
"stylelint": "16.20.0",
"stylelint-config-standard": "38.0.0",
"stylelint-prettier": "5.0.3",
"typescript": "*",
"webpack": "5.99.8",
"webpack": "5.99.9",
"workbox-webpack-plugin": "7.1.0"
}
}
@@ -29,7 +29,7 @@ describe('fetchAPI', () => {
});
});
it('check the versionning', () => {
it('check the versioning', () => {
fetchMock.mock('http://test.jest/api/v2.0/some/url', 200);
void fetchAPI('some/url', {}, '2.0');
@@ -1,4 +1,5 @@
import { PropsWithChildren, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, BoxButton, BoxProps, DropButton, Icon, Text } from '@/components';
@@ -38,6 +39,7 @@ export const DropdownMenu = ({
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const [isOpen, setIsOpen] = useState(false);
const blockButtonRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const onOpenChange = (isOpen: boolean) => {
setIsOpen(isOpen);
@@ -150,6 +152,7 @@ export const DropdownMenu = ({
$direction="row"
$align="center"
$gap={spacingsTokens['base']}
aria-selected={option.isSelected ? true : undefined}
>
{option.icon && (
<Icon
@@ -164,7 +167,14 @@ export const DropdownMenu = ({
</Text>
</Box>
{option.isSelected && (
<Icon iconName="check" $size="20px" $theme="greyscale" />
<Icon
role="img"
iconName="check"
$size="20px"
$theme="greyscale"
aria-label={t('Checked')}
aria-hidden="false"
/>
)}
</BoxButton>
);
@@ -14,6 +14,7 @@ export const Icon = ({
}: IconProps) => {
return (
<Text
aria-hidden="true"
{...textProps}
className={clsx('--docs--icon-bg', textProps.className, {
'material-icons-filled': variant === 'filled',
@@ -0,0 +1,48 @@
import {
UseMutationResult,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { User } from '@/features/auth/api/types';
import { KEY_AUTH } from '@/features/auth/api/useAuthQuery';
type UserUpdateRequest = Partial<User>;
async function updateUser(userUpdateData: UserUpdateRequest): Promise<User> {
const response = await fetchAPI(`users/${userUpdateData.id}/`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(userUpdateData),
});
if (!response.ok) {
throw new APIError(
`Failed to update the user`,
await errorCauses(response, userUpdateData),
);
}
return response.json() as Promise<User>;
}
export const useUserUpdate = (): UseMutationResult<
User,
APIError,
UserUpdateRequest
> => {
const queryClient = useQueryClient();
const mutationResult = useMutation<User, APIError, UserUpdateRequest>({
mutationFn: updateUser,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: [KEY_AUTH] });
},
onError: (error) => {
console.error('Error updating user', error);
},
});
return mutationResult;
};
@@ -1,10 +1,15 @@
import { Loader } from '@openfun/cunningham-react';
import Head from 'next/head';
import { PropsWithChildren, useEffect } from 'react';
import { PropsWithChildren, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useLanguageSynchronizer } from '@/features/language/';
import { useAuthQuery } from '@/features/auth';
import {
useCustomTranslations,
useSynchronizedLanguage,
} from '@/features/language';
import { useAnalytics } from '@/libs';
import { CrispProvider, PostHogAnalytic } from '@/services';
import { useSentryStore } from '@/stores/useSentryStore';
@@ -13,10 +18,35 @@ import { useConfig } from './api/useConfig';
export const ConfigProvider = ({ children }: PropsWithChildren) => {
const { data: conf } = useConfig();
const { data: user } = useAuthQuery();
const { setSentry } = useSentryStore();
const { setTheme } = useCunninghamTheme();
const { changeLanguageSynchronized } = useSynchronizedLanguage();
const { customizeTranslations } = useCustomTranslations();
const { AnalyticsProvider } = useAnalytics();
const { synchronizeLanguage } = useLanguageSynchronizer();
const { i18n } = useTranslation();
const languageSynchronized = useRef(false);
useEffect(() => {
if (!user || languageSynchronized.current) {
return;
}
const targetLanguage =
user?.language ?? i18n.resolvedLanguage ?? i18n.language;
void changeLanguageSynchronized(targetLanguage, user).then(() => {
languageSynchronized.current = true;
});
}, [user, i18n.resolvedLanguage, i18n.language, changeLanguageSynchronized]);
useEffect(() => {
if (!conf?.theme_customization?.translations) {
return;
}
customizeTranslations(conf.theme_customization.translations);
}, [conf?.theme_customization?.translations, customizeTranslations]);
useEffect(() => {
if (!conf?.SENTRY_DSN) {
@@ -34,10 +64,6 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
setTheme(conf.FRONTEND_THEME);
}, [conf?.FRONTEND_THEME, setTheme]);
useEffect(() => {
void synchronizeLanguage();
}, [synchronizeLanguage]);
useEffect(() => {
if (!conf?.POSTHOG_KEY) {
return;
@@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { Resource } from 'i18next';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Theme } from '@/cunningham/';
@@ -7,9 +8,10 @@ import { PostHogConf } from '@/services';
interface ThemeCustomization {
footer?: FooterType;
translations?: Resource;
}
interface ConfigResponse {
export interface ConfigResponse {
AI_FEATURE_ENABLED?: boolean;
COLLABORATION_WS_URL?: string;
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY?: boolean;
@@ -11,5 +11,5 @@ export interface User {
email: string;
full_name: string;
short_name: string;
language: string;
language?: string;
}
@@ -178,7 +178,6 @@ export const BlockNoteEditorVersion = ({
initialContent,
}: BlockNoteEditorVersionProps) => {
const readOnly = true;
const { setEditor } = useEditorStore();
const editor = useCreateBlockNote(
{
collaboration: {
@@ -193,15 +192,6 @@ export const BlockNoteEditorVersion = ({
},
[initialContent],
);
useHeadings(editor);
useEffect(() => {
setEditor(editor);
return () => {
setEditor(undefined);
};
}, [setEditor, editor]);
return (
<Box $css={cssEditor(readOnly)} className="--docs--editor-container">
@@ -5,7 +5,6 @@ import { css } from 'styled-components';
import * as Y from 'yjs';
import { Box, Text, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { DocHeader, DocVersionHeader } from '@/docs/doc-header/';
import {
Doc,
@@ -26,9 +25,6 @@ interface DocEditorProps {
export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
const { isDesktop } = useResponsiveStore();
const isVersion = !!versionId && typeof versionId === 'string';
const { colorsTokens } = useCunninghamTheme();
const { provider } = useProviderStore();
if (!provider) {
@@ -66,7 +62,6 @@ export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
</Box>
<Box
$background={colorsTokens['primary-bg']}
$direction="row"
$width="100%"
$css="overflow-x: clip; flex: 1;"
@@ -1,4 +1,4 @@
import data from '@emoji-mart/data';
import { EmojiMartData } from '@emoji-mart/data';
import Picker from '@emoji-mart/react';
import React from 'react';
import { useTranslation } from 'react-i18next';
@@ -6,19 +6,15 @@ import { useTranslation } from 'react-i18next';
import { Box } from '@/components';
interface EmojiPickerProps {
emojiData: EmojiMartData;
categories: string[];
custom: {
name: string;
id: string;
emojis: string[];
}[];
onClickOutside: () => void;
onEmojiSelect: ({ native }: { native: string }) => void;
}
export const EmojiPicker = ({
emojiData,
categories,
custom,
onClickOutside,
onEmojiSelect,
}: EmojiPickerProps) => {
@@ -27,9 +23,8 @@ export const EmojiPicker = ({
return (
<Box $position="absolute" $zIndex={1000} $margin="2rem 0 0 0">
<Picker
data={emojiData}
categories={categories}
custom={custom}
data={data}
locale={i18n.resolvedLanguage}
navPosition="none"
onClickOutside={onClickOutside}
@@ -10,52 +10,7 @@ import { Box, BoxButton, Icon } from '@/components';
import { DocsBlockNoteEditor } from '../../types';
import { EmojiPicker } from '../EmojiPicker';
const calloutCustom = [
{
name: 'Callout',
id: 'callout',
emojis: [
'bulb',
'point_right',
'point_up',
'ok_hand',
'key',
'construction',
'warning',
'fire',
'pushpin',
'scissors',
'question',
'no_entry',
'no_entry_sign',
'alarm_clock',
'phone',
'rotating_light',
'recycle',
'white_check_mark',
'lock',
'paperclip',
'book',
'speaking_head_in_silhouette',
'arrow_right',
'loudspeaker',
'hammer_and_wrench',
'gear',
],
},
];
const calloutCategories = [
'callout',
'people',
'nature',
'foods',
'activity',
'places',
'flags',
'objects',
'symbols',
];
import InitEmojiCallout from './initEmojiCallout';
export const CalloutBlock = createReactBlockSpec(
{
@@ -124,8 +79,8 @@ export const CalloutBlock = createReactBlockSpec(
{openEmojiPicker && (
<EmojiPicker
categories={calloutCategories}
custom={calloutCustom}
emojiData={InitEmojiCallout.emojidata}
categories={InitEmojiCallout.calloutCategories}
onClickOutside={onClickOutside}
onEmojiSelect={onEmojiSelect}
/>
@@ -0,0 +1,76 @@
/**
* "emoji-mart" is a singleton, multiple imports in the same
* application could cause issues.
* BlockNote uses "emoji-mart" internally as well, if
* Blocknote emoji picker is init before the callout emoji picker,
* the callout emoji picker will not be set up correctly.
* To avoid this, we initialize emoji-mart here and before any
* other components that uses it.
*/
import data, { Category, EmojiMartData } from '@emoji-mart/data';
import { init } from 'emoji-mart';
type EmojiMartDataFixed = Omit<EmojiMartData, 'categories'> & {
categories: (Category & { name: string })[];
};
const emojidata = structuredClone(data) as EmojiMartDataFixed;
const CALLOUT_ID = 'callout';
const CALLOUT_EMOJIS = [
'bulb',
'point_right',
'point_up',
'ok_hand',
'key',
'construction',
'warning',
'fire',
'pushpin',
'scissors',
'question',
'no_entry',
'no_entry_sign',
'alarm_clock',
'phone',
'rotating_light',
'recycle',
'white_check_mark',
'lock',
'paperclip',
'book',
'speaking_head_in_silhouette',
'arrow_right',
'loudspeaker',
'hammer_and_wrench',
'gear',
];
if (!emojidata.categories.some((c) => c.id === CALLOUT_ID)) {
emojidata.categories.unshift({
id: CALLOUT_ID,
name: 'Callout',
emojis: CALLOUT_EMOJIS,
});
}
void init({ data: emojidata });
const calloutCategories = [
'callout',
'people',
'nature',
'foods',
'activity',
'places',
'flags',
'objects',
'symbols',
];
const calloutEmojiData = {
emojidata,
calloutCategories,
};
export default calloutEmojiData;
@@ -9,7 +9,7 @@ export const useHeadings = (editor: DocsBlockNoteEditor) => {
useEffect(() => {
setHeadings(editor);
editor?.onEditorContentChange(() => {
editor?.onChange(() => {
setHeadings(editor);
});
@@ -5,6 +5,7 @@ export const cssEditor = (readonly: boolean) => css`
& > .bn-container,
& .ProseMirror {
height: 100%;
padding-bottom: 2rem;
img.bn-visual-media[src*='-unsafe'] {
pointer-events: none;
@@ -72,8 +73,7 @@ export const cssEditor = (readonly: boolean) => css`
border-radius: var(--c--theme--spacings--3xs);
}
.bn-block-content[data-content-type='paragraph'],
.bn-block-content[data-content-type='heading'] {
.bn-block[data-background-color] > .bn-block-content {
padding: var(--c--theme--spacings--3xs) var(--c--theme--spacings--3xs);
border-radius: var(--c--theme--spacings--3xs);
}
@@ -5,7 +5,7 @@ import { DocsExporterPDF } from '../types';
export const blockMappingParagraphPDF: DocsExporterPDF['mappings']['blockMapping']['paragraph'] =
(block, exporter) => {
/**
* Breakline in the editor are not rendered in the PDF
* Break line in the editor are not rendered in the PDF
* By adding a space if the block is empty we ensure that the block is rendered
*/
if (Array.isArray(block.content)) {
@@ -34,12 +34,12 @@ export const useRemoveDoc = (options?: UseRemoveDocOptions) => {
queryKey: [KEY_LIST_DOC],
});
if (options?.onSuccess) {
options.onSuccess(data, variables, context);
void options.onSuccess(data, variables, context);
}
},
onError: (error, variables, context) => {
if (options?.onError) {
options.onError(error, variables, context);
void options.onError(error, variables, context);
}
},
});
@@ -63,12 +63,12 @@ export const useDeleteDocAccess = (options?: UseDeleteDocAccessOptions) => {
queryKey: [KEY_LIST_USER],
});
if (options?.onSuccess) {
options.onSuccess(data, variables, context);
void options.onSuccess(data, variables, context);
}
},
onError: (error, variables, context) => {
if (options?.onError) {
options.onError(error, variables, context);
void options.onError(error, variables, context);
}
},
});
@@ -58,12 +58,12 @@ export const useDeleteDocInvitation = (
queryKey: [KEY_LIST_DOC_INVITATIONS],
});
if (options?.onSuccess) {
options.onSuccess(data, variables, context);
void options.onSuccess(data, variables, context);
}
},
onError: (error, variables, context) => {
if (options?.onError) {
options.onError(error, variables, context);
void options.onError(error, variables, context);
}
},
});
@@ -65,12 +65,12 @@ export const useUpdateDocAccess = (options?: UseUpdateDocAccessOptions) => {
queryKey: [KEY_LIST_DOC],
});
if (options?.onSuccess) {
options.onSuccess(data, variables, context);
void options.onSuccess(data, variables, context);
}
},
onError: (error, variables, context) => {
if (options?.onError) {
options.onError(error, variables, context);
void options.onError(error, variables, context);
}
},
});
@@ -66,12 +66,12 @@ export const useUpdateDocInvitation = (
queryKey: [KEY_LIST_DOC_INVITATIONS],
});
if (options?.onSuccess) {
options.onSuccess(data, variables, context);
void options.onSuccess(data, variables, context);
}
},
onError: (error, variables, context) => {
if (options?.onError) {
options.onError(error, variables, context);
void options.onError(error, variables, context);
}
},
});
@@ -2,7 +2,7 @@ import { Button } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, HorizontalSeparator } from '@/components';
import { Box, HorizontalSeparator, Icon } from '@/components';
import { Doc, useCopyDocLink } from '@/features/docs';
import { DocVisibility } from './DocVisibility';
@@ -36,7 +36,7 @@ export const DocShareModalFooter = ({ doc, onClose }: Props) => {
fullWidth={false}
onClick={copyDocLink}
color="tertiary"
icon={<span className="material-icons">add_link</span>}
icon={<Icon iconName="add_link" />}
>
{t('Copy link')}
</Button>
@@ -13,7 +13,7 @@ export const useTranslatedShareSettings = () => {
const linkModeTranslations = {
[LinkRole.READER]: t('Reading'),
[LinkRole.EDITOR]: t('Edition'),
[LinkRole.EDITOR]: t('Editing'),
};
const linkReachChoices = {
@@ -40,7 +40,7 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
$align="center"
role="row"
$gap="20px"
$padding={{ vertical: '2xs', horizontal: isDesktop ? 'base' : 'xs' }}
$padding={{ vertical: '4xs', horizontal: isDesktop ? 'base' : 'xs' }}
$css={css`
cursor: pointer;
border-radius: 4px;
@@ -50,6 +50,7 @@ export const SimpleDocItem = ({
background-color: transparent;
filter: drop-shadow(0px 2px 2px rgba(0, 0, 0, 0.05));
`}
$padding={`${spacingsTokens['3xs']} 0`}
>
{isPinned ? (
<PinnedDocumentIcon
@@ -1,45 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { User } from '@/features/auth/api/types';
export interface ChangeUserLanguageParams {
userId: User['id'];
language: User['language'];
}
export const changeUserLanguage = async ({
userId,
language,
}: ChangeUserLanguageParams): Promise<User> => {
const response = await fetchAPI(`users/${userId}/`, {
method: 'PATCH',
body: JSON.stringify({
language,
}),
});
if (!response.ok) {
throw new APIError(
`Failed to change the user language to ${language}`,
await errorCauses(response, {
value: language,
type: 'language',
}),
);
}
return response.json() as Promise<User>;
};
export function useChangeUserLanguage() {
const queryClient = useQueryClient();
return useMutation<User, APIError, ChangeUserLanguageParams>({
mutationFn: changeUserLanguage,
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ['change-user-language'],
});
},
});
}
@@ -1,42 +1,33 @@
import { Settings } from 'luxon';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { DropdownMenu, Icon, Text } from '@/components/';
import { useConfig } from '@/core';
import { useLanguageSynchronizer } from './hooks/useLanguageSynchronizer';
import { getMatchingLocales } from './utils/locale';
import { useAuthQuery } from '@/features/auth';
import {
getMatchingLocales,
useSynchronizedLanguage,
} from '@/features/language';
export const LanguagePicker = () => {
const { t, i18n } = useTranslation();
const { data: conf } = useConfig();
const { synchronizeLanguage } = useLanguageSynchronizer();
const language = i18n.languages[0];
Settings.defaultLocale = language;
const { data: user } = useAuthQuery();
const { changeLanguageSynchronized } = useSynchronizedLanguage();
const language = i18n.language;
// Compute options for dropdown
const optionsPicker = useMemo(() => {
const backendOptions = conf?.LANGUAGES ?? [[language, language]];
return backendOptions.map(([backendLocale, label]) => {
// Determine if the option is selected
const isSelected =
getMatchingLocales([backendLocale], [language]).length > 0;
// Define callback for updating both frontend and backend languages
const callback = () => {
i18n
.changeLanguage(backendLocale)
.then(() => {
void synchronizeLanguage('toBackend');
})
.catch((err) => {
console.error('Error changing language', err);
});
return backendOptions.map(([backendLocale, backendLabel]) => {
return {
label: backendLabel,
isSelected: getMatchingLocales([backendLocale], [language]).length > 0,
callback: () => changeLanguageSynchronized(backendLocale, user),
};
return { label, isSelected, callback };
});
}, [conf, i18n, language, synchronizeLanguage]);
}, [changeLanguageSynchronized, conf?.LANGUAGES, language, user]);
// Extract current language label for display
const currentLanguageLabel =
@@ -0,0 +1 @@
export * from './LanguagePicker';
@@ -0,0 +1,2 @@
export * from './useSynchronizedLanguage';
export * from './useCustomTranslations';
@@ -0,0 +1,27 @@
import { Resource } from 'i18next';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
export const useCustomTranslations = () => {
const { i18n } = useTranslation();
// Overwrite translations with a resource
const customizeTranslations = useCallback(
(currentCustomTranslations: Resource) => {
Object.entries(currentCustomTranslations).forEach(([lng, namespaces]) => {
Object.entries(namespaces).forEach(([ns, value]) => {
i18n.addResourceBundle(lng, ns, value, true, true);
});
});
// trigger re-render
if (Object.entries(currentCustomTranslations).length > 0) {
void i18n.changeLanguage(i18n.language);
}
},
[i18n],
);
return {
customizeTranslations,
};
};
@@ -1,82 +0,0 @@
import { useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useConfig } from '@/core';
import { useAuthQuery } from '@/features/auth/api';
import { useChangeUserLanguage } from '@/features/language/api/useChangeUserLanguage';
import { getMatchingLocales } from '@/features/language/utils/locale';
import { availableFrontendLanguages } from '@/i18n/initI18n';
export const useLanguageSynchronizer = () => {
const { data: conf, isSuccess: confInitialized } = useConfig();
const { data: user, isSuccess: userInitialized } = useAuthQuery();
const { i18n } = useTranslation();
const { mutateAsync: changeUserLanguage } = useChangeUserLanguage();
const languageSynchronizing = useRef(false);
const availableBackendLanguages = useMemo(() => {
return conf?.LANGUAGES.map(([locale]) => locale);
}, [conf?.LANGUAGES]);
const synchronizeLanguage = useCallback(
async (direction?: 'toBackend' | 'toFrontend') => {
if (
languageSynchronizing.current ||
!userInitialized ||
!confInitialized ||
!availableBackendLanguages ||
!availableFrontendLanguages
) {
return;
}
languageSynchronizing.current = true;
try {
const userPreferredLanguages = user.language ? [user.language] : [];
const setOrDetectedLanguages = i18n.languages;
// Default direction depends on whether a user already has a language preference
direction =
direction ??
(userPreferredLanguages.length ? 'toFrontend' : 'toBackend');
if (direction === 'toBackend') {
// Update user's preference from frontends's language
const closestBackendLanguage =
getMatchingLocales(
availableBackendLanguages,
setOrDetectedLanguages,
)[0] || availableBackendLanguages[0];
await changeUserLanguage({
userId: user.id,
language: closestBackendLanguage,
});
} else {
// Update frontends's language from user's preference
const closestFrontendLanguage =
getMatchingLocales(
availableFrontendLanguages,
userPreferredLanguages,
)[0] || availableFrontendLanguages[0];
if (i18n.resolvedLanguage !== closestFrontendLanguage) {
await i18n.changeLanguage(closestFrontendLanguage);
}
}
} catch (error) {
console.error('Error synchronizing language', error);
} finally {
languageSynchronizing.current = false;
}
},
[
i18n,
user,
userInitialized,
confInitialized,
availableBackendLanguages,
changeUserLanguage,
],
);
return { synchronizeLanguage };
};
@@ -0,0 +1,71 @@
import { useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useUserUpdate } from '@/core/api/useUserUpdate';
import { useConfig } from '@/core/config/api/useConfig';
import { User } from '@/features/auth';
import { getMatchingLocales } from '@/features/language/utils/locale';
export const useSynchronizedLanguage = () => {
const { i18n } = useTranslation();
const { mutateAsync: updateUser } = useUserUpdate();
const { data: config } = useConfig();
const isSynchronizingLanguage = useRef(false);
const availableFrontendLanguages = useMemo(
() => Object.keys(i18n?.options?.resources || { en: '<- fallback' }),
[i18n?.options?.resources],
);
const availableBackendLanguages = useMemo(
() => config?.LANGUAGES?.map(([locale]) => locale) || [],
[config?.LANGUAGES],
);
const changeBackendLanguage = useCallback(
async (language: string, user?: User) => {
const closestBackendLanguage = getMatchingLocales(
availableBackendLanguages,
[language],
)[0];
if (user && user.language !== closestBackendLanguage) {
await updateUser({ id: user.id, language: closestBackendLanguage });
}
},
[availableBackendLanguages, updateUser],
);
const changeFrontendLanguage = useCallback(
async (language: string) => {
const closestFrontendLanguage = getMatchingLocales(
availableFrontendLanguages,
[language],
)[0];
if (
i18n.isInitialized &&
i18n.resolvedLanguage !== closestFrontendLanguage
) {
await i18n.changeLanguage(closestFrontendLanguage);
}
},
[availableFrontendLanguages, i18n],
);
const changeLanguageSynchronized = useCallback(
async (language: string, user?: User) => {
if (!isSynchronizingLanguage.current) {
isSynchronizingLanguage.current = true;
await changeFrontendLanguage(language);
await changeBackendLanguage(language, user);
isSynchronizingLanguage.current = false;
}
},
[changeBackendLanguage, changeFrontendLanguage],
);
return {
changeLanguageSynchronized,
changeFrontendLanguage,
changeBackendLanguage,
};
};
@@ -1,2 +1,3 @@
export * from './hooks/useLanguageSynchronizer';
export * from './LanguagePicker';
export * from './hooks';
export * from './components';
export * from './utils';
@@ -0,0 +1 @@
export * from './locale';
@@ -112,7 +112,7 @@ export class ApiPlugin implements WorkboxPlugin {
};
/**
* When we get an network error.
* When we get a network error.
*/
handlerDidError: WorkboxPlugin['handlerDidError'] = async ({ request }) => {
if (!this.isFetchDidFailed) {
+31 -29
View File
@@ -4,36 +4,38 @@ import { initReactI18next } from 'react-i18next';
import resources from './translations.json';
export const availableFrontendLanguages: readonly string[] =
Object.keys(resources);
// Add an initialization guard
let isInitialized = false;
i18next
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: 'en',
debug: false,
detection: {
order: ['cookie', 'navigator'], // detection order
caches: ['cookie'], // Use cookies to store the language preference
lookupCookie: 'docs_language',
cookieMinutes: 525600, // Expires after one year
cookieOptions: {
path: '/',
sameSite: 'lax',
// Initialize i18next with the base translations only once
if (!isInitialized && !i18next.isInitialized) {
isInitialized = true;
i18next
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: 'en',
debug: false,
detection: {
order: ['cookie', 'navigator'],
caches: ['cookie'],
lookupCookie: 'docs_language',
cookieMinutes: 525600,
cookieOptions: {
path: '/',
sameSite: 'lax',
},
},
},
interpolation: {
escapeValue: false,
},
preload: availableFrontendLanguages,
lowerCaseLng: true,
nsSeparator: false,
keySeparator: false,
})
.catch(() => {
throw new Error('i18n initialization failed');
});
interpolation: {
escapeValue: false,
},
lowerCaseLng: true,
nsSeparator: false,
keySeparator: false,
})
.catch((e) => console.error('i18n initialization failed:', e));
}
export default i18next;
@@ -62,6 +62,7 @@
"Beautify": "Verschönern",
"Can't load this page, please check your internet connection.": "Diese Seite kann nicht geladen werden. Bitte überprüfen Sie Ihre Internetverbindung.",
"Cancel": "Abbrechen",
"Checked": "Angehakt",
"Close the modal": "Pop up schliessen",
"Collaborate": "Zusammenarbeiten",
"Collaborate and write in real time, without layout constraints.": "In Echtzeit und ohne Layout-Beschränkungen schreiben und zusammenarbeiten.",
@@ -253,6 +254,7 @@
"Callout": "Destacado",
"Can't load this page, please check your internet connection.": "No se puede cargar esta página, por favor compruebe su conexión a Internet.",
"Cancel": "Cancelar",
"Checked": "Marcado",
"Close the modal": "Cerrar modal",
"Collaborate": "Colabora",
"Collaborate and write in real time, without layout constraints.": "Colaborar y escribir en tiempo real, sin restricciones de diseño.",
@@ -437,6 +439,7 @@
"Callout": "Alerte",
"Can't load this page, please check your internet connection.": "Impossible de charger cette page, veuillez vérifier votre connexion Internet.",
"Cancel": "Annuler",
"Checked": "Coché",
"Close the modal": "Fermer la modale",
"Collaborate": "Collaborer",
"Collaborate and write in real time, without layout constraints.": "Collaborez et rédigez en temps réel, sans contrainte de mise en page.",
@@ -625,6 +628,7 @@
"Beautify": "Filtro bellezza",
"Can't load this page, please check your internet connection.": "Impossibile caricare questa pagina, controlla la tua connessione internet.",
"Cancel": "Cancella",
"Checked": "Selezionato",
"Collaborate": "Collabora",
"Collaborate and write in real time, without layout constraints.": "Collaborare e scrivere in tempo reale, senza vincoli di layout.",
"Collaborative writing, Simplified.": "Scrittura collaborativa, semplificata.",
@@ -781,6 +785,7 @@
"Beautify": "Maak mooier",
"Can't load this page, please check your internet connection.": "Kan deze pagina niet laden. Controleer je internetverbinding.",
"Cancel": "Breek af",
"Checked": "Aangevinkt",
"Close the modal": "Sluit het venster",
"Collaborate": "Samenwerken",
"Collaborate and write in real time, without layout constraints.": "Samenwerken en schrijven in realtime, zonder lay-out beperkingen.",
@@ -952,6 +957,7 @@
"Anonymous": "Anonym",
"Beautify": "Försköna",
"Cancel": "Avbryt",
"Checked": "Markerad",
"Close the modal": "Stäng fönstret",
"Convert Markdown": "Konvertera Markdown",
"Correct": "Korrekt",
@@ -989,6 +995,7 @@
"Banner image": "Afiş görseli",
"Beautify": "Güzelleştir",
"Cancel": "İptal",
"Checked": "İşaretli",
"Collaborate": "İşbirliği Yapın",
"Collaborate and write in real time, without layout constraints.": "Düzen kısıtlamaları olmadan gerçek zamanlı olarak işbirliği yapın ve yazın.",
"Collaborative writing, Simplified.": "İş birliğiyle yazmak çok daha Kolay.",
@@ -1078,6 +1085,7 @@
"Callout": "标注",
"Can't load this page, please check your internet connection.": "无法加载此页面,请检查您的网络连接。",
"Cancel": "取消",
"Checked": "已勾选",
"Close the modal": "关闭对话框",
"Collaborate": "协作",
"Collaborate and write in real time, without layout constraints.": "实时协作和写入,不受布局限制。",
@@ -26,6 +26,7 @@ export default function App({ Component, pageProps }: AppPropsWithLayout) {
<>
<Head>
<title>{t('Docs')}</title>
<meta property="og:title" content={t('Docs')} key="title" />
<meta
name="description"
content={t(
@@ -67,14 +67,6 @@ const DocPage = ({ id }: DocProps) => {
useCollaboration(doc?.id, doc?.content);
const { t } = useTranslation();
useEffect(() => {
if (doc?.title) {
setTimeout(() => {
document.title = `${doc.title} - ${t('Docs')}`;
}, 100);
}
}, [doc?.title, t]);
useEffect(() => {
if (!docQuery || isFetching) {
return;
@@ -142,7 +134,21 @@ const DocPage = ({ id }: DocProps) => {
);
}
return <DocEditor doc={doc} />;
return (
<>
<Head>
<title>
{doc.title} - {t('Docs')}
</title>
<meta
property="og:title"
content={`${doc.title} - ${t('Docs')}`}
key="title"
/>
</Head>
<DocEditor doc={doc} />
</>
);
};
const Page: NextPageWithLayout = () => {
@@ -0,0 +1,52 @@
/**
* @fileOverview This module provides utilities to interact with local storage safely.
*/
interface SyncStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
/**
* @namespace safeLocalStorage
* @description A utility for safely interacting with localStorage.
* It checks if the `window` object is defined before attempting to access localStorage,
* preventing errors in environments where `window` is not available.
*/
export const safeLocalStorage: SyncStorage = {
/**
* Retrieves an item from localStorage.
* @param {string} key - The key of the item to retrieve.
* @returns {string | null} The item's value, or null if the item does not exist or if localStorage is not available.
*/
getItem: (key: string): string | null => {
if (typeof window === 'undefined') {
return null;
}
return localStorage.getItem(key);
},
/**
* Sets an item in localStorage.
* @param {string} key - The key of the item to set.
* @param {string} value - The value to set for the item.
* @returns {void}
*/
setItem: (key: string, value: string): void => {
if (typeof window === 'undefined') {
return;
}
localStorage.setItem(key, value);
},
/**
* Removes an item from localStorage.
* @param {string} key - The key of the item to remove.
* @returns {void}
*/
removeItem: (key: string): void => {
if (typeof window === 'undefined') {
return;
}
localStorage.removeItem(key);
},
};
+5 -6
View File
@@ -19,7 +19,6 @@
"app:build": "yarn APP_IMPRESS run build",
"app:test": "yarn APP_IMPRESS run test",
"ci:build": "yarn APP_IMPRESS run build:ci",
"build": "yarn APP_IMPRESS run build && yarn COLLABORATION_SERVER run build",
"e2e:test": "yarn APP_E2E run test",
"lint": "yarn APP_IMPRESS run lint && yarn APP_E2E run lint && yarn workspace eslint-config-impress run lint && yarn I18N run lint && yarn COLLABORATION_SERVER run lint",
"i18n:extract": "yarn I18N run extract-translation",
@@ -29,11 +28,11 @@
"server:test": "yarn COLLABORATION_SERVER run test"
},
"resolutions": {
"@types/node": "22.15.19",
"@types/react": "19.1.4",
"@types/react-dom": "19.1.5",
"@typescript-eslint/eslint-plugin": "8.32.1",
"@typescript-eslint/parser": "8.32.1",
"@types/node": "22.15.29",
"@types/react": "19.1.6",
"@types/react-dom": "19.1.6",
"@typescript-eslint/eslint-plugin": "8.33.1",
"@typescript-eslint/parser": "8.33.1",
"eslint": "8.57.0",
"react": "19.1.0",
"react-dom": "19.1.0",
@@ -6,19 +6,19 @@
"lint": "eslint --ext .js ."
},
"dependencies": {
"@next/eslint-plugin-next": "15.3.2",
"@tanstack/eslint-plugin-query": "5.74.7",
"@next/eslint-plugin-next": "15.3.3",
"@tanstack/eslint-plugin-query": "5.78.0",
"@typescript-eslint/eslint-plugin": "*",
"@typescript-eslint/parser": "*",
"eslint": "*",
"eslint-config-next": "15.3.2",
"eslint-config-next": "15.3.3",
"eslint-config-prettier": "10.1.5",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-jest": "28.11.0",
"eslint-plugin-jest": "28.12.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-playwright": "2.2.0",
"eslint-plugin-prettier": "5.4.0",
"eslint-plugin-testing-library": "7.2.1",
"eslint-plugin-prettier": "5.4.1",
"eslint-plugin-testing-library": "7.4.0",
"prettier": "3.5.3"
}
}
+2 -3
View File
@@ -1,11 +1,10 @@
import fs from 'fs';
import path from 'path';
import { hideBin } from 'yargs/helpers';
import yargs from 'yargs/yargs';
import yargs from 'yargs';
// Get our args
const argv = yargs(hideBin(process.argv)).argv;
const argv = yargs(process.argv).argv;
const { app, output } = argv;
const folderPath = './locales/' + app;
+1 -1
View File
@@ -20,6 +20,6 @@
"jest": "29.7.0",
"ts-jest": "29.3.4",
"typescript": "*",
"yargs": "17.7.2"
"yargs": "18.0.0"
}
}
@@ -8,11 +8,10 @@
import fs from 'fs';
import path from 'path';
import { hideBin } from 'yargs/helpers';
import yargs from 'yargs/yargs';
import yargs from 'yargs';
// Get our args
const argv = yargs(hideBin(process.argv)).argv;
const argv = yargs(process.argv).argv;
const { app, output, language } = argv;
const folderPath = './locales/' + app;
+4 -4
View File
@@ -13,13 +13,13 @@
"test": "jest"
},
"engines": {
"node": ">=18"
"node": ">=22"
},
"dependencies": {
"@blocknote/server-util": "0.30.1",
"@blocknote/server-util": "0.31.1",
"@hocuspocus/server": "2.15.2",
"@sentry/node": "9.19.0",
"@sentry/profiling-node": "9.19.0",
"@sentry/node": "9.26.0",
"@sentry/profiling-node": "9.26.0",
"axios": "1.9.0",
"cors": "2.8.5",
"express": "5.1.0",
+1748 -338
View File
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,12 @@
{
"translations": {
"en": {
"translation": {
"Docs": "MyDocs",
"New doc": "+"
}
}
},
"footer": {
"default": {
"logo": {
+1 -1
View File
@@ -14,7 +14,7 @@
"build": "yarn build-mjml-to-html && yarn build-html-to-plain-text"
},
"volta": {
"node": "16.15.1"
"node": "22"
},
"repository": "https://github.com/numerique-gouv/impress",
"author": "DINUM",
-114
View File
@@ -1,114 +0,0 @@
# ERB templated nginx configuration
# see https://doc.scalingo.com/platform/deployment/buildpacks/nginx
upstream backend_server {
server localhost:8000 fail_timeout=0;
}
upstream collaboration_server {
server localhost:4444 fail_timeout=0;
}
server {
listen <%= ENV["PORT"] %>;
server_name _;
root /app/build/frontend-out;
error_page 404 /404.html;
location /collaboration/api/ {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://collaboration_server;
}
location /collaboration/ws/ {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
# Set appropriate timeout for WebSocketAdd commentMore actions
proxy_read_timeout 86400;
proxy_send_timeout 86400;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://collaboration_server;
}
# Django rest framework
location ^~ /api/ {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://backend_server;
}
# Django admin
location ^~ /admin/ {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://backend_server;
}
# Proxy auth for media
location /media/ {
# Auth request configuration
auth_request /media-auth;
auth_request_set $authHeader $upstream_http_authorization;
auth_request_set $authDate $upstream_http_x_amz_date;
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
# Pass specific headers from the auth response
proxy_set_header Authorization $authHeader;
proxy_set_header X-Amz-Date $authDate;
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
# Get resource from Object Storage
proxy_pass <%= ENV["AWS_S3_ENDPOINT_URL"] %>/<%= ENV["AWS_STORAGE_BUCKET_NAME"] %>/;
proxy_set_header Host <%= ENV["AWS_S3_ENDPOINT_URL"].split("://")[1] %>;
add_header Content-Security-Policy "default-src 'none'" always;
}
location /media-auth {
proxy_pass http://backend_server/api/v1.0/documents/media-auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Original-URL $request_uri;
# Prevent the body from being passed
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
}
location / {
try_files $uri index.html $uri/ =404;
}
location ~ "^/docs/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/?$" {
try_files $uri /docs/[id]/index.html;
}
location = /404.html {
internal;
}
}