Compare commits

..

8 Commits

Author SHA1 Message Date
Nathan Panchout 25f0914a8b wip 2024-11-14 17:09:35 +01:00
Nathan Panchout 4a4953b344 wip 2024-11-14 14:39:43 +01:00
Nathan Panchout 8268b26d7a 📱(frontend) make left panel responsive
We modify the left panel so that when the value changes in the
useResponsiveStore it is displayed on the screen
2024-11-14 08:59:44 +01:00
Nathan Panchout 61c093cb54 📱(frontend) update header for the new responsive layout [FIRST_COMMIT_HEADER]
The new version of the responsive layout adds a burger menu to show
the left-panel  with a slide-in animation.
We move the logout and language change button in this responsive panel.
2024-11-14 08:57:40 +01:00
Nathan Panchout 7a072e902d (frontend) update tests
Some minor changes have been integrated into the list of documents.
The tests must therefore be adapted accordingly.
2024-11-13 17:33:25 +01:00
Nathan Panchout 7d14309032 💄(frontend) add left panel
In the new interface there is a new left panel. We implement it and add it
to the MainLayout
2024-11-13 17:33:25 +01:00
Nathan Panchout d523493fa9 💄(frontend) add cunningham tokens
In order to use the spaces and grays of the DSFR,
we update the cunningham.ts file
2024-11-13 17:33:25 +01:00
Nathan Panchout 9b33a1f464 (frontend) implement new UI
This branch is a transition branch to gradually merge the new UI.
2024-11-12 17:58:34 +01:00
135 changed files with 4677 additions and 7529 deletions
-6
View File
@@ -8,9 +8,6 @@ on:
- 'main'
tags:
- 'v*'
pull_request:
branches:
- 'main'
env:
DOCKER_USER: 1001:127
@@ -55,7 +52,6 @@ jobs:
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: 'docker.io/lasuite/impress-backend:${{ github.sha }}'
continue-on-error: true
-
name: Build and push
uses: docker/build-push-action@v6
@@ -106,7 +102,6 @@ jobs:
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
continue-on-error: true
-
name: Build and push
uses: docker/build-push-action@v6
@@ -158,7 +153,6 @@ jobs:
with:
docker-build-args: '-f src/frontend/Dockerfile --target y-provider'
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
continue-on-error: true
-
name: Build and push
uses: docker/build-push-action@v6
-22
View File
@@ -1,22 +0,0 @@
name: Helmfile lint
run-name: Helmfile lint
on:
pull_request:
branches:
- 'main'
jobs:
helmfile-lint:
runs-on: ubuntu-latest
container:
image: ghcr.io/helmfile/helmfile:latest
steps:
-
uses: numerique-gouv/action-helmfile-lint@main
with:
app-id: ${{ secrets.APP_ID }}
age-key: ${{ secrets.SOPS_PRIVATE }}
private-key: ${{ secrets.PRIVATE_KEY }}
helmfile-src: "src/helm"
repositories: "impress,secrets"
+2 -2
View File
@@ -99,7 +99,7 @@ jobs:
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --project='chromium'
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-chromium-report
@@ -133,7 +133,7 @@ jobs:
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --project=firefox --project=webkit
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-other-report
+2 -4
View File
@@ -107,9 +107,7 @@ jobs:
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.12.6"
- name: Upgrade pip and setuptools
run: pip install --upgrade pip setuptools
python-version: "3.10"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
@@ -201,7 +199,7 @@ jobs:
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.12.6"
python-version: "3.10"
- name: Install development dependencies
run: pip install --user .[dev]
+6 -25
View File
@@ -9,41 +9,24 @@ and this project adheres to
## [Unreleased]
## [1.8.1] - 2024-11-27
## Fixed
🐛(frontend) link not clickable and flickering firefox #457
## [1.8.0] - 2024-11-25
## Added
- 🌐(backend) add german translation #259
- 🌐(frontend) Add German translation #255
- ✨(frontend) Add a broadcast store #387
- ✨(backend) whitelist pod's IP address #443
- ✨(backend) config endpoint #425
- (frontend) config endpoint #424
- ✨(frontend) add sentry #424
- ✨(frontend) add crisp chatbot #450
- ✨(frontend) WIP: New ui
- 💄(frontend) Add left panel
- 💄(frontend) update header layout #421
## Changed
- 🚸(backend) improve users similarity search and sort results #391
- ♻️(frontend) simplify stores #402
- ✨(frontend) update $css Box props type to add styled components RuleSet #423
- ✅(CI) trivy continue on error #453
- 🌐(backend) add german translation #259
## Fixed
- 🔧(backend) fix logging for docker and make it configurable by envar #427
- 🦺(backend) add comma to sub regex #408
- 🐛(editor) collaborative user tag hidden when read only #385
- 🐛(frontend) users have view access when revoked #387
- 🐛(frontend) fix placeholder editable when double clicks #454
- 🐛(frontend) user have view access when revoked #387
## [1.7.0] - 2024-10-24
@@ -275,9 +258,7 @@ and this project adheres to
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.8.1...main
[v1.8.1]: https://github.com/numerique-gouv/impress/releases/v1.8.1
[v1.8.0]: https://github.com/numerique-gouv/impress/releases/v1.8.0
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.7.0...main
[v1.7.0]: https://github.com/numerique-gouv/impress/releases/v1.7.0
[v1.6.0]: https://github.com/numerique-gouv/impress/releases/v1.6.0
[1.5.1]: https://github.com/numerique-gouv/impress/releases/v1.5.1
-13
View File
@@ -4,12 +4,6 @@ DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly
DJANGO_SETTINGS_MODULE=impress.settings
DJANGO_SUPERUSER_PASSWORD=admin
# Logging
# Set to DEBUG level for dev only
LOGGING_LEVEL_HANDLERS_CONSOLE=INFO
LOGGING_LEVEL_LOGGERS_ROOT=INFO
LOGGING_LEVEL_LOGGERS_APP=INFO
# Python
PYTHONPATH=/app
@@ -27,7 +21,6 @@ STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStora
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=impress
AWS_S3_SECRET_ACCESS_KEY=password
MEDIA_BASE_URL=http://localhost:8083
# OIDC
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/certs
@@ -51,9 +44,3 @@ OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
AI_BASE_URL=https://openaiendpoint.com
AI_API_KEY=password
AI_MODEL=llama
# Collaboration
COLLABORATION_SERVER_URL=ws://localhost:4444
# Frontend
FRONTEND_THEME=dsfr
-30
View File
@@ -26,13 +26,11 @@ from rest_framework import (
mixins,
pagination,
status,
views,
viewsets,
)
from rest_framework import (
response as drf_response,
)
from rest_framework.permissions import AllowAny
from core import enums, models
from core.services.ai_services import AIService
@@ -888,31 +886,3 @@ class InvitationViewset(
invitation.document.email_invitation(
language, invitation.email, invitation.role, self.request.user
)
class ConfigView(views.APIView):
"""API ViewSet for sharing some public settings."""
permission_classes = [AllowAny]
def get(self, request):
"""
GET /api/v1.0/config/
Return a dictionary of public settings.
"""
array_settings = [
"COLLABORATION_SERVER_URL",
"CRISP_WEBSITE_ID",
"ENVIRONMENT",
"FRONTEND_THEME",
"MEDIA_BASE_URL",
"LANGUAGES",
"LANGUAGE_CODE",
"SENTRY_DSN",
]
dict_settings = {}
for setting in array_settings:
if hasattr(settings, setting):
dict_settings[setting] = getattr(settings, setting)
return drf_response.Response(dict_settings)
-45
View File
@@ -1,45 +0,0 @@
"""
Test config API endpoints in the Impress core app.
"""
from django.test import override_settings
import pytest
from rest_framework.status import (
HTTP_200_OK,
)
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
@override_settings(
COLLABORATION_SERVER_URL="http://testcollab/",
CRISP_WEBSITE_ID="123",
FRONTEND_THEME="test-theme",
MEDIA_BASE_URL="http://testserver/",
SENTRY_DSN="https://sentry.test/123",
)
@pytest.mark.parametrize("is_authenticated", [False, True])
def test_api_config(is_authenticated):
"""Anonymous users should be allowed to get the configuration."""
client = APIClient()
if is_authenticated:
user = factories.UserFactory()
client.force_login(user)
response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
assert response.json() == {
"COLLABORATION_SERVER_URL": "http://testcollab/",
"CRISP_WEBSITE_ID": "123",
"ENVIRONMENT": "test",
"FRONTEND_THEME": "test-theme",
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"], ["de-de", "German"]],
"LANGUAGE_CODE": "en-us",
"MEDIA_BASE_URL": "http://testserver/",
"SENTRY_DSN": "https://sentry.test/123",
}
-1
View File
@@ -55,5 +55,4 @@ urlpatterns = [
]
),
),
path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()),
]
+32 -69
View File
@@ -10,9 +10,8 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.1/ref/settings/
"""
import json
import os
import tomllib
from socket import gethostbyname, gethostname
from django.utils.translation import gettext_lazy as _
@@ -28,12 +27,19 @@ DATA_DIR = os.path.join("/", "data")
def get_release():
"""
Get the current release of the application
By release, we mean the release from the version.json file à la Mozilla [1]
(if any). If this file has not been found, it defaults to "NA".
[1]
https://github.com/mozilla-services/Dockerflow/blob/master/docs/version_object.md
"""
# Try to get the current release from the version.json file generated by the
# CI during the Docker image build
try:
with open(os.path.join(BASE_DIR, "pyproject.toml"), "rb") as f:
pyproject_data = tomllib.load(f)
return pyproject_data["project"]["version"]
except (FileNotFoundError, KeyError):
with open(os.path.join(BASE_DIR, "version.json"), encoding="utf8") as version:
return json.load(version)["version"]
except FileNotFoundError:
return "NA" # Default: not available
@@ -50,7 +56,7 @@ class Base(Configuration):
You may also want to override default configuration by setting the following environment
variables:
* SENTRY_DSN
* DJANGO_SENTRY_DSN
* DB_NAME
* DB_HOST
* DB_PASSWORD
@@ -98,9 +104,6 @@ class Base(Configuration):
STATIC_ROOT = os.path.join(DATA_DIR, "static")
MEDIA_URL = "/media/"
MEDIA_ROOT = os.path.join(DATA_DIR, "media")
MEDIA_BASE_URL = values.Value(
None, environ_name="MEDIA_BASE_URL", environ_prefix=None
)
SITE_ID = 1
@@ -369,22 +372,7 @@ class Base(Configuration):
CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([])
# Sentry
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN", environ_prefix=None)
# Collaboration
COLLABORATION_SERVER_URL = values.Value(
None, environ_name="COLLABORATION_SERVER_URL", environ_prefix=None
)
# Frontend
FRONTEND_THEME = values.Value(
None, environ_name="FRONTEND_THEME", environ_prefix=None
)
# Crisp
CRISP_WEBSITE_ID = values.Value(
None, environ_name="CRISP_WEBSITE_ID", environ_prefix=None
)
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN")
# Easy thumbnails
THUMBNAIL_EXTENSION = "webp"
@@ -494,42 +482,6 @@ class Base(Configuration):
environ_prefix=None,
)
# Logging
# We want to make it easy to log to console but by default we log production
# to Sentry and don't want to log to console.
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
"level": values.Value(
"ERROR",
environ_name="LOGGING_LEVEL_HANDLERS_CONSOLE",
environ_prefix=None,
),
},
},
# Override root logger to send it to console
"root": {
"handlers": ["console"],
"level": values.Value(
"INFO", environ_name="LOGGING_LEVEL_LOGGERS_ROOT", environ_prefix=None
),
},
"loggers": {
"core": {
"handlers": ["console"],
"level": values.Value(
"INFO",
environ_name="LOGGING_LEVEL_LOGGERS_APP",
environ_prefix=None,
),
"propagate": False,
},
},
}
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
@@ -625,6 +577,23 @@ class Development(Base):
class Test(Base):
"""Test environment settings"""
LOGGING = values.DictValue(
{
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
},
},
"loggers": {
"impress": {
"handlers": ["console"],
"level": "DEBUG",
},
},
}
)
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.MD5PasswordHasher",
]
@@ -655,13 +624,7 @@ class Production(Base):
"""
# Security
# Add allowed host from environment variables.
# The machine hostname is added by default,
# it makes the application pingable by a load balancer on the same machine by example
ALLOWED_HOSTS = [
*values.ListValue([], environ_name="ALLOWED_HOSTS"),
gethostbyname(gethostname()),
]
ALLOWED_HOSTS = values.ListValue(None)
CSRF_TRUSTED_ORIGINS = values.ListValue([])
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
+3 -4
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "1.8.1"
version = "1.7.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -17,13 +17,13 @@ classifiers = [
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.10",
]
description = "An application to print markdown to pdf from a set of managed templates."
keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.12"
requires-python = ">=3.10"
dependencies = [
"boto3==1.35.44",
"Brotli==1.1.0",
@@ -127,7 +127,6 @@ select = [
[tool.ruff.lint.isort]
section-order = ["future","standard-library","django","third-party","impress","first-party","local-folder"]
sections = { impress=["core"], django=["django"] }
extra-standard-library = ["tomllib"]
[tool.ruff.lint.per-file-ignores]
"**/tests/*" = ["S", "SLF"]
+9
View File
@@ -61,9 +61,18 @@ FROM impress AS impress-builder
WORKDIR /home/frontend/apps/impress
ARG FRONTEND_THEME
ENV NEXT_PUBLIC_THEME=${FRONTEND_THEME}
ARG Y_PROVIDER_URL
ENV NEXT_PUBLIC_Y_PROVIDER_URL=${Y_PROVIDER_URL}
ARG API_ORIGIN
ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN}
ARG MEDIA_URL
ENV NEXT_PUBLIC_MEDIA_URL=${MEDIA_URL}
ARG SW_DEACTIVATED
ENV NEXT_PUBLIC_SW_DEACTIVATED=${SW_DEACTIVATED}
@@ -36,7 +36,7 @@ export const createDoc = async (
await page
.getByRole('button', {
name: 'Create a new document',
name: 'New doc',
})
.click();
@@ -1,160 +0,0 @@
import path from 'path';
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
const config = {
CRISP_WEBSITE_ID: null,
COLLABORATION_SERVER_URL: 'ws://localhost:4444',
ENVIRONMENT: 'development',
FRONTEND_THEME: 'dsfr',
MEDIA_BASE_URL: 'http://localhost:8083',
LANGUAGES: [
['en-us', 'English'],
['fr-fr', 'French'],
['de-de', 'German'],
],
LANGUAGE_CODE: 'en-us',
SENTRY_DSN: null,
};
test.describe('Config', () => {
test('it checks the config api is called', async ({ page }) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/config/') && response.status() === 200,
);
await page.goto('/');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
expect(await response.json()).toStrictEqual(config);
});
test('it checks that sentry is trying to init from config endpoint', async ({
page,
}) => {
await page.route('**/api/v1.0/config/', async (route) => {
const request = route.request();
if (request.method().includes('GET')) {
await route.fulfill({
json: {
...config,
SENTRY_DSN: 'https://sentry.io/123',
},
});
} else {
await route.continue();
}
});
const invalidMsg = 'Invalid Sentry Dsn: https://sentry.io/123';
const consoleMessage = page.waitForEvent('console', {
timeout: 5000,
predicate: (msg) => msg.text().includes(invalidMsg),
});
await page.goto('/');
expect((await consoleMessage).text()).toContain(invalidMsg);
});
test('it checks that theme is configured from config endpoint', async ({
page,
}) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/config/') && response.status() === 200,
);
await page.goto('/');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const jsonResponse = await response.json();
expect(jsonResponse.FRONTEND_THEME).toStrictEqual('dsfr');
const footer = page.locator('footer').first();
// alt 'Gouvernement Logo' comes from the theme
await expect(footer.getByAltText('Gouvernement Logo')).toBeVisible();
});
test('it checks that media server is configured from config endpoint', async ({
page,
browserName,
}) => {
await page.goto('/');
await createDoc(page, 'doc-media', browserName, 1);
const fileChooserPromise = page.waitForEvent('filechooser');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Resizable image with caption').click();
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(
path.join(__dirname, 'assets/logo-suite-numerique.png'),
);
const image = page.getByRole('img', { name: 'logo-suite-numerique.png' });
await expect(image).toBeVisible();
// Check src of image
expect(await image.getAttribute('src')).toMatch(
/http:\/\/localhost:8083\/media\/.*\/attachments\/.*.png/,
);
});
test('it checks that collaboration server is configured from config endpoint', async ({
page,
browserName,
}) => {
const webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket.url().includes('ws://localhost:4444/');
});
await page.goto('/');
const randomDoc = await createDoc(
page,
'doc-collaboration',
browserName,
1,
);
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
const webSocket = await webSocketPromise;
expect(webSocket.url()).toContain('ws://localhost:4444/');
});
test('it checks that Crisp is trying to init from config endpoint', async ({
page,
}) => {
await page.route('**/api/v1.0/config/', async (route) => {
const request = route.request();
if (request.method().includes('GET')) {
await route.fulfill({
json: {
...config,
CRISP_WEBSITE_ID: '1234',
},
});
} else {
await route.continue();
}
});
await page.goto('/');
await expect(
page.locator('#crisp-chatbox').getByText('Invalid website'),
).toBeVisible();
});
});
@@ -86,7 +86,7 @@ test.describe('Doc Header', () => {
test('it updates the title doc from editor heading', async ({ page }) => {
await page
.getByRole('button', {
name: 'Create a new document',
name: 'New doc',
})
.click();
@@ -159,9 +159,7 @@ test.describe('Doc Header', () => {
page.getByText('The document has been deleted.'),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Create a new document' }),
).toBeVisible();
await expect(page.getByRole('button', { name: 'New do' })).toBeVisible();
const row = page
.getByLabel('Datagrid of the documents page 1')
@@ -414,7 +412,7 @@ test.describe('Doc Header', () => {
// create page and navigate to it
await page
.getByRole('button', {
name: 'Create a new document',
name: 'New doc',
})
.click();
@@ -449,7 +447,7 @@ test.describe('Doc Header', () => {
// create page and navigate to it
await page
.getByRole('button', {
name: 'Create a new document',
name: 'New doc',
})
.click();
@@ -9,7 +9,7 @@ test.describe('Doc Routing', () => {
test('Check the presence of the meta tag noindex', async ({ page }) => {
const buttonCreateHomepage = page.getByRole('button', {
name: 'Create a new document',
name: 'New doc',
});
await expect(buttonCreateHomepage).toBeVisible();
@@ -27,7 +27,7 @@ test.describe('Doc Routing', () => {
await expect(page).toHaveURL('/');
const buttonCreateHomepage = page.getByRole('button', {
name: 'Create a new document',
name: 'New doc',
});
await expect(buttonCreateHomepage).toBeVisible();
@@ -36,6 +36,7 @@ test.describe('Doc Table Content', () => {
await page.getByRole('button', { name: 'Strike' }).click();
await page.locator('.bn-block-outer').first().click();
await editor.click();
await page.locator('.bn-block-outer').last().click();
// Create space to fill the viewport
@@ -1,77 +0,0 @@
import { expect, test } from '@playwright/test';
import { goToGridDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Footer', () => {
test('checks all the elements are visible', async ({ page }) => {
const footer = page.locator('footer').first();
await expect(footer.getByAltText('Gouvernement Logo')).toBeVisible();
await expect(
footer.getByRole('link', { name: 'legifrance.gouv.fr' }),
).toBeVisible();
await expect(
footer.getByRole('link', { name: 'info.gouv.fr' }),
).toBeVisible();
await expect(
footer.getByRole('link', { name: 'service-public.fr' }),
).toBeVisible();
await expect(
footer.getByRole('link', { name: 'data.gouv.fr' }),
).toBeVisible();
await expect(
footer.getByRole('link', { name: 'Legal Notice' }),
).toBeVisible();
await expect(
footer.getByRole('link', { name: 'Personal data and cookies' }),
).toBeVisible();
await expect(
footer.getByRole('link', { name: 'Accessibility' }),
).toBeVisible();
await expect(
footer.getByText(
'Unless otherwise stated, all content on this site is under licence',
),
).toBeVisible();
});
test('checks footer is not visible on doc editor', async ({ page }) => {
await expect(page.locator('footer')).toBeVisible();
await goToGridDoc(page);
await expect(page.locator('footer')).toBeHidden();
});
const legalPages = [
{ name: 'Legal Notice', url: '/legal-notice/' },
{ name: 'Personal data and cookies', url: '/personal-data-cookies/' },
{ name: 'Accessibility', url: '/accessibility/' },
];
for (const { name, url } of legalPages) {
test(`checks ${name} page`, async ({ page }) => {
const footer = page.locator('footer').first();
await footer.getByRole('link', { name }).click();
await expect(
page
.getByRole('heading', {
name,
})
.first(),
).toBeVisible();
await expect(page).toHaveURL(url);
});
}
});
@@ -75,29 +75,13 @@ test.describe('Header mobile', () => {
test('it checks the header when mobile', async ({ page }) => {
const header = page.locator('header').first();
await expect(header.getByLabel('Open the header menu')).toBeVisible();
await expect(
header.getByRole('button', {
name: 'Les services de La Suite numérique',
}),
header.getByRole('link', { name: 'Docs Logo Docs' }),
).toBeVisible();
await expect(
page.getByRole('button', {
name: 'Logout',
}),
).toBeHidden();
await expect(page.getByText('English')).toBeHidden();
await header.getByLabel('Open the header menu').click();
await expect(
page.getByRole('button', {
name: 'Logout',
}),
header.getByRole('button', { name: 'Les services de La Suite numé' }),
).toBeVisible();
await expect(page.getByText('English')).toBeVisible();
});
});
@@ -6,11 +6,7 @@ test.beforeEach(async ({ page }) => {
test.describe('Language', () => {
test('checks the language picker', async ({ page }) => {
await expect(
page.getByRole('button', {
name: 'Create a new document',
}),
).toBeVisible();
await expect(page.getByLabel('Logout')).toBeVisible();
const header = page.locator('header').first();
await header.getByRole('combobox').getByText('English').click();
@@ -19,11 +15,7 @@ test.describe('Language', () => {
header.getByRole('combobox').getByText('Français'),
).toBeVisible();
await expect(
page.getByRole('button', {
name: 'Créer un nouveau document',
}),
).toBeVisible();
await expect(page.getByLabel('Se déconnecter')).toBeVisible();
await header.getByRole('combobox').getByText('Français').click();
await header.getByRole('option', { name: 'Deutsch' }).click();
@@ -31,11 +23,7 @@ test.describe('Language', () => {
header.getByRole('combobox').getByText('Deutsch'),
).toBeVisible();
await expect(
page.getByRole('button', {
name: 'Neues Dokument erstellen',
}),
).toBeVisible();
await expect(page.getByLabel('Abmelden')).toBeVisible();
});
test('checks that backend uses the same language as the frontend', async ({
@@ -0,0 +1,48 @@
import { expect, test } from '@playwright/test';
test.describe('Left panel desktop', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('checks all the elements are visible', async ({ page }) => {
await expect(page.getByTestId('left-panel-desktop')).toBeVisible();
await expect(page.getByTestId('left-panel-mobile')).toBeHidden();
await expect(page.getByRole('button', { name: 'house' })).toBeVisible();
await expect(page.getByRole('button', { name: 'New doc' })).toBeVisible();
});
});
test.describe('Left panel mobile', () => {
test.use({ viewport: { width: 500, height: 1200 } });
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('checks all the desktop elements are hidden and all mobile elements are visible', async ({
page,
}) => {
await expect(page.getByTestId('left-panel-desktop')).toBeHidden();
await expect(page.getByTestId('left-panel-mobile')).not.toBeInViewport();
const header = page.locator('header').first();
const homeButton = page.getByRole('button', { name: 'house' });
const newDocButton = page.getByRole('button', { name: 'New doc' });
const languageButton = page.getByRole('combobox', { name: 'Language' });
const logoutButton = page.getByRole('button', { name: 'Logout' });
await expect(homeButton).not.toBeInViewport();
await expect(newDocButton).not.toBeInViewport();
await expect(languageButton).not.toBeInViewport();
await expect(logoutButton).not.toBeInViewport();
await header.getByLabel('Open the header menu').click();
await expect(page.getByTestId('left-panel-mobile')).toBeInViewport();
await expect(homeButton).toBeInViewport();
await expect(newDocButton).toBeInViewport();
await expect(languageButton).toBeInViewport();
await expect(logoutButton).toBeInViewport();
});
});
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "1.8.1",
"version": "1.7.0",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
@@ -12,7 +12,7 @@
"test:ui::chromium": "yarn test:ui --project=chromium"
},
"devDependencies": {
"@playwright/test": "1.49.0",
"@playwright/test": "1.48.1",
"@types/node": "*",
"@types/pdf-parse": "1.1.4",
"eslint-config-impress": "*",
+3
View File
@@ -1,2 +1,5 @@
NEXT_PUBLIC_API_ORIGIN=
NEXT_PUBLIC_Y_PROVIDER_URL=
NEXT_PUBLIC_MEDIA_URL=
NEXT_PUBLIC_THEME=dsfr
NEXT_PUBLIC_SW_DEACTIVATED=
@@ -1,2 +1,4 @@
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
NEXT_PUBLIC_Y_PROVIDER_URL=ws://localhost:4444
NEXT_PUBLIC_MEDIA_URL=http://localhost:8083
NEXT_PUBLIC_SW_DEACTIVATED=true
+1
View File
@@ -1 +1,2 @@
NEXT_PUBLIC_API_ORIGIN=http://test.jest
NEXT_PUBLIC_THEME=test-theme
+85 -26
View File
@@ -3,24 +3,30 @@ const config = {
default: {
theme: {
colors: {
'card-border': '#ededed',
'card-border': '#E5E5E5',
'primary-bg': '#FAFAFA',
'primary-100': '#EDF5FA',
'primary-150': '#E5EEFA',
'info-150': '#E5EEFA',
'greyscale-000': '#fff',
'greyscale-1000': '#161616',
},
font: {
sizes: {
ml: '0.938rem',
xl: '1.50rem',
t: '0.6875rem',
s: '0.75rem',
h1: '2.2rem',
h2: '1.7rem',
h3: '1.37rem',
h4: '1.15rem',
h5: '1rem',
h6: '0.87rem',
xl: '20px',
lg: '18px',
md: '16px',
sm: '14px',
xs: '12px',
h1: '32px',
h2: '28px',
h3: '24px',
h4: '22px',
h5: '20px',
h6: '18px',
},
weights: {
thin: 100,
@@ -34,6 +40,21 @@ const config = {
auto: 'auto',
bx: '2.2rem',
full: '100%',
'050V': '2px',
'100V': '4px',
'150V': '6px',
'100W': '8px',
'300V': '12px',
'200W': '16px',
'300W': '24px',
'400W': '32px',
'500W': '40px',
'600W': '48px',
'700W': '56px',
'800W': '64px',
'900W': '72px',
'1200W': '96px',
'1500W': '120px',
},
breakpoints: {
xxs: '320px',
@@ -46,6 +67,11 @@ const config = {
alt: '',
},
},
global: {
hover: {
'greyscale-100': '#055fd214',
},
},
components: {
datagrid: {
header: {
@@ -104,7 +130,7 @@ const config = {
focus: 'var(--c--components--forms-select--border-radius)',
},
'font-size': 'var(--c--theme--font--sizes--ml)',
'menu-background-color': '#ffffff',
'menu-background-color': '#fff',
'item-background-color': {
hover: 'var(--c--theme--colors--primary-300)',
},
@@ -126,7 +152,7 @@ const config = {
},
},
modal: {
'background-color': '#ffffff',
'background-color': '#fff',
},
button: {
'border-radius': {
@@ -196,6 +222,23 @@ const config = {
},
dsfr: {
theme: {
spacings: {
'050V': '2px',
'100V': '4px',
'150V': '6px',
'100W': '8px',
'300V': '12px',
'200W': '16px',
'300W': '24px',
'400W': '32px',
'500W': '40px',
'600W': '48px',
'700W': '56px',
'800W': '64px',
'900W': '72px',
'1200W': '96px',
'1500W': '120px',
},
colors: {
'card-border': '#ededed',
'primary-text': '#000091',
@@ -209,7 +252,7 @@ const config = {
'primary-700': '#272747',
'primary-800': '#21213f',
'primary-900': '#1c1a36',
'secondary-text': '#FFFFFF',
'secondary-text': '#fff',
'secondary-100': '#fee9ea',
'secondary-200': '#fedfdf',
'secondary-300': '#fdbfbf',
@@ -220,16 +263,16 @@ const config = {
'secondary-800': '#341f1f',
'secondary-900': '#2b1919',
'greyscale-text': '#303C4B',
'greyscale-000': '#f6f6f6',
'greyscale-100': '#eeeeee',
'greyscale-200': '#e5e5e5',
'greyscale-300': '#e1e1e1',
'greyscale-400': '#dddddd',
'greyscale-500': '#cecece',
'greyscale-600': '#7b7b7b',
'greyscale-700': '#666666',
'greyscale-800': '#2a2a2a',
'greyscale-900': '#1e1e1e',
'greyscale-000': '#fff',
'greyscale-050': '#F6F6F6',
'greyscale-100': '#eee',
'greyscale-200': '#E5E5E5',
'greyscale-250': '#ddd',
'greyscale-300': '#CECECE',
'greyscale-400': '#929292',
'greyscale-500': '#666',
'greyscale-700': '#3A3A3A',
'greyscale-1000': '#161616',
'success-text': '#1f8d49',
'success-100': '#dffee6',
'success-200': '#b8fec9',
@@ -276,6 +319,22 @@ const config = {
accent: 'Marianne',
base: 'Marianne',
},
size: {
ml: '0.938rem',
t: '0.6875rem',
s: '0.75rem',
xl: '20px',
lg: '18px',
md: '16px',
sm: '14px',
xs: '12px',
h1: '32px',
h2: '28px',
h3: '24px',
h4: '22px',
h5: '20px',
h6: '18px',
},
},
logo: {
src: '/assets/logo-gouv.svg',
@@ -297,9 +356,9 @@ const config = {
'color-hover': '#1212ff',
'color-active': '#2323ff',
},
color: '#ffffff',
'color-hover': '#ffffff',
'color-active': '#ffffff',
color: '#fff',
'color-hover': '#fff',
'color-active': '#fff',
},
'primary-text': {
background: {
@@ -363,7 +422,7 @@ const config = {
},
'forms-input': {
'border-radius': '4px',
'background-color': '#ffffff',
'background-color': '#fff',
'border-color': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
'value-color': 'var(--c--theme--colors--primary-text)',
@@ -381,7 +440,7 @@ const config = {
'item-font-size': '14px',
'border-radius': '4px',
'border-radius-hover': '4px',
'background-color': '#ffffff',
'background-color': '#fff',
'border-color': 'var(--c--theme--colors--primary-text)',
'border-color-hover': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
+16 -17
View File
@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "1.8.1",
"version": "1.7.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -19,39 +19,38 @@
"@blocknote/mantine": "*",
"@blocknote/react": "*",
"@gouvfr-lasuite/integration": "1.0.2",
"@hocuspocus/provider": "2.14.0",
"@hocuspocus/provider": "2.13.7",
"@openfun/cunningham-react": "2.9.4",
"@sentry/nextjs": "8.40.0",
"@tanstack/react-query": "5.61.3",
"crisp-sdk-web": "1.0.25",
"i18next": "24.0.0",
"@tanstack/react-query": "5.59.15",
"i18next": "23.16.2",
"i18next-browser-languagedetector": "8.0.0",
"idb": "8.0.0",
"lodash": "4.17.21",
"luxon": "3.5.0",
"next": "15.0.3",
"next": "14.2.15",
"react": "*",
"react-aria-components": "1.5.0",
"react-aria-components": "1.4.1",
"react-dom": "*",
"react-i18next": "15.1.1",
"react-select": "5.8.3",
"react-i18next": "15.0.3",
"react-select": "5.8.1",
"react-intersection-observer": "9.13.1",
"styled-components": "6.1.13",
"y-protocols": "1.0.6",
"yjs": "*",
"zustand": "5.0.1"
"zustand": "5.0.0"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.61.3",
"@tanstack/react-query-devtools": "5.59.15",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/jest-dom": "6.6.2",
"@testing-library/react": "16.0.1",
"@testing-library/user-event": "14.5.2",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.13",
"@types/jest": "29.5.13",
"@types/lodash": "4.17.12",
"@types/luxon": "3.4.2",
"@types/node": "*",
"@types/react": "18.3.12",
"@types/react": "18.3.11",
"@types/react-dom": "*",
"cross-env": "*",
"dotenv": "16.4.5",
@@ -65,7 +64,7 @@
"stylelint-config-standard": "36.0.1",
"stylelint-prettier": "5.0.2",
"typescript": "*",
"webpack": "5.96.1",
"webpack": "5.95.0",
"workbox-webpack-plugin": "7.1.0"
}
}
@@ -1,32 +0,0 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { AppWrapper } from '@/tests/utils';
import Page from '../pages';
jest.mock('next/router', () => ({
useRouter() {
return {
push: jest.fn(),
};
},
}));
jest.mock('@sentry/nextjs', () => ({
captureException: jest.fn(),
captureMessage: jest.fn(),
setUser: jest.fn(),
}));
describe('Page', () => {
it('checks Page rendering', () => {
render(<Page />, { wrapper: AppWrapper });
expect(
screen.getByRole('button', {
name: /Create a new document/i,
}),
).toBeInTheDocument();
});
});
@@ -1,6 +0,0 @@
export const backendUrl = () =>
process.env.NEXT_PUBLIC_API_ORIGIN ||
(typeof window !== 'undefined' ? window.location.origin : '');
export const baseApiUrl = (apiVersion: string = '1.0') =>
`${backendUrl()}/api/v${apiVersion}/`;
@@ -1,4 +1,5 @@
import { baseApiUrl } from './config';
import { baseApiUrl } from '@/core';
import { getCSRFToken } from './utils';
interface FetchAPIInit extends RequestInit {
@@ -1,5 +1,4 @@
export * from './APIError';
export * from './config';
export * from './fetchApi';
export * from './helpers';
export * from './types';
@@ -45,6 +45,7 @@ export type BoxType = ComponentPropsWithRef<typeof Box>;
export const Box = styled('div')<BoxProps>`
display: flex;
flex-direction: column;
${({ $align }) => $align && `align-items: ${$align};`}
${({ $background }) => $background && `background: ${$background};`}
${({ $color }) => $color && `color: ${$color};`}
@@ -1,5 +1,4 @@
import { ComponentPropsWithRef, forwardRef } from 'react';
import { css } from 'styled-components';
import { Box, BoxType } from './Box';
@@ -27,7 +26,7 @@ const BoxButton = forwardRef<HTMLDivElement, BoxType>(
$background="none"
$margin="none"
$padding="none"
$css={css`
$css={`
cursor: pointer;
border: none;
outline: none;
@@ -1,5 +1,4 @@
import { PropsWithChildren } from 'react';
import { css } from 'styled-components';
import { useCunninghamTheme } from '@/cunningham';
@@ -16,8 +15,8 @@ export const Card = ({
<Box
$background="white"
$radius="4px"
$css={css`
box-shadow: 2px 2px 5px ${colorsTokens()['greyscale-300']};
$css={`
border: 1px solid ${colorsTokens()['card-border']};
${$css}
`}
@@ -1,9 +1,4 @@
import React, {
PropsWithChildren,
ReactNode,
useEffect,
useState,
} from 'react';
import { PropsWithChildren, ReactNode, useEffect, useState } from 'react';
import { Button, DialogTrigger, Popover } from 'react-aria-components';
import styled from 'styled-components';
@@ -29,7 +24,7 @@ const StyledButton = styled(Button)`
text-wrap: nowrap;
`;
interface DropButtonProps {
export interface DropButtonProps {
button: ReactNode;
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
@@ -1,6 +1,14 @@
import { Text, TextType } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
type Props = {
iconName: string;
className?: string;
};
export const Icon = ({ iconName, className }: Props) => {
return <span className={`material-icons ${className}`}>{iconName}</span>;
};
interface IconBGProps extends TextType {
iconName: string;
}
@@ -23,6 +23,7 @@ export interface TextProps extends BoxProps {
$weight?: CSSProperties['fontWeight'];
$textAlign?: CSSProperties['textAlign'];
$size?: TextSizes | (string & {});
$theme?:
| 'primary'
| 'secondary'
@@ -31,6 +32,7 @@ export interface TextProps extends BoxProps {
| 'warning'
| 'danger'
| 'greyscale';
$variation?:
| 'text'
| '100'
@@ -41,7 +43,8 @@ export interface TextProps extends BoxProps {
| '600'
| '700'
| '800'
| '900';
| '900'
| '1000';
}
export type TextType = ComponentPropsWithRef<typeof Text>;
@@ -63,14 +66,16 @@ export const TextStyled = styled(Box)<TextProps>`
const Text = forwardRef<HTMLElement, ComponentPropsWithRef<typeof TextStyled>>(
({ className, $isMaterialIcon, ...props }, ref) => {
return (
<TextStyled
ref={ref}
as="span"
$theme="greyscale"
$variation="text"
className={`${className || ''}${$isMaterialIcon ? ' material-icons' : ''}`}
{...props}
/>
<>
<TextStyled
ref={ref}
as="span"
$theme={props.$theme ?? 'greyscale'}
$variation={props.$variation ?? 'text'}
className={`${className || ''}${$isMaterialIcon ? ' material-icons' : ''}`}
{...props}
/>
</>
);
},
);
@@ -0,0 +1,87 @@
import { PropsWithChildren, useMemo } from 'react';
import { DropButton, DropButtonProps } from '@/components';
import { Icon } from '@/components/Icon';
import styles from './dropdown-menu.module.scss';
export type DropdownMenuOption = {
icon?: string;
label: string;
callback?: () => void | Promise<unknown>;
danger?: boolean;
show?: boolean;
};
export type DropdownMenuProps = Omit<DropButtonProps, 'button'> & {
options: DropdownMenuOption[];
showArrow?: boolean;
arrowClassname?: string;
};
export const DropdownMenu = ({
options,
children,
showArrow = false,
arrowClassname,
...dropButtonProps
}: PropsWithChildren<DropdownMenuProps>) => {
const showDropdown = useMemo(() => {
let show = false;
options.forEach((option) => {
show = show || (option.show !== undefined ? option.show : true);
});
return show;
}, [options]);
const getButton = () => {
if (!showArrow) {
return children;
}
return (
<div className={styles.withArrowContainer}>
<div>{children}</div>
<Icon
className={arrowClassname ?? 'clr-primary-600'}
iconName={
dropButtonProps.isOpen ? 'arrow_drop_up' : 'arrow_drop_down'
}
/>
</div>
);
};
if (!showDropdown) {
return;
}
return (
<DropButton {...dropButtonProps} button={getButton()}>
<div className={styles.listOption}>
{options.map((option) => {
if (option.show !== undefined && !option.show) {
return;
}
return (
<button
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
dropButtonProps.onOpenChange?.(false);
void option.callback?.();
}}
key={option.label}
className={styles.item}
>
{option.icon && (
<Icon className={styles.itemIcon} iconName={option.icon} />
)}
{option.label}
</button>
);
})}
</div>
</DropButton>
);
};
@@ -0,0 +1,42 @@
.simpleContent {
display: flex;
align-items: center;
gap: var(--c--theme--spacings--st);
}
.listOption {
display: flex;
flex-direction: column;
.item:not(:last-child) {
border-bottom: 1px solid var(--c--theme--colors--greyscale-200);
}
}
.item {
display: flex;
align-items: center;
gap: var(--c--theme--spacings--200W);
border: none;
background-color: white;
font-size: var(--c--theme--font--sizes--sm);
color: var(--c--theme--colors--primary-600);
font-weight: 500;
padding: var(--c--theme--spacings--100W) var(--c--theme--spacings--200W);
width: 100%;
cursor: pointer;
user-select: none;
&:hover {
background-color: var(--c--theme--colors--greyscale-050);
}
.itemIcon {
font-size: 24px;
}
}
.withArrowContainer {
display: flex;
align-items: center;
gap: 5px;
}
@@ -0,0 +1,14 @@
import { useState } from 'react';
export const useDropdownMenu = () => {
const [isOpen, setIsOpen] = useState(false);
const onOpenChange = (isOpen: boolean) => {
setIsOpen(isOpen);
};
return {
isOpen,
onOpenChange,
};
};
@@ -0,0 +1,34 @@
import { useCunninghamTheme } from '@/cunningham';
import { Box } from '../Box';
export enum SeparatorVariant {
LIGHT = 'light',
DARK = 'dark',
}
type Props = {
variant?: SeparatorVariant;
};
export const HorizontalSeparator = ({
variant = SeparatorVariant.LIGHT,
}: Props) => {
const { colorsTokens, themeTokens } = useCunninghamTheme();
const colors = colorsTokens();
const spacings = themeTokens().spacings;
return (
<Box
$css={`
width: 100%;
height: 1px;
margin: ${spacings?.['300W'] ?? '0.5rem'} 0 ;
background-color: ${
variant === SeparatorVariant.LIGHT
? colors['greyscale-200']
: colors['greyscale-800']
};
`}
/>
);
};
@@ -0,0 +1,29 @@
import { PropsWithChildren } from 'react';
import { useCunninghamTheme } from '@/cunningham';
import { Box } from '../Box';
type Props = {
showSeparator?: boolean;
};
export const SeparatedSection = ({
showSeparator = true,
children,
}: PropsWithChildren<Props>) => {
const theme = useCunninghamTheme();
const colors = theme.colorsTokens();
return (
<Box
className="toto"
$padding={{ vertical: '100V' }}
$css={`
padding: 12px 0;
${showSeparator ? `border-bottom: 1px solid ${(colors?.['greyscale-200'] as string) ?? '#E5E5E5'};` : ''}
`}
>
{children}
</Box>
);
};
@@ -7,7 +7,6 @@ import '@/i18n/initI18n';
import { useResponsiveStore } from '@/stores/';
import { Auth } from './auth/';
import { ConfigProvider } from './config/';
/**
* QueryClient:
@@ -40,9 +39,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<CunninghamProvider theme={theme}>
<ConfigProvider>
<Auth>{children}</Auth>
</ConfigProvider>
<Auth>{children}</Auth>
</CunninghamProvider>
</QueryClientProvider>
);
@@ -1,40 +0,0 @@
import { Crisp } from 'crisp-sdk-web';
import fetchMock from 'fetch-mock';
import { useAuthStore } from '../useAuthStore';
jest.mock('crisp-sdk-web', () => ({
...jest.requireActual('crisp-sdk-web'),
Crisp: {
isCrispInjected: jest.fn().mockReturnValue(true),
setTokenId: jest.fn(),
user: {
setEmail: jest.fn(),
},
session: {
reset: jest.fn(),
},
},
}));
describe('useAuthStore', () => {
afterEach(() => {
jest.clearAllMocks();
fetchMock.restore();
});
it('checks support session is terminated when logout', () => {
window.$crisp = true;
Object.defineProperty(window, 'location', {
value: {
...window.location,
replace: jest.fn(),
},
writable: true,
});
useAuthStore.getState().logout();
expect(Crisp.session.reset).toHaveBeenCalled();
});
});
@@ -1,7 +1,6 @@
import { create } from 'zustand';
import { baseApiUrl } from '@/api';
import { terminateCrispSession } from '@/services';
import { baseApiUrl } from '@/core/conf';
import { User, getMe } from './api';
import { PATH_AUTH_LOCAL_STORAGE } from './conf';
@@ -43,7 +42,6 @@ export const useAuthStore = create<AuthStore>((set, get) => ({
window.location.replace(`${baseApiUrl()}authenticate/`);
},
logout: () => {
terminateCrispSession();
window.location.replace(`${baseApiUrl()}logout/`);
},
// If we try to access a specific page and we are not authenticated
@@ -0,0 +1,18 @@
export const mediaUrl = () =>
process.env.NEXT_PUBLIC_MEDIA_URL ||
(typeof window !== 'undefined' ? window.location.origin : '');
export const backendUrl = () =>
process.env.NEXT_PUBLIC_API_ORIGIN ||
(typeof window !== 'undefined' ? window.location.origin : '');
export const baseApiUrl = (apiVersion: string = '1.0') =>
`${backendUrl()}/api/v${apiVersion}/`;
export const providerUrl = (docId: string) => {
const base =
process.env.NEXT_PUBLIC_Y_PROVIDER_URL ||
(typeof window !== 'undefined' ? `wss://${window.location.host}/ws` : '');
return `${base}/${docId}`;
};
@@ -1,49 +0,0 @@
import { Loader } from '@openfun/cunningham-react';
import { PropsWithChildren, useEffect } from 'react';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { configureCrispSession } from '@/services';
import { useSentryStore } from '@/stores/useSentryStore';
import { useConfig } from './api/useConfig';
export const ConfigProvider = ({ children }: PropsWithChildren) => {
const { data: conf } = useConfig();
const { setSentry } = useSentryStore();
const { setTheme } = useCunninghamTheme();
useEffect(() => {
if (!conf?.SENTRY_DSN) {
return;
}
setSentry(conf.SENTRY_DSN, conf.ENVIRONMENT);
}, [conf?.SENTRY_DSN, conf?.ENVIRONMENT, setSentry]);
useEffect(() => {
if (!conf?.FRONTEND_THEME) {
return;
}
setTheme(conf.FRONTEND_THEME);
}, [conf?.FRONTEND_THEME, setTheme]);
useEffect(() => {
if (!conf?.CRISP_WEBSITE_ID) {
return;
}
configureCrispSession(conf.CRISP_WEBSITE_ID);
}, [conf?.CRISP_WEBSITE_ID]);
if (!conf) {
return (
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
<Loader />
</Box>
);
}
return children;
};
@@ -1 +0,0 @@
export * from './useConfig';
@@ -1,35 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Theme } from '@/cunningham/';
interface ConfigResponse {
LANGUAGES: [string, string][];
LANGUAGE_CODE: string;
ENVIRONMENT: string;
COLLABORATION_SERVER_URL?: string;
CRISP_WEBSITE_ID?: string;
FRONTEND_THEME?: Theme;
MEDIA_BASE_URL?: string;
SENTRY_DSN?: string;
}
export const getConfig = async (): Promise<ConfigResponse> => {
const response = await fetchAPI(`config/`);
if (!response.ok) {
throw new APIError('Failed to get the doc', await errorCauses(response));
}
return response.json() as Promise<ConfigResponse>;
};
export const KEY_CONFIG = 'config';
export function useConfig() {
return useQuery<ConfigResponse, APIError, ConfigResponse>({
queryKey: [KEY_CONFIG],
queryFn: () => getConfig(),
staleTime: Infinity,
});
}
@@ -1,2 +0,0 @@
export * from './useMediaUrl';
export * from './useCollaborationUrl';
@@ -1,15 +0,0 @@
import { useConfig } from '../api';
export const useCollaborationUrl = (room?: string) => {
const { data: conf } = useConfig();
if (!room) {
return;
}
const base =
conf?.COLLABORATION_SERVER_URL ||
(typeof window !== 'undefined' ? `wss://${window.location.host}/ws` : '');
return `${base}/${room}`;
};
@@ -1,10 +0,0 @@
import { useConfig } from '../api';
export const useMediaUrl = () => {
const { data: conf } = useConfig();
return (
conf?.MEDIA_BASE_URL ||
(typeof window !== 'undefined' ? window.location.origin : '')
);
};
@@ -1,3 +0,0 @@
export * from './api/';
export * from './ConfigProvider';
export * from './hooks';
+1 -1
View File
@@ -1,3 +1,3 @@
export * from './AppProvider';
export * from './auth';
export * from './config';
export * from './conf';
@@ -1,6 +1,12 @@
import { useCunninghamTheme } from '../useCunninghamTheme';
import useCunninghamTheme from '../useCunninghamTheme';
describe('<useCunninghamTheme />', () => {
it('has the theme from NEXT_PUBLIC_THEME', () => {
const { theme } = useCunninghamTheme.getState();
expect(theme).toBe('test-theme');
});
it('has the dsfr logo correctly set', () => {
const { themeTokens, setTheme } = useCunninghamTheme.getState();
setTheme('dsfr');
File diff suppressed because it is too large Load Diff
@@ -13,7 +13,7 @@ export const tokens = {
'secondary-700': '#97A3AE',
'secondary-800': '#757E87',
'secondary-900': '#596067',
'info-text': '#FFFFFF',
'info-text': '#fff',
'info-100': '#EBF2FC',
'info-200': '#8CB5EA',
'info-300': '#5894E1',
@@ -32,7 +32,7 @@ export const tokens = {
'greyscale-700': '#555F6B',
'greyscale-800': '#303C4B',
'greyscale-900': '#0C1A2B',
'greyscale-000': '#FFFFFF',
'greyscale-000': '#fff',
'primary-100': '#EDF5FA',
'primary-200': '#8CB5EA',
'primary-300': '#5894E1',
@@ -69,29 +69,34 @@ export const tokens = {
'danger-700': '#9B0000',
'danger-800': '#780000',
'danger-900': '#5C0000',
'primary-text': '#FFFFFF',
'success-text': '#FFFFFF',
'warning-text': '#FFFFFF',
'danger-text': '#FFFFFF',
'card-border': '#ededed',
'primary-text': '#fff',
'success-text': '#fff',
'warning-text': '#fff',
'danger-text': '#fff',
'card-border': '#E5E5E5',
'primary-bg': '#FAFAFA',
'primary-150': '#E5EEFA',
'info-150': '#E5EEFA',
'greyscale-1000': '#161616',
},
font: {
sizes: {
h1: '2.2rem',
h2: '1.7rem',
h3: '1.37rem',
h4: '1.15rem',
h5: '1rem',
h6: '0.87rem',
h1: '32px',
h2: '28px',
h3: '24px',
h4: '22px',
h5: '20px',
h6: '18px',
l: '1rem',
m: '0.8125rem',
s: '0.75rem',
ml: '0.938rem',
xl: '1.50rem',
t: '0.6875rem',
xl: '20px',
lg: '18px',
md: '16px',
sm: '14px',
xs: '12px',
},
weights: {
thin: 100,
@@ -130,6 +135,21 @@ export const tokens = {
auto: 'auto',
bx: '2.2rem',
full: '100%',
'050V': '2px',
'100V': '4px',
'150V': '6px',
'100W': '8px',
'300V': '12px',
'200W': '16px',
'300W': '24px',
'400W': '32px',
'500W': '40px',
'600W': '48px',
'700W': '56px',
'800W': '64px',
'900W': '72px',
'1200W': '96px',
'1500W': '120px',
},
transitions: {
'ease-in': 'cubic-bezier(0.32, 0, 0.67, 0)',
@@ -202,7 +222,7 @@ export const tokens = {
focus: 'var(--c--components--forms-select--border-radius)',
},
'font-size': 'var(--c--theme--font--sizes--ml)',
'menu-background-color': '#ffffff',
'menu-background-color': '#fff',
'item-background-color': {
hover: 'var(--c--theme--colors--primary-300)',
},
@@ -223,7 +243,7 @@ export const tokens = {
'border-color-hover': 'var(--c--theme--colors--greyscale-200)',
},
},
modal: { 'background-color': '#ffffff' },
modal: { 'background-color': '#fff' },
button: {
'border-radius': {
active: 'var(--c--components--button--border-radius)',
@@ -333,6 +353,23 @@ export const tokens = {
},
dsfr: {
theme: {
spacings: {
'050V': '2px',
'100V': '4px',
'150V': '6px',
'100W': '8px',
'300V': '12px',
'200W': '16px',
'300W': '24px',
'400W': '32px',
'500W': '40px',
'600W': '48px',
'700W': '56px',
'800W': '64px',
'900W': '72px',
'1200W': '96px',
'1500W': '120px',
},
colors: {
'card-border': '#ededed',
'primary-text': '#000091',
@@ -346,7 +383,7 @@ export const tokens = {
'primary-700': '#272747',
'primary-800': '#21213f',
'primary-900': '#1c1a36',
'secondary-text': '#FFFFFF',
'secondary-text': '#fff',
'secondary-100': '#fee9ea',
'secondary-200': '#fedfdf',
'secondary-300': '#fdbfbf',
@@ -357,16 +394,16 @@ export const tokens = {
'secondary-800': '#341f1f',
'secondary-900': '#2b1919',
'greyscale-text': '#303C4B',
'greyscale-000': '#f6f6f6',
'greyscale-100': '#eeeeee',
'greyscale-200': '#e5e5e5',
'greyscale-300': '#e1e1e1',
'greyscale-400': '#dddddd',
'greyscale-500': '#cecece',
'greyscale-600': '#7b7b7b',
'greyscale-700': '#666666',
'greyscale-800': '#2a2a2a',
'greyscale-900': '#1e1e1e',
'greyscale-000': '#fff',
'greyscale-050': '#F6F6F6',
'greyscale-100': '#eee',
'greyscale-200': '#E5E5E5',
'greyscale-250': '#ddd',
'greyscale-300': '#CECECE',
'greyscale-400': '#929292',
'greyscale-500': '#666',
'greyscale-700': '#3A3A3A',
'greyscale-1000': '#161616',
'success-text': '#1f8d49',
'success-100': '#dffee6',
'success-200': '#b8fec9',
@@ -408,7 +445,25 @@ export const tokens = {
'danger-800': '#412121',
'danger-900': '#3a1c1c',
},
font: { families: { accent: 'Marianne', base: 'Marianne' } },
font: {
families: { accent: 'Marianne', base: 'Marianne' },
size: {
ml: '0.938rem',
t: '0.6875rem',
s: '0.75rem',
xl: '20px',
lg: '18px',
md: '16px',
sm: '14px',
xs: '12px',
h1: '32px',
h2: '28px',
h3: '24px',
h4: '22px',
h5: '20px',
h6: '18px',
},
},
logo: {
src: '/assets/logo-gouv.svg',
widthHeader: '110px',
@@ -427,9 +482,9 @@ export const tokens = {
'color-hover': '#1212ff',
'color-active': '#2323ff',
},
color: '#ffffff',
'color-hover': '#ffffff',
'color-active': '#ffffff',
color: '#fff',
'color-hover': '#fff',
'color-active': '#fff',
},
'primary-text': {
background: {
@@ -486,7 +541,7 @@ export const tokens = {
},
'forms-input': {
'border-radius': '4px',
'background-color': '#ffffff',
'background-color': '#fff',
'border-color': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
'value-color': 'var(--c--theme--colors--primary-text)',
@@ -502,7 +557,7 @@ export const tokens = {
'item-font-size': '14px',
'border-radius': '4px',
'border-radius-hover': '4px',
'background-color': '#ffffff',
'background-color': '#fff',
'border-color': 'var(--c--theme--colors--primary-text)',
'border-color-hover': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
@@ -1,2 +1,4 @@
export * from './cunningham-tokens';
export * from './useCunninghamTheme';
import { tokens } from './cunningham-tokens';
import useCunninghamTheme from './useCunninghamTheme';
export { tokens, useCunninghamTheme };
@@ -5,31 +5,33 @@ import { tokens } from './cunningham-tokens';
type Tokens = typeof tokens.themes.default & Partial<typeof tokens.themes.dsfr>;
type ColorsTokens = Tokens['theme']['colors'];
type SpacingsTokens = Tokens['theme']['spacings'];
type ComponentTokens = Tokens['components'];
export type Theme = keyof typeof tokens.themes;
type Theme = 'default' | 'dsfr';
interface AuthStore {
theme: string;
theme: Theme;
setTheme: (theme: Theme) => void;
themeTokens: () => Partial<Tokens['theme']>;
colorsTokens: () => Partial<ColorsTokens>;
spacingsTokens: () => SpacingsTokens;
componentTokens: () => ComponentTokens;
}
export const useCunninghamTheme = create<AuthStore>((set, get) => {
const useCunninghamTheme = create<AuthStore>((set, get) => {
const currentTheme = () =>
merge(
tokens.themes['default'],
tokens.themes[get().theme as keyof typeof tokens.themes],
) as Tokens;
merge(tokens.themes['default'], tokens.themes[get().theme]) as Tokens;
return {
theme: 'dsfr',
theme: (process.env.NEXT_PUBLIC_THEME as Theme) || 'dsfr',
themeTokens: () => currentTheme().theme,
colorsTokens: () => currentTheme().theme.colors,
componentTokens: () => currentTheme().components,
spacingsTokens: () => currentTheme().theme.spacings,
setTheme: (theme: Theme) => {
set({ theme });
},
};
});
export default useCunninghamTheme;
+3
View File
@@ -20,6 +20,9 @@ declare module '*.svg?url' {
namespace NodeJS {
interface ProcessEnv {
NEXT_PUBLIC_API_ORIGIN?: string;
NEXT_PUBLIC_MEDIA_URL?: string;
NEXT_PUBLIC_Y_PROVIDER_URL?: string;
NEXT_PUBLIC_SW_DEACTIVATED?: string;
NEXT_PUBLIC_THEME?: string;
}
}
@@ -1,19 +1,21 @@
import { Dictionary, locales } from '@blocknote/core';
import { locales } from '@blocknote/core';
import '@blocknote/core/fonts/inter.css';
import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
import { useCreateBlockNote } from '@blocknote/react';
import { HocuspocusProvider } from '@hocuspocus/provider';
import React, { useEffect } from 'react';
import { t } from 'i18next';
import React, { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, TextErrors } from '@/components';
import { mediaUrl } from '@/core';
import { useAuthStore } from '@/core/auth';
import { Doc } from '@/features/docs/doc-management';
import { Doc, useDocStore } from '@/features/docs/doc-management';
import { useUploadFile } from '../hook';
import { useCreateDocAttachment } from '../api/useCreateDocUpload';
import useSaveDoc from '../hook/useSaveDoc';
import { useEditorStore, useHeadingStore } from '../stores';
import { useHeadingStore } from '../stores';
import { randomColor } from '../utils';
import { BlockNoteToolbar } from './BlockNoteToolbar';
@@ -24,8 +26,17 @@ const cssEditor = (readonly: boolean) => `
};
& .bn-editor {
padding-right: 30px;
${readonly && `padding-left: 30px;`}
${
readonly &&
`
padding-left: 30px;
pointer-events: none;
`
}
};
& .collaboration-cursor__caret.ProseMirror-widget{
word-wrap: initial;
}
& .bn-inline-content code {
background-color: gainsboro;
padding: 2px;
@@ -75,20 +86,34 @@ export const BlockNoteEditor = ({
}: BlockNoteEditorProps) => {
const isVersion = doc.id !== storeId;
const { userData } = useAuthStore();
const { setEditor } = useEditorStore();
const { t } = useTranslation();
const { setStore, docsStore } = useDocStore();
const readOnly = !doc.abilities.partial_update || isVersion;
useSaveDoc(doc.id, provider.document, !readOnly);
const storedEditor = docsStore?.[storeId]?.editor;
const {
mutateAsync: createDocAttachment,
isError: isErrorAttachment,
error: errorAttachment,
} = useCreateDocAttachment();
const { setHeadings, resetHeadings } = useHeadingStore();
const { i18n } = useTranslation();
const lang = i18n.language;
const { uploadFile, errorAttachment } = useUploadFile(doc.id);
const uploadFile = useCallback(
async (file: File) => {
const body = new FormData();
body.append('file', file);
const collabName =
userData?.full_name ||
userData?.email ||
(readOnly ? 'Reader' : t('Anonymous'));
const ret = await createDocAttachment({
docId: doc.id,
body,
});
return `${mediaUrl()}${ret.file}`;
},
[createDocAttachment, doc.id],
);
const editor = useCreateBlockNote(
{
@@ -96,48 +121,19 @@ export const BlockNoteEditor = ({
provider,
fragment: provider.document.getXmlFragment('document-store'),
user: {
name: collabName,
name: userData?.full_name || userData?.email || t('Anonymous'),
color: randomColor(),
},
/**
* We re-use the blocknote code to render the cursor but we:
* - fix rendering issue with Firefox
* - We don't want to show the cursor when anonymous users
*/
renderCursor: (user: { color: string; name: string }) => {
const cursor = document.createElement('span');
if (user.name === 'Reader') {
return cursor;
}
cursor.classList.add('collaboration-cursor__caret');
cursor.setAttribute('style', `border-color: ${user.color}`);
const label = document.createElement('span');
label.classList.add('collaboration-cursor__label');
label.setAttribute('style', `background-color: ${user.color}`);
label.insertBefore(document.createTextNode(user.name), null);
cursor.insertBefore(label, null);
return cursor;
},
},
dictionary: locales[lang as keyof typeof locales] as Dictionary,
dictionary: locales[lang as keyof typeof locales],
uploadFile,
},
[collabName, lang, provider, uploadFile],
[lang, provider, uploadFile, userData?.email, userData?.full_name],
);
useEffect(() => {
setEditor(editor);
return () => {
setEditor(undefined);
};
}, [setEditor, editor]);
setStore(storeId, { editor });
}, [setStore, storeId, editor]);
useEffect(() => {
setHeadings(editor);
@@ -153,7 +149,7 @@ export const BlockNoteEditor = ({
return (
<Box $css={cssEditor(readOnly)}>
{errorAttachment && (
{isErrorAttachment && (
<Box $margin={{ bottom: 'big' }}>
<TextErrors
causes={errorAttachment.cause}
@@ -164,7 +160,7 @@ export const BlockNoteEditor = ({
)}
<BlockNoteView
editor={editor}
editor={storedEditor ?? editor}
formattingToolbar={false}
editable={!readOnly}
theme="light"
@@ -1,10 +1,10 @@
import { Alert, Loader, VariantType } from '@openfun/cunningham-react';
import { useRouter as useNavigate } from 'next/navigation';
import { useRouter } from 'next/router';
import React, { useEffect } from 'react';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Card, Text, TextErrors } from '@/components';
import { useCollaborationUrl } from '@/core';
import { useCunninghamTheme } from '@/cunningham';
import { DocHeader } from '@/features/docs/doc-header';
import { Doc, useDocStore } from '@/features/docs/doc-management';
@@ -32,8 +32,8 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
const { colorsTokens } = useCunninghamTheme();
const { providers } = useDocStore();
const provider = providers?.[doc.id];
const { docsStore } = useDocStore();
const provider = docsStore?.[doc.id]?.provider;
if (!provider) {
return null;
@@ -58,10 +58,10 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
)}
<Box
$background={colorsTokens()['primary-bg']}
$height="100%"
$direction="row"
$width="100%"
$margin={{ all: isMobile ? 'tiny' : 'small', top: 'none' }}
$css="overflow-x: clip;"
$css="overflow-x: clip; flex: 1;"
$position="relative"
>
<Card
@@ -98,25 +98,24 @@ export const DocVersionEditor = ({ doc, versionId }: DocVersionEditorProps) => {
docId: doc.id,
versionId,
});
const { createProvider, providers } = useDocStore();
const collaborationUrl = useCollaborationUrl(versionId);
const { createProvider, docsStore } = useDocStore();
const { replace } = useRouter();
const navigate = useNavigate();
useEffect(() => {
if (!version?.id || !collaborationUrl) {
if (!version?.id) {
return;
}
const provider = providers?.[version.id];
const provider = docsStore?.[version.id]?.provider;
if (!provider || provider.document.guid !== version.id) {
createProvider(collaborationUrl, version.id, version.content);
createProvider(version.id, version.content);
}
}, [createProvider, providers, version, collaborationUrl]);
}, [createProvider, docsStore, version]);
if (isError && error) {
if (error.status === 404) {
void replace(`/404`);
navigate.replace(`/404`);
return null;
}
@@ -144,7 +143,7 @@ export const DocVersionEditor = ({ doc, versionId }: DocVersionEditorProps) => {
);
}
const provider = providers?.[version.id];
const provider = docsStore?.[version.id]?.provider;
if (!provider) {
return null;
@@ -127,7 +127,9 @@ export const PanelEditor = ({
</BoxButton>
)}
</Box>
{isPanelTableContentOpen && <TableContent headings={headings} />}
{isPanelTableContentOpen && (
<TableContent doc={doc} headings={headings} />
)}
{!isPanelTableContentOpen && doc.abilities.versions_list && (
<VersionList doc={doc} />
)}
@@ -1,2 +0,0 @@
export * from './useSaveDoc';
export * from './useUploadFile';
@@ -1,35 +0,0 @@
import { useCallback } from 'react';
import { useMediaUrl } from '@/core/config';
import { useCreateDocAttachment } from '../api';
export const useUploadFile = (docId: string) => {
const mediaUrl = useMediaUrl();
const {
mutateAsync: createDocAttachment,
isError: isErrorAttachment,
error: errorAttachment,
} = useCreateDocAttachment();
const uploadFile = useCallback(
async (file: File) => {
const body = new FormData();
body.append('file', file);
const ret = await createDocAttachment({
docId,
body,
});
return `${mediaUrl}${ret.file}`;
},
[createDocAttachment, docId, mediaUrl],
);
return {
uploadFile,
isErrorAttachment,
errorAttachment,
};
};
@@ -1,3 +1,2 @@
export * from './useEditorStore';
export * from './useHeadingStore';
export * from './usePanelEditorStore';
@@ -1,14 +0,0 @@
import { BlockNoteEditor } from '@blocknote/core';
import { create } from 'zustand';
export interface UseEditorstore {
editor?: BlockNoteEditor;
setEditor: (editor: BlockNoteEditor | undefined) => void;
}
export const useEditorStore = create<UseEditorstore>((set) => ({
editor: undefined,
setEditor: (editor) => {
set({ editor });
},
}));
@@ -23,5 +23,6 @@ function hslToHex(h: number, s: number, l: number) {
return `#${f(0)}${f(8)}${f(4)}`;
}
export const toBase64 = (str: Uint8Array) =>
Buffer.from(str).toString('base64');
export const toBase64 = (
str: WithImplicitCoercion<ArrayBuffer | SharedArrayBuffer>,
) => Buffer.from(str).toString('base64');
@@ -1,6 +1,5 @@
import { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Card, StyledLink, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
@@ -33,6 +32,7 @@ export const DocHeader = ({ doc, versionId }: DocHeaderProps) => {
return (
<>
<Card
$width="100%"
$margin={isMobile ? 'tiny' : 'small'}
aria-label={t('It is the card information about the document.')}
>
@@ -47,11 +47,7 @@ export const DocHeader = ({ doc, versionId }: DocHeaderProps) => {
$theme="primary"
$variation="600"
$size="2rem"
$css={css`
&:hover {
background-color: ${colorsTokens()['primary-100']};
}
`}
$css={`&:hover {background-color: ${colorsTokens()['primary-100']}; };`}
$hasTransition
$radius="5px"
$padding="tiny"
@@ -57,7 +57,7 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
const { broadcast } = useBroadcastStore();
const { mutate: updateDoc } = useUpdateDoc({
listInvalideQueries: [KEY_LIST_DOC],
listInvalideQueries: [KEY_DOC, KEY_LIST_DOC],
onSuccess(data) {
if (data.title !== untitledDocument) {
toast(t('Document title updated successfully'), VariantType.SUCCESS);
@@ -104,10 +104,6 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
}
};
useEffect(() => {
setTitleDisplay(doc.title);
}, [doc.title]);
useEffect(() => {
if ((!debounceRef.current && !isUntitled) || !headingText) {
return;
@@ -133,7 +129,6 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
$radius="4px"
$padding={{ horizontal: 'tiny', vertical: '4px' }}
$margin="none"
$minWidth="200px"
contentEditable={isFirefox() ? 'true' : 'plaintext-only'}
onClick={handleOnClick}
onBlurCapture={(e) =>
@@ -8,18 +8,17 @@ import { useTranslation } from 'react-i18next';
import { Box, DropButton, IconOptions } from '@/components';
import { useAuthStore } from '@/core';
import {
useEditorStore,
usePanelEditorStore,
} from '@/features/docs/doc-editor/';
import { usePanelEditorStore } from '@/features/docs/doc-editor/';
import {
Doc,
ModalRemoveDoc,
ModalShare,
useDocStore,
} from '@/features/docs/doc-management';
import { ModalVersion, Versions } from '@/features/docs/doc-versioning';
import { useResponsiveStore } from '@/stores';
import { ModalVersion, Versions } from '../../doc-versioning';
import { ModalPDF } from './ModalExport';
interface DocToolBoxProps {
@@ -37,12 +36,13 @@ export const DocToolBox = ({ doc, versionId }: DocToolBoxProps) => {
const [isModalVersionOpen, setIsModalVersionOpen] = useState(false);
const { isSmallMobile } = useResponsiveStore();
const { authenticated } = useAuthStore();
const { editor } = useEditorStore();
const { docsStore } = useDocStore();
const { toast } = useToastProvider();
const copyCurrentEditorToClipboard = async (
asFormat: 'html' | 'markdown',
) => {
const editor = docsStore[doc.id]?.editor;
if (!editor) {
toast(t('Editor unavailable'), VariantType.ERROR, { duration: 3000 });
return;
@@ -10,12 +10,11 @@ import {
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { t } from 'i18next';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text } from '@/components';
import { useEditorStore } from '@/features/docs/doc-editor';
import { Doc } from '@/features/docs/doc-management';
import { Doc, useDocStore } from '@/features/docs/doc-management';
import { useExport } from '../api/useExport';
import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
@@ -27,12 +26,11 @@ interface ModalPDFProps {
}
export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => {
const { t } = useTranslation();
const { data: templates } = useTemplates({
ordering: TemplatesOrdering.BY_CREATED_ON_DESC,
});
const { toast } = useToastProvider();
const { editor } = useEditorStore();
const { docsStore } = useDocStore();
const {
mutate: createExport,
data: documentGenerated,
@@ -105,6 +103,8 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => {
return;
}
const editor = docsStore[doc.id].editor;
if (!editor) {
toast(t('No editor found'), VariantType.ERROR);
return;
@@ -1,4 +1,8 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import {
UseQueryOptions,
useInfiniteQuery,
useQuery,
} from '@tanstack/react-query';
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
@@ -52,3 +56,14 @@ export function useDocs(
...queryConfig,
});
}
export const useInfiniteDocs = (params: DocsParams) => {
return useInfiniteQuery({
initialPageParam: 1,
queryKey: [KEY_LIST_DOC, 'infinite', params],
queryFn: ({ pageParam }) => getDocs({ ...params, page: pageParam }),
getNextPageParam: (lastPage, allPages) => {
return lastPage.next ? allPages.length + 1 : undefined;
},
});
};
@@ -0,0 +1,9 @@
<svg width="32" height="36" viewBox="0 0 32 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.01394" y="1.23611" width="25.9722" height="33.5278" rx="3.54167" fill="white"/>
<rect x="2.01394" y="1.23611" width="25.9722" height="33.5278" rx="3.54167" stroke="#DCDCFC" stroke-width="0.472222"/>
<path d="M6.5 8.55556H15" stroke="#6A6AF4" stroke-width="1.88889" stroke-linecap="round"/>
<path d="M6.5 11.3889H23.5M6.5 14.2222H23.5M6.5 17.0556H23.5M6.5 19.8889H23.5M6.5 22.7222H20.6667" stroke="#CACAFB" stroke-width="1.88889" stroke-linecap="round"/>
<rect x="7" y="10" width="16" height="16" rx="8" fill="#6A6AF4"/>
<rect x="7" y="10" width="16" height="16" rx="8" stroke="white" stroke-width="1.5"/>
<path d="M16.8 18L18 19.2V20.1H15.45V22.95L15 23.4L14.55 22.95V20.1H12V19.2L13.2 18V14.7H12.6V13.8H17.4V14.7H16.8V18Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 853 B

@@ -0,0 +1,6 @@
<svg width="28" height="34" viewBox="0 0 28 34" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.01394" y="0.236111" width="25.9722" height="33.5278" rx="3.54167" fill="white"/>
<rect x="1.01394" y="0.236111" width="25.9722" height="33.5278" rx="3.54167" stroke="#DCDCFC" stroke-width="0.472222"/>
<path d="M5.5 7.55554H14" stroke="#6A6AF4" stroke-width="1.88889" stroke-linecap="round"/>
<path d="M5.5 10.3889H22.5M5.5 13.2222H22.5M5.5 16.0556H22.5M5.5 18.8889H22.5M5.5 21.7222H22.5M5.5 24.5556H22.5M5.5 27.3889H22.5M5.5 30.2222H22.5M5.5 33.0556H22.5" stroke="#CACAFB" stroke-width="1.88889" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 635 B

@@ -6,11 +6,11 @@ import {
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import { useTranslation } from 'react-i18next';
import { t } from 'i18next';
import { useRouter } from 'next/navigation';
import { Box, Text, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham/';
import useCunninghamTheme from '@/cunningham/useCunninghamTheme';
import { useRemoveDoc } from '../api/useRemoveDoc';
import IconDoc from '../assets/icon-doc.svg';
@@ -22,10 +22,9 @@ interface ModalRemoveDocProps {
}
export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const { toast } = useToastProvider();
const { push } = useRouter();
const router = useRouter();
const {
mutate: removeDoc,
@@ -36,7 +35,7 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
toast(t('The document has been deleted.'), VariantType.SUCCESS, {
duration: 4000,
});
void push('/');
router.push('/');
},
});
@@ -3,7 +3,7 @@ import {
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { t } from 'i18next';
import { createGlobalStyle } from 'styled-components';
import { Box, Card, IconBG, SideModal, Text } from '@/components';
@@ -44,7 +44,6 @@ interface ModalShareProps {
}
export const ModalShare = ({ onClose, doc }: ModalShareProps) => {
const { t } = useTranslation();
const { isMobile, isSmallMobile } = useResponsiveStore();
const width = isSmallMobile ? '100vw' : isMobile ? '90vw' : '70vw';
const { toast } = useToastProvider();
@@ -0,0 +1,60 @@
import { ReactNode } from 'react';
import styled, { css } from 'styled-components';
import { Box, Text } from '@/components';
import { Doc } from '@/features/docs';
import PinnedDocumentIcon from '@/features/docs/doc-management/assets/pinned-document.svg';
import SimpleFileIcon from '@/features/docs/doc-management/assets/simple-document.svg';
const ItemContainer = styled(Box)`
display: flex;
flex-direction: row;
align-items: center;
gap: var(--c--theme--spacings--100W);
border-radius: var(--c--theme--spacings--100V);
padding: var(--c--theme--spacings--150V);
cursor: pointer;
`;
const ItemTextCss = css`
overflow: hidden;
text-overflow: ellipsis;
white-space: initial;
display: -webkit-box;
line-clamp: 1;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
`;
type Props = {
doc: Doc;
isPinned?: boolean;
subText?: ReactNode | string;
};
export const SimpleDocItem = ({ doc, isPinned = false, subText }: Props) => {
return (
<ItemContainer>
<Box
$css={`
background-color: transparent;
filter: drop-shadow(0px 2px 2px rgba(0, 0, 0, 0.05));
display: flex;
align-items: center;
`}
>
{isPinned ? <PinnedDocumentIcon /> : <SimpleFileIcon />}
</Box>
<div>
<Text $weight={500} $variation="1000" $size="sm" $css={ItemTextCss}>
{doc.title}
</Text>
<Text $variation="500" $size="xs" $css={ItemTextCss}>
{subText ??
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi vel ante libero. Interdum et malesuada fames ac ante ipsum primis in faucibus. Sed imperdiet neque quam, sed euismod metus mollis ut. '}
</Text>
</div>
</ItemContainer>
);
};
@@ -1,27 +1,30 @@
import { BlockNoteEditor } from '@blocknote/core';
import { HocuspocusProvider } from '@hocuspocus/provider';
import * as Y from 'yjs';
import { create } from 'zustand';
import { providerUrl } from '@/core';
import { Base64, Doc, blocksToYDoc } from '@/features/docs/doc-management';
interface DocStore {
provider: HocuspocusProvider;
editor?: BlockNoteEditor;
}
export interface UseDocStore {
currentDoc?: Doc;
providers: {
[storeId: string]: HocuspocusProvider;
docsStore: {
[storeId: string]: DocStore;
};
createProvider: (
providerUrl: string,
storeId: string,
initialDoc: Base64,
) => HocuspocusProvider;
setProviders: (storeId: string, providers: HocuspocusProvider) => void;
createProvider: (storeId: string, initialDoc: Base64) => HocuspocusProvider;
setStore: (storeId: string, props: Partial<DocStore>) => void;
setCurrentDoc: (doc: Doc | undefined) => void;
}
export const useDocStore = create<UseDocStore>((set, get) => ({
currentDoc: undefined,
providers: {},
createProvider: (providerUrl, storeId, initialDoc) => {
docsStore: {},
createProvider: (storeId: string, initialDoc: Base64) => {
const doc = new Y.Doc({
guid: storeId,
});
@@ -40,22 +43,28 @@ export const useDocStore = create<UseDocStore>((set, get) => ({
}
const provider = new HocuspocusProvider({
url: providerUrl,
url: providerUrl(storeId),
name: storeId,
document: doc,
});
get().setProviders(storeId, provider);
get().setStore(storeId, { provider });
return provider;
},
setProviders: (storeId, provider) => {
set(({ providers }) => ({
providers: {
...providers,
[storeId]: provider,
},
}));
setStore: (storeId, props) => {
set(({ docsStore }, ...store) => {
return {
...store,
docsStore: {
...docsStore,
[storeId]: {
...docsStore[storeId],
...props,
},
},
};
});
},
setCurrentDoc: (doc) => {
set({ currentDoc: doc });
@@ -1,20 +1,23 @@
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, BoxButton, Text } from '@/components';
import { HeadingBlock, useEditorStore } from '@/features/docs/doc-editor';
import { HeadingBlock } from '@/features/docs/doc-editor';
import { Doc, useDocStore } from '@/features/docs/doc-management';
import { useResponsiveStore } from '@/stores';
import { Heading } from './Heading';
interface TableContentProps {
doc: Doc;
headings: HeadingBlock[];
}
export const TableContent = ({ headings }: TableContentProps) => {
const { editor } = useEditorStore();
export const TableContent = ({ doc, headings }: TableContentProps) => {
const { docsStore } = useDocStore();
const { isMobile } = useResponsiveStore();
const { t } = useTranslation();
const editor = docsStore?.[doc.id]?.editor;
const [headingIdHighlight, setHeadingIdHighlight] = useState<string>();
// To highlight the first heading in the viewport
@@ -46,7 +49,7 @@ export const TableContent = ({ headings }: TableContentProps) => {
}
};
window.addEventListener('scroll', () => {
document.getElementById('mainContent')?.addEventListener('scroll', () => {
setTimeout(() => {
handleScroll();
}, 300);
@@ -6,8 +6,8 @@ import {
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import { useTranslation } from 'react-i18next';
import { t } from 'i18next';
import { useRouter } from 'next/navigation';
import * as Y from 'yjs';
import { Box, Text } from '@/components';
@@ -30,27 +30,30 @@ export const ModalVersion = ({
docId,
versionId,
}: ModalVersionProps) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const { push } = useRouter();
const { providers } = useDocStore();
const router = useRouter();
const { docsStore, setStore } = useDocStore();
const { mutate: updateDoc } = useUpdateDoc({
listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
onSuccess: () => {
const onDisplaySuccess = () => {
toast(t('Version restored successfully'), VariantType.SUCCESS);
void push(`/docs/${docId}`);
router.push(`/docs/${docId}`);
};
if (!providers?.[docId] || !providers?.[versionId]) {
if (!docsStore?.[docId]?.provider || !docsStore?.[versionId]?.provider) {
onDisplaySuccess();
return;
}
setStore(docId, {
editor: undefined,
});
revertUpdate(
providers[docId].document,
providers[docId].document,
providers[versionId].document,
docsStore[docId].provider.document,
docsStore[docId].provider.document,
docsStore[versionId].provider.document,
);
onDisplaySuccess();
@@ -80,7 +83,7 @@ export const ModalVersion = ({
fullWidth
onClick={() => {
const newDoc = toBase64(
Y.encodeStateAsUpdate(providers?.[versionId].document),
Y.encodeStateAsUpdate(docsStore?.[versionId]?.provider.document),
);
updateDoc({
@@ -1,6 +1,6 @@
import { Button } from '@openfun/cunningham-react';
import { t } from 'i18next';
import React, { PropsWithChildren, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, DropButton, IconOptions, StyledLink, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
@@ -25,7 +25,6 @@ export const VersionItem = ({
link,
isActive,
}: VersionItemProps) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const [isDropOpen, setIsDropOpen] = useState(false);
const [isModalVersionOpen, setIsModalVersionOpen] = useState(false);
@@ -0,0 +1,14 @@
import { Doc } from '../../doc-management';
import { SimpleDocItem } from '../../doc-management/components/items/SimpleDocItem';
type Props = {
doc: Doc;
};
export const DocGridListItem = ({ doc }: Props) => {
return (
<div>
<SimpleDocItem doc={doc} />
</div>
);
};
@@ -1,40 +0,0 @@
import { Button } from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Box } from '@/components';
import { useCreateDoc, useTrans } from '@/features/docs/doc-management/';
import { useResponsiveStore } from '@/stores';
import { DocsGrid } from './DocsGrid';
export const DocsGridContainer = () => {
const { t } = useTranslation();
const { untitledDocument } = useTrans();
const { push } = useRouter();
const { isMobile } = useResponsiveStore();
const { mutate: createDoc } = useCreateDoc({
onSuccess: (doc) => {
void push(`/docs/${doc.id}`);
},
});
const handleCreateDoc = () => {
createDoc({ title: untitledDocument });
};
return (
<Box $overflow="auto">
<Box
$align="flex-end"
$justify="center"
$margin={isMobile ? 'small' : 'big'}
>
<Button onClick={handleCreateDoc}>{t('Create a new document')}</Button>
</Box>
<DocsGrid />
</Box>
);
};
@@ -0,0 +1,75 @@
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Card, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useInfiniteDocs } from '@/features/docs/doc-management/api/useDocs';
import { LEFT_PANEL_WIDTH } from '@/features/left-pannel/conf';
import { useResponsiveStore } from '@/stores';
import { DocGridListItem } from './DocGridListItem';
export const DocsGridList = () => {
const { themeTokens, spacingsTokens, colorsTokens } = useCunninghamTheme();
const spacings = spacingsTokens();
const colors = colorsTokens();
const { t } = useTranslation();
const { isResponsive } = useResponsiveStore();
const { data, isFetching, isLoading, fetchNextPage, hasNextPage } =
useInfiniteDocs({
page: 1,
});
const loading = isFetching || isLoading;
const loadMore = (inView: boolean) => {
if (!inView) {
return;
}
void fetchNextPage();
};
return (
<Card
$css={`
width: 960px;
max-width: calc(100dvw - ${isResponsive ? 34 : LEFT_PANEL_WIDTH}px);
padding: ${spacings['300W']};
`}
>
<Text
$margin={{ bottom: `${spacings['100W']}` }}
$css="margin-block: 0"
as="h4"
>
{t('All docs')}
</Text>
<section>
<Box
as="header"
$css={css`
display: flex;
flex-direction: row;
margin-bottom: ${spacings['100W']};
font-size: ${themeTokens().font?.sizes.xs};
color: ${colors['greyscale-500']};
padding-bottom: ${spacings['150V']} ${spacings['100W']};
`}
>
<Box $flex={7}>{t('Name')}</Box>
{!isResponsive && <Box $flex={1}>{t('Update at')}</Box>}
<Box $flex={1} />
</Box>
</section>
<Box $gap={`${spacings['150V']}`}>
{data?.pages.map((currentPage) => {
return currentPage.results.map((doc) => (
<DocGridListItem doc={doc} key={doc.id} />
));
})}
</Box>
</Card>
);
};
@@ -1 +0,0 @@
export * from './DocsGridContainer';
@@ -1 +0,0 @@
export * from './components';
@@ -5,7 +5,7 @@ import {
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import { useRouter } from 'next/navigation';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -35,7 +35,7 @@ export const MemberItem = ({
const { isSmallMobile, screenWidth } = useResponsiveStore();
const [localRole, setLocalRole] = useState(role);
const { toast } = useToastProvider();
const { push } = useRouter();
const router = useRouter();
const { mutate: updateDocAccess, error: errorUpdate } = useUpdateDocAccess({
onSuccess: () => {
toast(t('The role has been updated'), VariantType.SUCCESS, {
@@ -55,7 +55,7 @@ export const MemberItem = ({
);
if (isMyself) {
void push('/');
router.push('/');
}
},
});
@@ -1,4 +1,5 @@
import Image from 'next/image';
import React from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
@@ -1,31 +1,4 @@
<svg viewBox="0 0 36 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_5_830)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M26.5706 16.681V20.756H9.59985V16.681H26.5706ZM26.5706 23.0467V27.1215H9.59985V23.0467H26.5706ZM19.5375 29.2926V33.3674H9.59985V29.2926H19.5375Z"
fill="#E1000F"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M35.6982 16.7351V33.2911C35.6826 33.2845 35.667 33.2756 35.6492 33.269V37.4663C35.6492 39.8874 33.641 41.8683 31.1867 41.8683H4.461C2.00664 41.8683 -0.00146484 39.8874 -0.00146484 37.4663V4.45137C-0.00146484 2.03028 2.00664 0.0493774 4.461 0.0493774H18.723V4.45137H4.461V37.4663H31.1867V16.7351H35.6982Z"
fill="#000091"
/>
<path
d="M33.9524 0.0360107H23.626C22.6794 0.0360107 21.9049 0.799997 21.9049 1.73376V11.9202C21.9049 12.854 22.6794 13.618 23.626 13.618H33.9524C34.899 13.618 35.6735 12.854 35.6735 11.9202V1.73376C35.6735 0.799997 34.899 0.0360107 33.9524 0.0360107ZM26.6378 6.40257C26.6378 7.10713 26.0613 7.67588 25.347 7.67588H24.4865V8.73697C24.4865 9.08501 24.1939 9.37362 23.8411 9.37362C23.4883 9.37362 23.1957 9.08501 23.1957 8.73697V5.12925C23.1957 4.66237 23.5829 4.28038 24.0562 4.28038H25.347C26.0613 4.28038 26.6378 4.84913 26.6378 5.55369V6.40257ZM30.9405 8.10031C30.9405 8.80488 30.364 9.37362 29.6497 9.37362H27.9286C27.6877 9.37362 27.4984 9.18687 27.4984 8.94919V4.70482C27.4984 4.46713 27.6877 4.28038 27.9286 4.28038H29.6497C30.364 4.28038 30.9405 4.84913 30.9405 5.55369V8.10031ZM34.3827 4.91704C34.3827 5.26507 34.0901 5.55369 33.7373 5.55369H33.0919V6.40257H33.7373C34.0901 6.40257 34.3827 6.69118 34.3827 7.03922C34.3827 7.38726 34.0901 7.67588 33.7373 7.67588H33.0919V8.73697C33.0919 9.08501 32.7993 9.37362 32.4465 9.37362C32.0936 9.37362 31.8011 9.08501 31.8011 8.73697V5.12925C31.8011 4.66237 32.1883 4.28038 32.6616 4.28038H33.7373C34.0901 4.28038 34.3827 4.569 34.3827 4.91704ZM24.4865 6.40257H25.347V5.55369H24.4865V6.40257ZM28.7892 8.10031H29.6497V5.55369H28.7892V8.10031Z"
fill="#000091"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M35.6734 10.0666V13.6574H32.4942V10.0666H35.6734ZM25.0866 0.0441895V3.59171H21.9041V0.0441895H25.0866Z"
fill="#000091"
/>
</g>
<defs>
<clipPath id="clip0_5_830">
<rect width="36" height="42" fill="white" />
</clipPath>
</defs>
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.6305 28.8312C22.7983 28.5038 23.9166 27.9062 24.6505 26.8503C25.3749 25.8163 25.5789 24.5047 25.5789 23.2425V4.75099C25.5789 4.42358 25.5611 4.09557 25.5216 3.77148C26.1016 3.99961 26.5486 4.37658 26.8626 4.90239C27.2331 5.50024 27.4184 6.28757 27.4184 7.26435V26.0464C27.4184 27.3684 27.0942 28.3578 26.4458 29.0146C25.7974 29.6714 24.8207 29.9998 23.5155 29.9998H16.4209C16.5889 29.9704 16.7574 29.9401 16.9262 29.909C18.4067 29.6444 19.9713 29.2854 21.6185 28.8346L21.6305 28.8312Z" fill="#C9191E"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.58203 25.655V6.8477C4.58203 5.70251 4.88938 4.83519 5.50408 4.24575C6.1272 3.65631 6.95242 3.33212 7.97972 3.27318C9.49542 3.18055 10.9311 3.05425 12.2868 2.89425C13.6425 2.72584 14.9393 2.53217 16.1771 2.31324C17.4234 2.0943 18.6359 1.85011 19.8148 1.58065C21.0274 1.29435 21.9578 1.4375 22.6062 2.0101C23.2546 2.58269 23.5788 3.49632 23.5788 4.75099V23.2425C23.5788 24.3456 23.3893 25.1666 23.0104 25.7055C22.6315 26.2529 21.9915 26.6528 21.0905 26.9054C19.4906 27.3433 17.9833 27.6886 16.5687 27.9412C15.154 28.2022 13.7731 28.4001 12.4258 28.5348C11.0785 28.6696 9.69751 28.7748 8.28286 28.8506C7.11241 28.918 6.20299 28.6738 5.5546 28.118C4.90622 27.5707 4.58203 26.7497 4.58203 25.655ZM9.20865 10.2624C11.0635 10.1444 12.7632 9.96305 14.3075 9.71831C14.6822 9.65722 15.0564 9.5936 15.4291 9.52759C15.8192 9.45851 16.1013 9.11859 16.1013 8.72337C16.1013 8.21154 15.638 7.82609 15.135 7.91189C14.846 7.96118 14.5555 8.00909 14.2635 8.05562C12.7346 8.29923 11.0452 8.47998 9.19523 8.5977C8.91819 8.61558 8.69776 8.70188 8.55608 8.87391C8.42209 9.03661 8.35645 9.23229 8.35645 9.45535C8.35645 9.68212 8.43296 9.87951 8.58568 10.0418L8.58783 10.0439C8.75336 10.2095 8.96369 10.2811 9.20865 10.2624ZM9.20801 14.456C11.0631 14.338 12.763 14.1566 14.3075 13.9119C15.8588 13.6589 17.3936 13.3638 18.9112 13.0266C19.2191 12.9581 19.4498 12.8503 19.5652 12.683C19.6786 12.5221 19.7347 12.3376 19.7347 12.1332C19.7347 11.9026 19.6469 11.704 19.476 11.5426C19.2921 11.3689 19.0348 11.3284 18.7304 11.3911L18.7285 11.3915C17.2823 11.7194 15.794 12.0053 14.2635 12.2492C12.7346 12.4928 11.0452 12.6735 9.19523 12.7913C8.91819 12.8091 8.69776 12.8954 8.55608 13.0675C8.42276 13.2294 8.35645 13.4205 8.35645 13.6363C8.35645 13.8703 8.43209 14.0723 8.58558 14.2354L8.59 14.2396C8.75499 14.3949 8.96316 14.4655 9.20551 14.4562L9.20801 14.456ZM9.20847 18.6494C11.0634 18.5229 12.7631 18.3374 14.3075 18.0927C15.8589 17.8482 17.3934 17.5573 18.9112 17.22C19.2199 17.1514 19.4508 17.0391 19.566 16.8627C19.6783 16.7029 19.7347 16.5233 19.7347 16.3266C19.7347 16.0961 19.6469 15.8974 19.476 15.7361C19.2921 15.5623 19.0348 15.5218 18.7304 15.5845L18.729 15.5848C17.2827 15.9043 15.7942 16.1861 14.2635 16.43C12.7345 16.6736 11.045 16.8586 9.19495 16.9847C8.91804 17.0026 8.69771 17.0889 8.55608 17.2609C8.42276 17.4228 8.35645 17.6139 8.35645 17.8297C8.35645 18.0637 8.43209 18.2658 8.58558 18.4289L8.59 18.433C8.75499 18.5883 8.96316 18.6589 9.20551 18.6496L9.20847 18.6494ZM14.3075 22.257C12.7632 22.5018 11.0635 22.6831 9.20867 22.8012C8.9637 22.8198 8.75337 22.7482 8.58783 22.5826L8.58572 22.5805C8.433 22.4182 8.35645 22.2208 8.35645 21.9941C8.35645 21.771 8.42209 21.5753 8.55608 21.4126C8.69776 21.2406 8.91827 21.1543 9.19531 21.1364C11.0453 21.0187 12.7346 20.838 14.2635 20.5943C14.5555 20.5478 14.846 20.4999 15.135 20.4506C15.638 20.3648 16.1013 20.7503 16.1013 21.2621C16.1013 21.6573 15.8192 21.9972 15.4291 22.0663C15.0564 22.1323 14.6822 22.1959 14.3075 22.257Z" fill="#000091"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -1,90 +1,91 @@
import { Button } from '@openfun/cunningham-react';
import Image from 'next/image';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Box, StyledLink, Text } from '@/components/';
import { Box, Icon, StyledLink, Text } from '@/components/';
import { ButtonLogin } from '@/core/auth';
import { useCunninghamTheme } from '@/cunningham';
import { LanguagePicker } from '@/features/language';
import { useResponsiveStore } from '@/stores';
import { default as IconDocs } from '../assets/icon-docs.svg?url';
import { HEADER_HEIGHT } from '../conf';
import { DropdownMenu } from './DropdownMenu';
import { LaGaufre } from './LaGaufre';
export const Header = () => {
const { t } = useTranslation();
const { isSmallMobile } = useResponsiveStore();
const theme = useCunninghamTheme();
const tokens = theme.themeTokens();
const colors = theme.colorsTokens();
const { isResponsive, toggleMobileMenu } = useResponsiveStore();
return (
<Box
as="header"
$justify="center"
$width="100%"
$zIndex="100"
$padding={{ vertical: 'xtiny' }}
$css="box-shadow: 0 1px 4px #00000040;"
$css={`
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
height: ${HEADER_HEIGHT}px;
min-height: ${HEADER_HEIGHT}px;
padding: 0 ${tokens.spacings?.['300V'] ?? '1rem'};
background-color: ${colors?.['greyscale-000'] ?? '#FFFFFF'};
border-bottom: 1px solid ${(colors?.['greyscale-200'] as string) ?? '#E5E5E5'};
`}
>
<Box
$margin={{
left: 'big',
right: isSmallMobile ? 'none' : 'big',
}}
$align="center"
$justify="space-between"
$direction="row"
>
<Box>
<StyledLink href="/">
<Box
$align="center"
$gap="0.8rem"
$direction="row"
$position="relative"
$height="fit-content"
$margin={{ top: 'auto' }}
>
<Image priority src={IconDocs} alt={t('Docs Logo')} width={25} />
<Text
$padding="2px 3px"
$size="8px"
$background="#368bd6"
$color="white"
$position="absolute"
$radius="5px"
$css={`
bottom: 13px;
right: -17px;
`}
>
BETA
</Text>
<Text
$margin="none"
as="h2"
$color="#000091"
$zIndex={1}
$size="1.30rem"
$css="font-family: 'Marianne'"
>
{t('Docs')}
</Text>
</Box>
</StyledLink>
{isResponsive && (
<Button
size="medium"
onClick={toggleMobileMenu}
aria-label={t('Open the header menu')}
color="primary-text"
icon={<Icon iconName="menu" />}
/>
)}
<StyledLink href="/">
<Box
$align="center"
$gap={(tokens.spacings?.['100V'] as string) ?? '0.8rem'}
$direction="row"
$position="relative"
$height="fit-content"
$margin={{ top: 'auto' }}
>
<Image priority src={IconDocs} alt={t('Docs Logo')} width={25} />
<Text
$margin="none"
as="h2"
$color="#000091"
$zIndex={1}
$size="1.30rem"
$css="font-family: 'Marianne'"
>
{t('Docs')}
</Text>
</Box>
{isSmallMobile ? (
<Box $direction="row" $gap="2rem">
<LaGaufre />
<DropdownMenu />
</Box>
) : (
<Box $align="center" $gap="2vw" $direction="row">
<ButtonLogin />
<LanguagePicker />
<LaGaufre />
</Box>
)}
</Box>
</StyledLink>
{isResponsive ? (
<Box
$direction="row"
$gap={(tokens.spacings?.['300V'] as string) ?? '0.625rem'}
>
<LaGaufre />
</Box>
) : (
<Box
$align="center"
$gap={(tokens.spacings?.['300V'] as string) ?? '0.625rem'}
$direction="row"
>
<ButtonLogin />
<LanguagePicker />
<LaGaufre />
</Box>
)}
</Box>
);
};
@@ -0,0 +1 @@
export const HEADER_HEIGHT = 52;
@@ -0,0 +1,114 @@
import { Button } from '@openfun/cunningham-react';
import { useRouter } from 'next/navigation';
import { PropsWithChildren, ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Icon } from '@/components';
import { HorizontalSeparator } from '@/components/separators/HorizontalSeparator';
import { SeparatedSection } from '@/components/separators/SeparatedSection';
import { ButtonLogin } from '@/core';
import { useCunninghamTheme } from '@/cunningham';
import { useCreateDoc } from '@/features/docs';
import { HEADER_HEIGHT } from '@/features/header/conf';
import { LanguagePicker } from '@/features/language';
import { useResponsiveStore } from '@/stores';
export const LeftPanel = ({ children }: PropsWithChildren) => {
const { t } = useTranslation();
const router = useRouter();
const { isResponsive, isMobileMenuOpen, toggleMobileMenu } =
useResponsiveStore();
const theme = useCunninghamTheme();
const colors = theme.colorsTokens();
const { mutate: createDoc } = useCreateDoc({
onSuccess: (doc) => {
router.push(`/docs/${doc.id}`);
toggleMobileMenu();
},
});
const goToHome = () => {
router.push('/');
toggleMobileMenu();
};
const createNewDoc = () => {
createDoc({ title: t('Untitled document') });
};
const getContent = (): ReactNode => {
return (
<div>
<SeparatedSection>
<Box
$padding={{ horizontal: '300V' }}
$direction="row"
$justify="space-between"
$align="center"
>
<Box $direction="row" $gap="2px">
<Button
onClick={goToHome}
size="medium"
color="primary-text"
icon={<Icon iconName="house" />}
/>
</Box>
<Button onClick={createNewDoc}>{t('New doc')}</Button>
</Box>
</SeparatedSection>
{children}
</div>
);
};
return (
<>
{!isResponsive && (
<Box
data-testid="left-panel-desktop"
$css={`
height: calc(100vh - ${HEADER_HEIGHT}px);
width: 300px;
min-width: 300px;
border-right: 1px solid ${(colors?.['greyscale-200'] as string) ?? '#E5E5E5'};
`}
>
{getContent()}
</Box>
)}
{isResponsive && (
<Box
data-testid="left-panel-mobile"
$css={`
z-index: 1000;
width: 100dvw;
height: calc(100dvh - ${HEADER_HEIGHT}px);
position: fixed;
transition: 0.15s;
background-color: ${(colors?.['greyscale-000'] as string) ?? '#fff'};
left: ${isMobileMenuOpen ? '0' : '-100dvw'};
`}
>
{getContent()}
<Box
$css={`
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: var(--c--theme--spacings--200W);
`}
>
{children && <HorizontalSeparator />}
<ButtonLogin />
<LanguagePicker />
</Box>
</Box>
)}
</>
);
};
@@ -0,0 +1 @@
export const LEFT_PANEL_WIDTH = 300;
@@ -2,7 +2,7 @@ export type RequestData = {
url: string;
method?: string;
headers: Record<string, string>;
body?: ArrayBufferLike;
body?: ArrayBuffer;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
};
@@ -48,12 +48,12 @@ export class RequestSerializer {
return new RequestSerializer(requestData);
}
public static arrayBufferToString(buffer: ArrayBufferLike) {
public static arrayBufferToString(buffer: ArrayBuffer) {
const decoder = new TextDecoder();
return decoder.decode(buffer as ArrayBuffer);
return decoder.decode(buffer);
}
public static arrayBufferToJson<T>(buffer: ArrayBufferLike) {
public static arrayBufferToJson<T>(buffer: ArrayBuffer) {
const jsonString = RequestSerializer.arrayBufferToString(buffer);
return JSON.parse(jsonString) as T;
}
@@ -64,9 +64,7 @@ export class RequestSerializer {
}
public static objectToArrayBuffer(ob: Record<string, unknown>) {
return RequestSerializer.stringToArrayBuffer(
JSON.stringify(ob),
) as ArrayBuffer;
return RequestSerializer.stringToArrayBuffer(JSON.stringify(ob));
}
constructor(requestData: RequestData) {
@@ -87,7 +85,7 @@ export class RequestSerializer {
toRequest(): Request {
const { url, ...rest } = this._requestData;
return new Request(url, { ...rest, body: rest.body as BodyInit });
return new Request(url, rest);
}
clone(): RequestSerializer {
@@ -1,36 +1,62 @@
import { PropsWithChildren } from 'react';
import { PropsWithChildren, ReactNode } from 'react';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Footer } from '@/features/footer';
import { Header } from '@/features/header';
import { HEADER_HEIGHT } from '@/features/header/conf';
import { LeftPanel } from '@/features/left-pannel/components/LeftPanel';
import { useResponsiveStore } from '@/stores';
interface MainLayoutProps {
withoutFooter?: boolean;
export enum MainLayoutBackgroundColor {
WHITE = 'white',
GREY = 'grey',
}
type MainLayoutProps = {
backgroundColor?: MainLayoutBackgroundColor;
leftPanelContent?: ReactNode;
};
export function MainLayout({
children,
withoutFooter,
backgroundColor = MainLayoutBackgroundColor.WHITE,
leftPanelContent,
}: PropsWithChildren<MainLayoutProps>) {
const { colorsTokens } = useCunninghamTheme();
const { isResponsive } = useResponsiveStore();
const { themeTokens, colorsTokens } = useCunninghamTheme();
const tokens = themeTokens();
const colors = colorsTokens();
return (
<Box>
<Box $minHeight="100vh">
<Header />
<Box $css="flex: 1;" $direction="row">
<Box
as="main"
$minHeight="100vh"
$width="100%"
$background={colorsTokens()['primary-bg']}
>
{children}
</Box>
<div>
<Header />
<Box $direction="row" $width="100%">
<LeftPanel>{leftPanelContent}</LeftPanel>
<Box
as="main"
id="mainContent"
$padding={{
vertical: !isResponsive
? ((tokens.spacings?.['200W'] as string) ?? '1.12rem')
: ((tokens.spacings?.['100V'] as string) ?? '0.5rem'),
horizontal: !isResponsive
? ((tokens.spacings?.['1200W'] as string) ?? '5.62rem')
: ((tokens.spacings?.['100V'] as string) ?? '0.5rem'),
}}
$css={`
display: flex;
width: 100%;
flex-direction: column;
align-items: center;
flex: 1;
height: calc(100dvh - ${HEADER_HEIGHT}px);
overflow-y: scroll;
background-color: ${backgroundColor === MainLayoutBackgroundColor.WHITE ? colors['greyscale-000'] : colors['greyscale-050']};
`}
>
{children}
</Box>
</Box>
{!withoutFooter && <Footer />}
</Box>
</div>
);
}
@@ -1,12 +1,12 @@
import { Loader } from '@openfun/cunningham-react';
import { useQueryClient } from '@tanstack/react-query';
import Head from 'next/head';
import { useRouter as useNavigate } from 'next/navigation';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { Box, Text } from '@/components';
import { TextErrors } from '@/components/TextErrors';
import { useCollaborationUrl } from '@/core';
import { useAuthStore } from '@/core/auth';
import { DocEditor } from '@/features/docs/doc-editor';
import { KEY_DOC, useDoc, useDocStore } from '@/features/docs/doc-management';
@@ -28,7 +28,7 @@ export function DocLayout() {
<Head>
<meta name="robots" content="noindex" />
</Head>
<MainLayout withoutFooter>
<MainLayout>
<DocPage id={id} />
</MainLayout>
</>
@@ -43,12 +43,11 @@ const DocPage = ({ id }: DocProps) => {
const { login } = useAuthStore();
const { data: docQuery, isError, error } = useDoc({ id });
const [doc, setDoc] = useState(docQuery);
const { setCurrentDoc, createProvider, providers } = useDocStore();
const { setCurrentDoc, createProvider, docsStore } = useDocStore();
const { setBroadcastProvider, addTask } = useBroadcastStore();
const queryClient = useQueryClient();
const { replace } = useRouter();
const provider = providers?.[id];
const collaborationUrl = useCollaborationUrl(doc?.id);
const navigate = useNavigate();
const provider = docsStore?.[id]?.provider;
useEffect(() => {
if (doc?.title) {
@@ -72,17 +71,17 @@ const DocPage = ({ id }: DocProps) => {
}, [docQuery, setCurrentDoc]);
useEffect(() => {
if (!doc?.id || !collaborationUrl) {
if (!doc?.id) {
return;
}
let newProvider = provider;
if (!provider || provider.document.guid !== doc.id) {
newProvider = createProvider(collaborationUrl, doc.id, doc.content);
newProvider = createProvider(doc.id, doc.content);
}
setBroadcastProvider(newProvider);
}, [createProvider, doc, provider, setBroadcastProvider, collaborationUrl]);
}, [createProvider, doc, provider, setBroadcastProvider]);
/**
* We add a broadcast task to reset the query cache
@@ -102,7 +101,7 @@ const DocPage = ({ id }: DocProps) => {
if (isError && error) {
if (error.status === 404) {
void replace(`/404`);
navigate.replace(`/404`);
return null;
}
@@ -1,15 +1,19 @@
import type { ReactElement } from 'react';
import { DocsGridContainer } from '@/features/docs/docs-grid';
import { MainLayout } from '@/layouts';
import { DocsGridList } from '@/features/docs/docs-grid/components/DocsGridList';
import { MainLayout, MainLayoutBackgroundColor } from '@/layouts';
import { NextPageWithLayout } from '@/types/next';
const Page: NextPageWithLayout = () => {
return <DocsGridContainer />;
return <DocsGridList />;
};
Page.getLayout = function getLayout(page: ReactElement) {
return <MainLayout>{page}</MainLayout>;
return (
<MainLayout backgroundColor={MainLayoutBackgroundColor.GREY}>
{page}
</MainLayout>
);
};
export default Page;
@@ -1,31 +0,0 @@
/**
* Configure Crisp chat for real-time support across all pages.
*/
import { Crisp } from 'crisp-sdk-web';
import { User } from '@/core';
export const initializeCrispSession = (user: User) => {
if (!Crisp.isCrispInjected()) {
return;
}
Crisp.setTokenId(`impress-${user.id}`);
Crisp.user.setEmail(user.email);
};
export const configureCrispSession = (websiteId: string) => {
if (Crisp.isCrispInjected()) {
return;
}
Crisp.configure(websiteId);
Crisp.setSafeMode(true);
};
export const terminateCrispSession = () => {
if (!Crisp.isCrispInjected()) {
return;
}
Crisp.setTokenId();
Crisp.session.reset();
};
@@ -1 +0,0 @@
export * from './Crisp';

Some files were not shown because too many files have changed in this diff Show More