Compare commits

..

1 Commits

Author SHA1 Message Date
Sylvain Zimmer ddac6197e3 (buildpack) add PaaS deployment support, tested with Scalingo 2025-06-01 23:54:35 +02:00
78 changed files with 884 additions and 2348 deletions
-1
View File
@@ -34,4 +34,3 @@ db.sqlite3
# Frontend
node_modules
.next
-23
View File
@@ -1,23 +0,0 @@
# 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: '22.x'
node_version: '20.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: '22.x'
node_version: '20.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: '22.x'
default: '20.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: '22.x'
node_version: '20.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: "22.x"
node-version: "20.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: "22.x"
node-version: "20.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: "22.x"
node-version: "20.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: "22.x"
node-version: "20.x"
- name: Restore the frontend cache
uses: actions/cache@v4
-16
View File
@@ -8,22 +8,6 @@ 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
@@ -0,0 +1,2 @@
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/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/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 the project by going to <http://localhost:3000>.
You can access to 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 eponymous Make rule:
📝 Note that if you need to run them afterwards, you can use the eponym 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/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/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
@@ -0,0 +1,15 @@
#!/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
@@ -0,0 +1,15 @@
#!/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
@@ -0,0 +1,18 @@
#!/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:22
image: node:18
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 | false |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | allow all CORS origins | true |
| 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,18 +53,4 @@ 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", "automated"],
"labels": ["dependencies", "noChangeLog"],
"packageRules": [
{
"enabled": false,
+1 -1
View File
@@ -404,7 +404,7 @@ class DocumentViewSet(
Example:
- Ascending: GET /api/v1.0/documents/?ordering=created_at
- Descending: GET /api/v1.0/documents/?ordering=-title
- Desceding: 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 default dict back to a normal dict
return dict(links_definitions) # Convert defaultdict back to a normal dict
def compute_ancestors_links(self, user):
"""
+5 -2
View File
@@ -16,6 +16,7 @@ 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
@@ -74,7 +75,9 @@ class Base(Configuration):
# Database
DATABASES = {
"default": {
"default": dj_database_url.config()
if os.environ.get("DATABASE_URL")
else {
"ENGINE": values.Value(
"django.db.backends.postgresql_psycopg2",
environ_name="DB_ENGINE",
@@ -396,7 +399,7 @@ class Base(Configuration):
# CORS
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_ALL_ORIGINS = values.BooleanValue(False)
CORS_ALLOW_ALL_ORIGINS = values.BooleanValue(True)
CORS_ALLOWED_ORIGINS = values.ListValue([])
CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([])
+16 -15
View File
@@ -26,9 +26,10 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"beautifulsoup4==4.13.4",
"boto3==1.38.33",
"boto3==1.38.18",
"Brotli==1.1.0",
"celery[redis]==5.5.3",
"celery[redis]==5.5.2",
"dj-database-url==2.3.0",
"django-configurations==2.5.1",
"django-cors-headers==4.7.0",
"django-countries==7.6.1",
@@ -38,7 +39,7 @@ dependencies = [
"django-redis==5.4.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.1.10",
"django==5.1.9",
"django-treebeard==4.7.1",
"djangorestframework==3.16.0",
"drf_spectacular==0.28.0",
@@ -46,19 +47,19 @@ dependencies = [
"easy_thumbnails==2.10",
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jsonschema==4.24.0",
"jsonschema==4.23.0",
"lxml==5.4.0",
"markdown==3.8",
"mozilla-django-oidc==4.0.1",
"nested-multipart-parser==1.5.0",
"openai==1.85.0",
"openai==1.79.0",
"psycopg[binary]==3.2.9",
"pycrdt==0.12.20",
"pycrdt==0.12.19",
"PyJWT==2.10.1",
"python-magic==0.4.27",
"redis<6.0.0",
"requests==2.32.4",
"sentry-sdk==2.29.1",
"requests==2.32.3",
"sentry-sdk==2.28.0",
"whitenoise==6.9.0",
]
@@ -72,21 +73,21 @@ dependencies = [
dev = [
"django-extensions==4.1",
"django-test-migrations==1.5.0",
"drf-spectacular-sidecar==2025.6.1",
"freezegun==1.5.2",
"drf-spectacular-sidecar==2025.5.1",
"freezegun==1.5.1",
"ipdb==0.13.13",
"ipython==9.3.0",
"ipython==9.2.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.4.0",
"pytest==8.3.5",
"pytest-icdiff==0.9",
"pytest-xdist==3.7.0",
"pytest-xdist==3.6.1",
"responses==0.25.7",
"ruff==0.11.13",
"types-requests==2.32.0.20250602",
"ruff==0.11.10",
"types-requests==2.32.0.20250515",
]
[tool.setuptools]
@@ -119,29 +119,9 @@ 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 logged', () => {
test.describe('Config: Not loggued', () => {
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 logged', () => {
test.describe('Doc Create: Not loggued', () => {
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 close the connection (backend signal)
// When the visibility is changed, the ws should closed 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 logged', () => {
test.describe('Doc Routing: Not loggued', () => {
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('Editing').click();
await page.getByLabel('Edition').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 unauthenticated.', async ({
test('A doc is not accessible when unauthentified.', 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('Editing').click();
await page.getByLabel('Edition').click();
await expect(
page.getByText('The document visibility has been updated.').first(),
@@ -128,16 +128,8 @@ export async function waitForLanguageSwitch(
lang: TestLanguageValue,
) {
const header = page.locator('header').first();
const languagePicker = header.locator('.--docs--language-picker-text');
const isAlreadyTargetLanguage = await languagePicker
.innerText()
.then((text) => text.toLowerCase().includes(lang.label.toLowerCase()));
await header.getByRole('button', { name: 'arrow_drop_down' }).click();
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.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",
"@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",
"@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.7.0",
"@gouvfr-lasuite/ui-kit": "0.6.0",
"@hocuspocus/provider": "2.15.2",
"@openfun/cunningham-react": "3.1.0",
"@react-pdf/renderer": "4.3.0",
"@sentry/nextjs": "9.26.0",
"@tanstack/react-query": "5.80.5",
"@sentry/nextjs": "9.19.0",
"@tanstack/react-query": "5.76.1",
"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.2.1",
"i18next": "25.1.3",
"i18next-browser-languagedetector": "8.1.0",
"idb": "8.0.3",
"lodash": "4.17.21",
"luxon": "3.6.1",
"next": "15.3.3",
"posthog-js": "1.249.3",
"next": "15.3.2",
"posthog-js": "1.242.2",
"react": "*",
"react-aria-components": "1.9.0",
"react-aria-components": "1.8.0",
"react-dom": "*",
"react-i18next": "15.5.2",
"react-i18next": "15.5.1",
"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.5"
"zustand": "5.0.4"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.80.5",
"@tanstack/react-query-devtools": "5.76.1",
"@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.17",
"@types/lodash": "4.17.16",
"@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.20.0",
"stylelint": "16.19.1",
"stylelint-config-standard": "38.0.0",
"stylelint-prettier": "5.0.3",
"typescript": "*",
"webpack": "5.99.9",
"webpack": "5.99.8",
"workbox-webpack-plugin": "7.1.0"
}
}
@@ -29,7 +29,7 @@ describe('fetchAPI', () => {
});
});
it('check the versioning', () => {
it('check the versionning', () => {
fetchMock.mock('http://test.jest/api/v2.0/some/url', 200);
void fetchAPI('some/url', {}, '2.0');
@@ -1,5 +1,4 @@
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';
@@ -39,7 +38,6 @@ 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);
@@ -152,7 +150,6 @@ export const DropdownMenu = ({
$direction="row"
$align="center"
$gap={spacingsTokens['base']}
aria-selected={option.isSelected ? true : undefined}
>
{option.icon && (
<Icon
@@ -167,14 +164,7 @@ export const DropdownMenu = ({
</Text>
</Box>
{option.isSelected && (
<Icon
role="img"
iconName="check"
$size="20px"
$theme="greyscale"
aria-label={t('Checked')}
aria-hidden="false"
/>
<Icon iconName="check" $size="20px" $theme="greyscale" />
)}
</BoxButton>
);
@@ -14,7 +14,6 @@ export const Icon = ({
}: IconProps) => {
return (
<Text
aria-hidden="true"
{...textProps}
className={clsx('--docs--icon-bg', textProps.className, {
'material-icons-filled': variant === 'filled',
@@ -1,48 +0,0 @@
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,15 +1,10 @@
import { Loader } from '@openfun/cunningham-react';
import Head from 'next/head';
import { PropsWithChildren, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { PropsWithChildren, useEffect } from 'react';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useAuthQuery } from '@/features/auth';
import {
useCustomTranslations,
useSynchronizedLanguage,
} from '@/features/language';
import { useLanguageSynchronizer } from '@/features/language/';
import { useAnalytics } from '@/libs';
import { CrispProvider, PostHogAnalytic } from '@/services';
import { useSentryStore } from '@/stores/useSentryStore';
@@ -18,35 +13,10 @@ 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 { 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]);
const { synchronizeLanguage } = useLanguageSynchronizer();
useEffect(() => {
if (!conf?.SENTRY_DSN) {
@@ -64,6 +34,10 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
setTheme(conf.FRONTEND_THEME);
}, [conf?.FRONTEND_THEME, setTheme]);
useEffect(() => {
void synchronizeLanguage();
}, [synchronizeLanguage]);
useEffect(() => {
if (!conf?.POSTHOG_KEY) {
return;
@@ -1,5 +1,4 @@
import { useQuery } from '@tanstack/react-query';
import { Resource } from 'i18next';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Theme } from '@/cunningham/';
@@ -8,10 +7,9 @@ import { PostHogConf } from '@/services';
interface ThemeCustomization {
footer?: FooterType;
translations?: Resource;
}
export interface ConfigResponse {
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,6 +178,7 @@ export const BlockNoteEditorVersion = ({
initialContent,
}: BlockNoteEditorVersionProps) => {
const readOnly = true;
const { setEditor } = useEditorStore();
const editor = useCreateBlockNote(
{
collaboration: {
@@ -192,6 +193,15 @@ export const BlockNoteEditorVersion = ({
},
[initialContent],
);
useHeadings(editor);
useEffect(() => {
setEditor(editor);
return () => {
setEditor(undefined);
};
}, [setEditor, editor]);
return (
<Box $css={cssEditor(readOnly)} className="--docs--editor-container">
@@ -5,6 +5,7 @@ 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,
@@ -25,6 +26,9 @@ 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) {
@@ -62,6 +66,7 @@ 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 { EmojiMartData } from '@emoji-mart/data';
import data from '@emoji-mart/data';
import Picker from '@emoji-mart/react';
import React from 'react';
import { useTranslation } from 'react-i18next';
@@ -6,15 +6,19 @@ 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) => {
@@ -23,8 +27,9 @@ 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,7 +10,52 @@ import { Box, BoxButton, Icon } from '@/components';
import { DocsBlockNoteEditor } from '../../types';
import { EmojiPicker } from '../EmojiPicker';
import InitEmojiCallout from './initEmojiCallout';
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',
];
export const CalloutBlock = createReactBlockSpec(
{
@@ -79,8 +124,8 @@ export const CalloutBlock = createReactBlockSpec(
{openEmojiPicker && (
<EmojiPicker
emojiData={InitEmojiCallout.emojidata}
categories={InitEmojiCallout.calloutCategories}
categories={calloutCategories}
custom={calloutCustom}
onClickOutside={onClickOutside}
onEmojiSelect={onEmojiSelect}
/>
@@ -1,76 +0,0 @@
/**
* "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?.onChange(() => {
editor?.onEditorContentChange(() => {
setHeadings(editor);
});
@@ -5,7 +5,6 @@ export const cssEditor = (readonly: boolean) => css`
& > .bn-container,
& .ProseMirror {
height: 100%;
padding-bottom: 2rem;
img.bn-visual-media[src*='-unsafe'] {
pointer-events: none;
@@ -73,7 +72,8 @@ export const cssEditor = (readonly: boolean) => css`
border-radius: var(--c--theme--spacings--3xs);
}
.bn-block[data-background-color] > .bn-block-content {
.bn-block-content[data-content-type='paragraph'],
.bn-block-content[data-content-type='heading'] {
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) => {
/**
* Break line in the editor are not rendered in the PDF
* Breakline 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) {
void options.onSuccess(data, variables, context);
options.onSuccess(data, variables, context);
}
},
onError: (error, variables, context) => {
if (options?.onError) {
void options.onError(error, variables, context);
options.onError(error, variables, context);
}
},
});
@@ -63,12 +63,12 @@ export const useDeleteDocAccess = (options?: UseDeleteDocAccessOptions) => {
queryKey: [KEY_LIST_USER],
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, context);
options.onSuccess(data, variables, context);
}
},
onError: (error, variables, context) => {
if (options?.onError) {
void options.onError(error, variables, context);
options.onError(error, variables, context);
}
},
});
@@ -58,12 +58,12 @@ export const useDeleteDocInvitation = (
queryKey: [KEY_LIST_DOC_INVITATIONS],
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, context);
options.onSuccess(data, variables, context);
}
},
onError: (error, variables, context) => {
if (options?.onError) {
void options.onError(error, variables, context);
options.onError(error, variables, context);
}
},
});
@@ -65,12 +65,12 @@ export const useUpdateDocAccess = (options?: UseUpdateDocAccessOptions) => {
queryKey: [KEY_LIST_DOC],
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, context);
options.onSuccess(data, variables, context);
}
},
onError: (error, variables, context) => {
if (options?.onError) {
void options.onError(error, variables, context);
options.onError(error, variables, context);
}
},
});
@@ -66,12 +66,12 @@ export const useUpdateDocInvitation = (
queryKey: [KEY_LIST_DOC_INVITATIONS],
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, context);
options.onSuccess(data, variables, context);
}
},
onError: (error, variables, context) => {
if (options?.onError) {
void options.onError(error, variables, context);
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, Icon } from '@/components';
import { Box, HorizontalSeparator } 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={<Icon iconName="add_link" />}
icon={<span className="material-icons">add_link</span>}
>
{t('Copy link')}
</Button>
@@ -13,7 +13,7 @@ export const useTranslatedShareSettings = () => {
const linkModeTranslations = {
[LinkRole.READER]: t('Reading'),
[LinkRole.EDITOR]: t('Editing'),
[LinkRole.EDITOR]: t('Edition'),
};
const linkReachChoices = {
@@ -40,7 +40,7 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
$align="center"
role="row"
$gap="20px"
$padding={{ vertical: '4xs', horizontal: isDesktop ? 'base' : 'xs' }}
$padding={{ vertical: '2xs', horizontal: isDesktop ? 'base' : 'xs' }}
$css={css`
cursor: pointer;
border-radius: 4px;
@@ -50,7 +50,6 @@ 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,33 +1,42 @@
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 { useAuthQuery } from '@/features/auth';
import {
getMatchingLocales,
useSynchronizedLanguage,
} from '@/features/language';
import { useLanguageSynchronizer } from './hooks/useLanguageSynchronizer';
import { getMatchingLocales } from './utils/locale';
export const LanguagePicker = () => {
const { t, i18n } = useTranslation();
const { data: conf } = useConfig();
const { data: user } = useAuthQuery();
const { changeLanguageSynchronized } = useSynchronizedLanguage();
const language = i18n.language;
const { synchronizeLanguage } = useLanguageSynchronizer();
const language = i18n.languages[0];
Settings.defaultLocale = language;
// Compute options for dropdown
const optionsPicker = useMemo(() => {
const backendOptions = conf?.LANGUAGES ?? [[language, language]];
return backendOptions.map(([backendLocale, backendLabel]) => {
return {
label: backendLabel,
isSelected: getMatchingLocales([backendLocale], [language]).length > 0,
callback: () => changeLanguageSynchronized(backendLocale, user),
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 { label, isSelected, callback };
});
}, [changeLanguageSynchronized, conf?.LANGUAGES, language, user]);
}, [conf, i18n, language, synchronizeLanguage]);
// Extract current language label for display
const currentLanguageLabel =
@@ -0,0 +1,45 @@
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 +0,0 @@
export * from './LanguagePicker';
@@ -1,2 +0,0 @@
export * from './useSynchronizedLanguage';
export * from './useCustomTranslations';
@@ -1,27 +0,0 @@
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,
};
};
@@ -0,0 +1,82 @@
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 };
};
@@ -1,71 +0,0 @@
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,3 +1,2 @@
export * from './hooks';
export * from './components';
export * from './utils';
export * from './hooks/useLanguageSynchronizer';
export * from './LanguagePicker';
@@ -1 +0,0 @@
export * from './locale';
@@ -112,7 +112,7 @@ export class ApiPlugin implements WorkboxPlugin {
};
/**
* When we get a network error.
* When we get an network error.
*/
handlerDidError: WorkboxPlugin['handlerDidError'] = async ({ request }) => {
if (!this.isFetchDidFailed) {
+29 -31
View File
@@ -4,38 +4,36 @@ import { initReactI18next } from 'react-i18next';
import resources from './translations.json';
// Add an initialization guard
let isInitialized = false;
export const availableFrontendLanguages: readonly string[] =
Object.keys(resources);
// 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',
},
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',
},
interpolation: {
escapeValue: false,
},
lowerCaseLng: true,
nsSeparator: false,
keySeparator: false,
})
.catch((e) => console.error('i18n initialization failed:', e));
}
},
interpolation: {
escapeValue: false,
},
preload: availableFrontendLanguages,
lowerCaseLng: true,
nsSeparator: false,
keySeparator: false,
})
.catch(() => {
throw new Error('i18n initialization failed');
});
export default i18next;
@@ -62,7 +62,6 @@
"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.",
@@ -254,7 +253,6 @@
"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.",
@@ -439,7 +437,6 @@
"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.",
@@ -628,7 +625,6 @@
"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.",
@@ -785,7 +781,6 @@
"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.",
@@ -957,7 +952,6 @@
"Anonymous": "Anonym",
"Beautify": "Försköna",
"Cancel": "Avbryt",
"Checked": "Markerad",
"Close the modal": "Stäng fönstret",
"Convert Markdown": "Konvertera Markdown",
"Correct": "Korrekt",
@@ -995,7 +989,6 @@
"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.",
@@ -1085,7 +1078,6 @@
"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,7 +26,6 @@ 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,6 +67,14 @@ 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;
@@ -134,21 +142,7 @@ const DocPage = ({ id }: DocProps) => {
);
}
return (
<>
<Head>
<title>
{doc.title} - {t('Docs')}
</title>
<meta
property="og:title"
content={`${doc.title} - ${t('Docs')}`}
key="title"
/>
</Head>
<DocEditor doc={doc} />
</>
);
return <DocEditor doc={doc} />;
};
const Page: NextPageWithLayout = () => {
@@ -1,52 +0,0 @@
/**
* @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);
},
};
+6 -5
View File
@@ -19,6 +19,7 @@
"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",
@@ -28,11 +29,11 @@
"server:test": "yarn COLLABORATION_SERVER run test"
},
"resolutions": {
"@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",
"@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",
"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.3",
"@tanstack/eslint-plugin-query": "5.78.0",
"@next/eslint-plugin-next": "15.3.2",
"@tanstack/eslint-plugin-query": "5.74.7",
"@typescript-eslint/eslint-plugin": "*",
"@typescript-eslint/parser": "*",
"eslint": "*",
"eslint-config-next": "15.3.3",
"eslint-config-next": "15.3.2",
"eslint-config-prettier": "10.1.5",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-jest": "28.12.0",
"eslint-plugin-jest": "28.11.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-playwright": "2.2.0",
"eslint-plugin-prettier": "5.4.1",
"eslint-plugin-testing-library": "7.4.0",
"eslint-plugin-prettier": "5.4.0",
"eslint-plugin-testing-library": "7.2.1",
"prettier": "3.5.3"
}
}
+3 -2
View File
@@ -1,10 +1,11 @@
import fs from 'fs';
import path from 'path';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import yargs from 'yargs/yargs';
// Get our args
const argv = yargs(process.argv).argv;
const argv = yargs(hideBin(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": "18.0.0"
"yargs": "17.7.2"
}
}
@@ -8,10 +8,11 @@
import fs from 'fs';
import path from 'path';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import yargs from 'yargs/yargs';
// Get our args
const argv = yargs(process.argv).argv;
const argv = yargs(hideBin(process.argv)).argv;
const { app, output, language } = argv;
const folderPath = './locales/' + app;
+4 -4
View File
@@ -13,13 +13,13 @@
"test": "jest"
},
"engines": {
"node": ">=22"
"node": ">=18"
},
"dependencies": {
"@blocknote/server-util": "0.31.1",
"@blocknote/server-util": "0.30.1",
"@hocuspocus/server": "2.15.2",
"@sentry/node": "9.26.0",
"@sentry/profiling-node": "9.26.0",
"@sentry/node": "9.19.0",
"@sentry/profiling-node": "9.19.0",
"axios": "1.9.0",
"cors": "2.8.5",
"express": "5.1.0",
+338 -1748
View File
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,4 @@
{
"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": "22"
"node": "16.15.1"
},
"repository": "https://github.com/numerique-gouv/impress",
"author": "DINUM",
+114
View File
@@ -0,0 +1,114 @@
# 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;
}
}