Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a4d968eff3 | |||
| 12f7c8b371 | |||
| 70e3e9e7d4 | |||
| 296dbb7957 | |||
| 3827f0f799 | |||
| d89e3dc6d4 | |||
| 91cf5f9367 | |||
| e0426ca803 | |||
| a01790dc0b | |||
| e14622ff66 | |||
| 75fd994a5b | |||
| 239933aef3 | |||
| 95c06d68cb | |||
| 0d12278474 | |||
| 8ce13075c1 | |||
| 225f5a8abb | |||
| 71a8765770 | |||
| 9186a101ec | |||
| 74e816c479 | |||
| 5d9eb2d694 | |||
| 33e168ba17 | |||
| f1c0f6bba0 | |||
| 8f4fd15495 | |||
| cc6ce4a945 | |||
| 044c8f0bbd | |||
| 7e62dcf1fc | |||
| e7742d914c | |||
| b1a5c17d75 | |||
| bd68396e52 | |||
| 9f1ae58ead | |||
| f597549e96 | |||
| 847e120d67 | |||
| 6682ddafff | |||
| 2f23404003 | |||
| 6d4210d34b | |||
| 85f7598be8 |
@@ -9,16 +9,22 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.2.0] - 2025-02-10
|
||||
|
||||
## Added
|
||||
|
||||
- 📝(doc) Add security.md and codeofconduct.md #604
|
||||
- ✨(frontend) add home page #553
|
||||
- ✨(frontend) add home page #608
|
||||
- ✨(frontend) cursor display on activity #609
|
||||
- ✨(frontend) Add export page break #623
|
||||
|
||||
## Changed
|
||||
|
||||
- 🔧(backend) make AI feature reach configurable #628
|
||||
|
||||
## Fixed
|
||||
|
||||
🌐(CI) Fix email partially translated #616
|
||||
- 🌐(CI) Fix email partially translated #616
|
||||
- 🐛(frontend) fix cursor breakline #609
|
||||
- 🐛(frontend) fix style pdf export #609
|
||||
|
||||
@@ -405,7 +411,8 @@ and this project adheres to
|
||||
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
||||
|
||||
|
||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.1.0...main
|
||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.2.0...main
|
||||
[v2.2.0]: https://github.com/numerique-gouv/impress/releases/v2.2.0
|
||||
[v2.1.0]: https://github.com/numerique-gouv/impress/releases/v2.1.0
|
||||
[v2.0.1]: https://github.com/numerique-gouv/impress/releases/v2.0.1
|
||||
[v2.0.0]: https://github.com/numerique-gouv/impress/releases/v2.0.0
|
||||
|
||||
@@ -15,3 +15,8 @@ the following command inside your docker container:
|
||||
(Note : in your development environment, you can `make migrate`.)
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- AI features are now limited to users who are authenticated. Before this release, even anonymous
|
||||
users who gained editor access on a document with link reach used to get AI feature.
|
||||
IF you want anonymous users to keep access on AI features, you must now define the
|
||||
`AI_ALLOW_REACH_FROM` setting to "public".
|
||||
|
||||
@@ -15,7 +15,7 @@ services:
|
||||
- "1081:1080"
|
||||
|
||||
minio:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
# user: ${DOCKER_USER:-1000}
|
||||
image: minio/minio
|
||||
environment:
|
||||
- MINIO_ROOT_USER=impress
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@blocknote/core": "^0.23.4",
|
||||
"next": "^15.1.7"
|
||||
}
|
||||
}
|
||||
@@ -629,6 +629,9 @@ class Document(MP_Node, BaseModel):
|
||||
# which date to allow them anyway)
|
||||
# Anonymous users should also not see document accesses
|
||||
has_access_role = bool(roles) and not is_deleted
|
||||
can_update_from_access = (
|
||||
is_owner_or_admin or RoleChoices.EDITOR in roles
|
||||
) and not is_deleted
|
||||
|
||||
# Add roles provided by the document link, taking into account its ancestors
|
||||
|
||||
@@ -647,11 +650,23 @@ class Document(MP_Node, BaseModel):
|
||||
is_owner_or_admin or RoleChoices.EDITOR in roles
|
||||
) and not is_deleted
|
||||
|
||||
ai_allow_reach_from = settings.AI_ALLOW_REACH_FROM
|
||||
ai_access = any(
|
||||
[
|
||||
ai_allow_reach_from == LinkReachChoices.PUBLIC and can_update,
|
||||
ai_allow_reach_from == LinkReachChoices.AUTHENTICATED
|
||||
and user.is_authenticated
|
||||
and can_update,
|
||||
ai_allow_reach_from == LinkReachChoices.RESTRICTED
|
||||
and can_update_from_access,
|
||||
]
|
||||
)
|
||||
|
||||
return {
|
||||
"accesses_manage": is_owner_or_admin,
|
||||
"accesses_view": has_access_role,
|
||||
"ai_transform": can_update,
|
||||
"ai_translate": can_update,
|
||||
"ai_transform": ai_access,
|
||||
"ai_translate": ai_access,
|
||||
"attachment_upload": can_update,
|
||||
"children_list": can_get,
|
||||
"children_create": can_update and user.is_authenticated,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
Test AI transform API endpoint for users in impress's core app.
|
||||
"""
|
||||
|
||||
import random
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.core.cache import cache
|
||||
@@ -31,6 +32,9 @@ def ai_settings():
|
||||
yield
|
||||
|
||||
|
||||
@override_settings(
|
||||
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"reach, role",
|
||||
[
|
||||
@@ -57,6 +61,7 @@ def test_api_documents_ai_transform_anonymous_forbidden(reach, role):
|
||||
}
|
||||
|
||||
|
||||
@override_settings(AI_ALLOW_REACH_FROM="public")
|
||||
@pytest.mark.usefixtures("ai_settings")
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_documents_ai_transform_anonymous_success(mock_create):
|
||||
@@ -93,6 +98,27 @@ def test_api_documents_ai_transform_anonymous_success(mock_create):
|
||||
)
|
||||
|
||||
|
||||
@override_settings(AI_ALLOW_REACH_FROM=random.choice(["authenticated", "restricted"]))
|
||||
@pytest.mark.usefixtures("ai_settings")
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_documents_ai_transform_anonymous_limited_by_setting(mock_create):
|
||||
"""
|
||||
Anonymous users should be able to request AI transform to a document
|
||||
if the link reach and role permit it.
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach="public", link_role="editor")
|
||||
|
||||
answer = '{"answer": "Salut"}'
|
||||
mock_create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content=answer))]
|
||||
)
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
|
||||
response = APIClient().post(url, {"text": "Hello", "action": "summarize"})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, role",
|
||||
[
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
Test AI translate API endpoint for users in impress's core app.
|
||||
"""
|
||||
|
||||
import random
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.core.cache import cache
|
||||
@@ -51,6 +52,9 @@ def test_api_documents_ai_translate_viewset_options_metadata():
|
||||
}
|
||||
|
||||
|
||||
@override_settings(
|
||||
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"reach, role",
|
||||
[
|
||||
@@ -77,6 +81,7 @@ def test_api_documents_ai_translate_anonymous_forbidden(reach, role):
|
||||
}
|
||||
|
||||
|
||||
@override_settings(AI_ALLOW_REACH_FROM="public")
|
||||
@pytest.mark.usefixtures("ai_settings")
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_documents_ai_translate_anonymous_success(mock_create):
|
||||
@@ -113,6 +118,27 @@ def test_api_documents_ai_translate_anonymous_success(mock_create):
|
||||
)
|
||||
|
||||
|
||||
@override_settings(AI_ALLOW_REACH_FROM=random.choice(["authenticated", "restricted"]))
|
||||
@pytest.mark.usefixtures("ai_settings")
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_documents_ai_translate_anonymous_limited_by_setting(mock_create):
|
||||
"""
|
||||
Anonymous users should be able to request AI translate to a document
|
||||
if the link reach and role permit it.
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach="public", link_role="editor")
|
||||
|
||||
answer = '{"answer": "Salut"}'
|
||||
mock_create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content=answer))]
|
||||
)
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
|
||||
response = APIClient().post(url, {"text": "Hello", "language": "es"})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, role",
|
||||
[
|
||||
|
||||
@@ -28,8 +28,8 @@ def test_api_documents_retrieve_anonymous_public_standalone():
|
||||
"abilities": {
|
||||
"accesses_manage": False,
|
||||
"accesses_view": False,
|
||||
"ai_transform": document.link_role == "editor",
|
||||
"ai_translate": document.link_role == "editor",
|
||||
"ai_transform": False,
|
||||
"ai_translate": False,
|
||||
"attachment_upload": document.link_role == "editor",
|
||||
"children_create": False,
|
||||
"children_list": True,
|
||||
@@ -84,8 +84,8 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
||||
"abilities": {
|
||||
"accesses_manage": False,
|
||||
"accesses_view": False,
|
||||
"ai_transform": grand_parent.link_role == "editor",
|
||||
"ai_translate": grand_parent.link_role == "editor",
|
||||
"ai_transform": False,
|
||||
"ai_translate": False,
|
||||
"attachment_upload": grand_parent.link_role == "editor",
|
||||
"children_create": False,
|
||||
"children_list": True,
|
||||
|
||||
@@ -12,6 +12,7 @@ from django.core import mail
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.test.utils import override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
@@ -124,6 +125,9 @@ def test_models_documents_soft_delete(depth):
|
||||
# get_abilities
|
||||
|
||||
|
||||
@override_settings(
|
||||
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"is_authenticated,reach,role",
|
||||
[
|
||||
@@ -175,6 +179,9 @@ def test_models_documents_get_abilities_forbidden(
|
||||
assert document.get_abilities(user) == expected_abilities
|
||||
|
||||
|
||||
@override_settings(
|
||||
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"is_authenticated,reach",
|
||||
[
|
||||
@@ -243,8 +250,8 @@ def test_models_documents_get_abilities_editor(
|
||||
expected_abilities = {
|
||||
"accesses_manage": False,
|
||||
"accesses_view": False,
|
||||
"ai_transform": True,
|
||||
"ai_translate": True,
|
||||
"ai_transform": is_authenticated,
|
||||
"ai_translate": is_authenticated,
|
||||
"attachment_upload": True,
|
||||
"children_create": is_authenticated,
|
||||
"children_list": True,
|
||||
@@ -271,6 +278,9 @@ def test_models_documents_get_abilities_editor(
|
||||
assert all(value is False for value in document.get_abilities(user).values())
|
||||
|
||||
|
||||
@override_settings(
|
||||
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
|
||||
)
|
||||
def test_models_documents_get_abilities_owner(django_assert_num_queries):
|
||||
"""Check abilities returned for the owner of a document."""
|
||||
user = factories.UserFactory()
|
||||
@@ -300,12 +310,16 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
|
||||
}
|
||||
with django_assert_num_queries(1):
|
||||
assert document.get_abilities(user) == expected_abilities
|
||||
|
||||
document.soft_delete()
|
||||
document.refresh_from_db()
|
||||
expected_abilities["move"] = False
|
||||
assert document.get_abilities(user) == expected_abilities
|
||||
|
||||
|
||||
@override_settings(
|
||||
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
|
||||
)
|
||||
def test_models_documents_get_abilities_administrator(django_assert_num_queries):
|
||||
"""Check abilities returned for the administrator of a document."""
|
||||
user = factories.UserFactory()
|
||||
@@ -335,11 +349,15 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
|
||||
}
|
||||
with django_assert_num_queries(1):
|
||||
assert document.get_abilities(user) == expected_abilities
|
||||
|
||||
document.soft_delete()
|
||||
document.refresh_from_db()
|
||||
assert all(value is False for value in document.get_abilities(user).values())
|
||||
|
||||
|
||||
@override_settings(
|
||||
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
|
||||
)
|
||||
def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
|
||||
"""Check abilities returned for the editor of a document."""
|
||||
user = factories.UserFactory()
|
||||
@@ -369,23 +387,31 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
|
||||
}
|
||||
with django_assert_num_queries(1):
|
||||
assert document.get_abilities(user) == expected_abilities
|
||||
|
||||
document.soft_delete()
|
||||
document.refresh_from_db()
|
||||
assert all(value is False for value in document.get_abilities(user).values())
|
||||
|
||||
|
||||
def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
|
||||
@pytest.mark.parametrize("ai_access_setting", ["public", "authenticated", "restricted"])
|
||||
def test_models_documents_get_abilities_reader_user(
|
||||
ai_access_setting, django_assert_num_queries
|
||||
):
|
||||
"""Check abilities returned for the reader of a document."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(users=[(user, "reader")])
|
||||
|
||||
access_from_link = (
|
||||
document.link_reach != "restricted" and document.link_role == "editor"
|
||||
)
|
||||
|
||||
expected_abilities = {
|
||||
"accesses_manage": False,
|
||||
"accesses_view": True,
|
||||
"ai_transform": access_from_link,
|
||||
"ai_translate": access_from_link,
|
||||
# If you get your editor rights from the link role and not your access role
|
||||
# You should not access AI if it's restricted to users with specific access
|
||||
"ai_transform": access_from_link and ai_access_setting != "restricted",
|
||||
"ai_translate": access_from_link and ai_access_setting != "restricted",
|
||||
"attachment_upload": access_from_link,
|
||||
"children_create": access_from_link,
|
||||
"children_list": True,
|
||||
@@ -404,11 +430,14 @@ def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
|
||||
"versions_list": True,
|
||||
"versions_retrieve": True,
|
||||
}
|
||||
with django_assert_num_queries(1):
|
||||
assert document.get_abilities(user) == expected_abilities
|
||||
document.soft_delete()
|
||||
document.refresh_from_db()
|
||||
assert all(value is False for value in document.get_abilities(user).values())
|
||||
|
||||
with override_settings(AI_ALLOW_REACH_FROM=ai_access_setting):
|
||||
with django_assert_num_queries(1):
|
||||
assert document.get_abilities(user) == expected_abilities
|
||||
|
||||
document.soft_delete()
|
||||
document.refresh_from_db()
|
||||
assert all(value is False for value in document.get_abilities(user).values())
|
||||
|
||||
|
||||
def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
|
||||
@@ -446,6 +475,44 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
|
||||
}
|
||||
|
||||
|
||||
@override_settings(AI_ALLOW_REACH_FROM="public")
|
||||
@pytest.mark.parametrize(
|
||||
"is_authenticated,reach",
|
||||
[
|
||||
(True, "public"),
|
||||
(False, "public"),
|
||||
(True, "authenticated"),
|
||||
],
|
||||
)
|
||||
def test_models_document_get_abilities_ai_access_authenticated(is_authenticated, reach):
|
||||
"""Validate AI abilities when AI is available to any anonymous user with editor rights."""
|
||||
user = factories.UserFactory() if is_authenticated else AnonymousUser()
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role="editor")
|
||||
|
||||
abilities = document.get_abilities(user)
|
||||
assert abilities["ai_transform"] is True
|
||||
assert abilities["ai_translate"] is True
|
||||
|
||||
|
||||
@override_settings(AI_ALLOW_REACH_FROM="authenticated")
|
||||
@pytest.mark.parametrize(
|
||||
"is_authenticated,reach",
|
||||
[
|
||||
(True, "public"),
|
||||
(False, "public"),
|
||||
(True, "authenticated"),
|
||||
],
|
||||
)
|
||||
def test_models_document_get_abilities_ai_access_public(is_authenticated, reach):
|
||||
"""Validate AI abilities when AI is available only to authenticated users with editor rights."""
|
||||
user = factories.UserFactory() if is_authenticated else AnonymousUser()
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role="editor")
|
||||
|
||||
abilities = document.get_abilities(user)
|
||||
assert abilities["ai_transform"] == is_authenticated
|
||||
assert abilities["ai_translate"] == is_authenticated
|
||||
|
||||
|
||||
def test_models_documents_get_versions_slice_pagination(settings):
|
||||
"""
|
||||
The "get_versions_slice" method should allow navigating all versions of
|
||||
|
||||
@@ -516,7 +516,12 @@ class Base(Configuration):
|
||||
AI_API_KEY = values.Value(None, environ_name="AI_API_KEY", environ_prefix=None)
|
||||
AI_BASE_URL = values.Value(None, environ_name="AI_BASE_URL", environ_prefix=None)
|
||||
AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None)
|
||||
|
||||
AI_ALLOW_REACH_FROM = values.Value(
|
||||
choices=("public", "authenticated", "restricted"),
|
||||
default="authenticated",
|
||||
environ_name="AI_ALLOW_REACH_FROM",
|
||||
environ_prefix=None,
|
||||
)
|
||||
AI_DOCUMENT_RATE_THROTTLE_RATES = {
|
||||
"minute": 5,
|
||||
"hour": 100,
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "impress"
|
||||
version = "2.1.0"
|
||||
version = "2.2.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable playwright/no-conditional-expect */
|
||||
/* eslint-disable playwright/no-conditional-in-test */
|
||||
import path from 'path';
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
@@ -368,4 +370,77 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
await expect(editor.getByText('Bonjour le monde')).toBeVisible();
|
||||
});
|
||||
|
||||
[
|
||||
{ ai_transform: false, ai_translate: false },
|
||||
{ ai_transform: true, ai_translate: false },
|
||||
{ ai_transform: false, ai_translate: true },
|
||||
].forEach(({ ai_transform, ai_translate }) => {
|
||||
test(`it checks AI buttons when can transform is at "${ai_transform}" and can translate is at "${ai_translate}"`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockedDocument(page, {
|
||||
accesses: [
|
||||
{
|
||||
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
|
||||
role: 'owner',
|
||||
user: {
|
||||
email: 'super@owner.com',
|
||||
full_name: 'Super Owner',
|
||||
},
|
||||
},
|
||||
],
|
||||
abilities: {
|
||||
destroy: true, // Means owner
|
||||
link_configuration: true,
|
||||
ai_transform,
|
||||
ai_translate,
|
||||
accesses_manage: true,
|
||||
accesses_view: true,
|
||||
update: true,
|
||||
partial_update: true,
|
||||
retrieve: true,
|
||||
},
|
||||
link_reach: 'public',
|
||||
link_role: 'editor',
|
||||
created_at: '2021-09-01T09:00:00Z',
|
||||
});
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
await verifyDocName(page, 'Mocked document');
|
||||
|
||||
await page.locator('.bn-block-outer').last().fill('Hello World');
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.getByText('Hello').dblclick();
|
||||
|
||||
if (!ai_transform && !ai_translate) {
|
||||
await expect(page.getByRole('button', { name: 'AI' })).toBeHidden();
|
||||
return;
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'AI' }).click();
|
||||
|
||||
if (ai_transform) {
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Use as prompt' }),
|
||||
).toBeVisible();
|
||||
} else {
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Use as prompt' }),
|
||||
).toBeHidden();
|
||||
}
|
||||
|
||||
if (ai_translate) {
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Language' }),
|
||||
).toBeVisible();
|
||||
} else {
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Language' }),
|
||||
).toBeHidden();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ test.describe('Header', () => {
|
||||
test('checks all the elements are visible', async ({ page }) => {
|
||||
const header = page.locator('header').first();
|
||||
|
||||
await expect(header.getByAltText('Docs Logo')).toBeVisible();
|
||||
await expect(header.getByLabel('Docs Logo')).toBeVisible();
|
||||
await expect(header.locator('h2').getByText('Docs')).toHaveCSS(
|
||||
'color',
|
||||
'rgb(0, 0, 145)',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-e2e",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "eslint . --ext .ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-impress",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FocusScope } from '@react-aria/focus';
|
||||
import {
|
||||
PropsWithChildren,
|
||||
ReactNode,
|
||||
@@ -6,16 +7,16 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Button, Popover } from 'react-aria-components';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledPopover = styled(Popover)`
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.1);
|
||||
|
||||
border: 1px solid #dddddd;
|
||||
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
padding: 1rem;
|
||||
`;
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
@@ -29,6 +30,10 @@ const StyledButton = styled(Button)`
|
||||
font-size: 0.938rem;
|
||||
padding: 0;
|
||||
text-wrap: nowrap;
|
||||
|
||||
&:focus-within {
|
||||
outline: 2px solid #007bff;
|
||||
}
|
||||
`;
|
||||
|
||||
export interface DropButtonProps {
|
||||
@@ -43,15 +48,17 @@ export const DropButton = ({
|
||||
isOpen = false,
|
||||
onOpenChange,
|
||||
children,
|
||||
label,
|
||||
}: PropsWithChildren<DropButtonProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const [isLocalOpen, setIsLocalOpen] = useState(isOpen);
|
||||
|
||||
const triggerRef = useRef(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const firstFocusableRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLocalOpen(isOpen);
|
||||
}, [isOpen]);
|
||||
if (isLocalOpen && firstFocusableRef.current) {
|
||||
firstFocusableRef.current.focus();
|
||||
}
|
||||
}, [isLocalOpen]);
|
||||
|
||||
const onOpenChangeHandler = (isOpen: boolean) => {
|
||||
setIsLocalOpen(isOpen);
|
||||
@@ -63,18 +70,30 @@ export const DropButton = ({
|
||||
<StyledButton
|
||||
ref={triggerRef}
|
||||
onPress={() => onOpenChangeHandler(true)}
|
||||
aria-label={label}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={isLocalOpen}
|
||||
aria-label={t('Open the document options')}
|
||||
>
|
||||
{button}
|
||||
<span aria-hidden="true">{button}</span>
|
||||
</StyledButton>
|
||||
|
||||
<StyledPopover
|
||||
triggerRef={triggerRef}
|
||||
isOpen={isLocalOpen}
|
||||
onOpenChange={onOpenChangeHandler}
|
||||
>
|
||||
{children}
|
||||
</StyledPopover>
|
||||
{isLocalOpen && (
|
||||
<StyledPopover
|
||||
triggerRef={triggerRef}
|
||||
isOpen={isLocalOpen}
|
||||
onOpenChange={onOpenChangeHandler}
|
||||
>
|
||||
<FocusScope contain restoreFocus>
|
||||
{children}
|
||||
<button
|
||||
ref={firstFocusableRef}
|
||||
onClick={() => setIsLocalOpen(false)}
|
||||
>
|
||||
{t('Close the modal')}
|
||||
</button>
|
||||
</FocusScope>
|
||||
</StyledPopover>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//import { t } from 'i18next';
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
|
||||
@@ -47,7 +47,11 @@ export const QuickSearchInput = ({
|
||||
$gap={spacing['2xs']}
|
||||
$padding={{ all: 'base' }}
|
||||
>
|
||||
{!loading && <Icon iconName="search" $variation="600" />}
|
||||
{!loading && (
|
||||
<span aria-hidden="true">
|
||||
<Icon iconName="search" $variation="600" />
|
||||
</span>
|
||||
)}
|
||||
{loading && (
|
||||
<div>
|
||||
<Loader size="small" />
|
||||
|
||||
@@ -203,6 +203,7 @@ input:-webkit-autofill:focus {
|
||||
|
||||
.c__select__wrapper .c__select__inner__actions__open:focus {
|
||||
outline: none;
|
||||
|
||||
}
|
||||
|
||||
.c__select__wrapper .labelled-box__label.c__offscreen {
|
||||
@@ -605,3 +606,31 @@ input:-webkit-autofill:focus {
|
||||
.c__tooltip {
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
/**
|
||||
* lecture ou non des icons
|
||||
*/
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
[data-icon]:before {
|
||||
font-family: 'Material Icons';
|
||||
content: attr(data-icon);
|
||||
}
|
||||
|
||||
button:focus {
|
||||
background-color: var(
|
||||
--c--components--button--primary-text--background--color-hover
|
||||
);
|
||||
border-radius: var(--c--components--button--border-radius--focus);
|
||||
box-shadow: 0 0 0 2px var(--c--theme--colors--primary-400)
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import Image from 'next/image';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { BoxButton } from '@/components';
|
||||
|
||||
import ProConnectImg from '../assets/button-proconnect.svg?url';
|
||||
import ProConnectImg from '../assets/button-proconnect.svg';
|
||||
import { useAuth } from '../hooks';
|
||||
import { gotoLogin, gotoLogout } from '../utils';
|
||||
|
||||
@@ -41,8 +40,9 @@ export const ProConnectButton = () => {
|
||||
background-color: var(--c--theme--colors--primary-action);
|
||||
}
|
||||
`}
|
||||
$radius="4px"
|
||||
>
|
||||
<Image src={ProConnectImg} alt={t('ProConnect Image')} />
|
||||
<ProConnectImg />
|
||||
</BoxButton>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -92,6 +92,13 @@ export function AIGroupButton() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canAITransform = currentDoc.abilities.ai_transform;
|
||||
const canAITranslate = currentDoc.abilities.ai_translate;
|
||||
|
||||
if (!canAITransform && !canAITranslate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Components.Generic.Menu.Root>
|
||||
<Components.Generic.Menu.Trigger>
|
||||
@@ -111,79 +118,85 @@ export function AIGroupButton() {
|
||||
className="bn-menu-dropdown bn-drag-handle-menu"
|
||||
sub={true}
|
||||
>
|
||||
<AIMenuItemTransform
|
||||
action="prompt"
|
||||
docId={currentDoc.id}
|
||||
icon={
|
||||
<Text $isMaterialIcon $size="s">
|
||||
text_fields
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
{t('Use as prompt')}
|
||||
</AIMenuItemTransform>
|
||||
<AIMenuItemTransform
|
||||
action="rephrase"
|
||||
docId={currentDoc.id}
|
||||
icon={
|
||||
<Text $isMaterialIcon $size="s">
|
||||
refresh
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
{t('Rephrase')}
|
||||
</AIMenuItemTransform>
|
||||
<AIMenuItemTransform
|
||||
action="summarize"
|
||||
docId={currentDoc.id}
|
||||
icon={
|
||||
<Text $isMaterialIcon $size="s">
|
||||
summarize
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
{t('Summarize')}
|
||||
</AIMenuItemTransform>
|
||||
<AIMenuItemTransform
|
||||
action="correct"
|
||||
docId={currentDoc.id}
|
||||
icon={
|
||||
<Text $isMaterialIcon $size="s">
|
||||
check
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
{t('Correct')}
|
||||
</AIMenuItemTransform>
|
||||
<Components.Generic.Menu.Root position="right" sub={true}>
|
||||
<Components.Generic.Menu.Trigger sub={false}>
|
||||
<Components.Generic.Menu.Item
|
||||
className="bn-menu-item"
|
||||
subTrigger={true}
|
||||
>
|
||||
<Box $direction="row" $gap="0.6rem">
|
||||
{canAITransform && (
|
||||
<>
|
||||
<AIMenuItemTransform
|
||||
action="prompt"
|
||||
docId={currentDoc.id}
|
||||
icon={
|
||||
<Text $isMaterialIcon $size="s">
|
||||
translate
|
||||
text_fields
|
||||
</Text>
|
||||
{t('Language')}
|
||||
</Box>
|
||||
</Components.Generic.Menu.Item>
|
||||
</Components.Generic.Menu.Trigger>
|
||||
<Components.Generic.Menu.Dropdown
|
||||
sub={true}
|
||||
className="bn-menu-dropdown"
|
||||
>
|
||||
{languages.map((language) => (
|
||||
<AIMenuItemTranslate
|
||||
key={language.value}
|
||||
language={language.value}
|
||||
docId={currentDoc.id}
|
||||
}
|
||||
>
|
||||
{t('Use as prompt')}
|
||||
</AIMenuItemTransform>
|
||||
<AIMenuItemTransform
|
||||
action="rephrase"
|
||||
docId={currentDoc.id}
|
||||
icon={
|
||||
<Text $isMaterialIcon $size="s">
|
||||
refresh
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
{t('Rephrase')}
|
||||
</AIMenuItemTransform>
|
||||
<AIMenuItemTransform
|
||||
action="summarize"
|
||||
docId={currentDoc.id}
|
||||
icon={
|
||||
<Text $isMaterialIcon $size="s">
|
||||
summarize
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
{t('Summarize')}
|
||||
</AIMenuItemTransform>
|
||||
<AIMenuItemTransform
|
||||
action="correct"
|
||||
docId={currentDoc.id}
|
||||
icon={
|
||||
<Text $isMaterialIcon $size="s">
|
||||
check
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
{t('Correct')}
|
||||
</AIMenuItemTransform>
|
||||
</>
|
||||
)}
|
||||
{canAITranslate && (
|
||||
<Components.Generic.Menu.Root position="right" sub={true}>
|
||||
<Components.Generic.Menu.Trigger sub={false}>
|
||||
<Components.Generic.Menu.Item
|
||||
className="bn-menu-item"
|
||||
subTrigger={true}
|
||||
>
|
||||
{language.display_name}
|
||||
</AIMenuItemTranslate>
|
||||
))}
|
||||
</Components.Generic.Menu.Dropdown>
|
||||
</Components.Generic.Menu.Root>
|
||||
<Box $direction="row" $gap="0.6rem">
|
||||
<Text $isMaterialIcon $size="s">
|
||||
translate
|
||||
</Text>
|
||||
{t('Language')}
|
||||
</Box>
|
||||
</Components.Generic.Menu.Item>
|
||||
</Components.Generic.Menu.Trigger>
|
||||
<Components.Generic.Menu.Dropdown
|
||||
sub={true}
|
||||
className="bn-menu-dropdown"
|
||||
>
|
||||
{languages.map((language) => (
|
||||
<AIMenuItemTranslate
|
||||
key={language.value}
|
||||
language={language.value}
|
||||
docId={currentDoc.id}
|
||||
>
|
||||
{language.display_name}
|
||||
</AIMenuItemTranslate>
|
||||
))}
|
||||
</Components.Generic.Menu.Dropdown>
|
||||
</Components.Generic.Menu.Root>
|
||||
)}
|
||||
</Components.Generic.Menu.Dropdown>
|
||||
</Components.Generic.Menu.Root>
|
||||
);
|
||||
|
||||
@@ -65,7 +65,7 @@ export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
|
||||
$css="overflow-x: clip; flex: 1;"
|
||||
$position="relative"
|
||||
>
|
||||
<Box $css="flex:1;" $overflow="auto" $position="relative">
|
||||
<Box $css="flex:1;" $position="relative" $width="100%">
|
||||
{isVersion ? (
|
||||
<DocVersionEditor docId={doc.id} versionId={versionId} />
|
||||
) : (
|
||||
|
||||
@@ -11,7 +11,7 @@ export const cssEditor = (readonly: boolean) => css`
|
||||
}
|
||||
.collaboration-cursor-custom__caret {
|
||||
position: absolute;
|
||||
height: 85%;
|
||||
height: 100%;
|
||||
width: 2px;
|
||||
bottom: 4%;
|
||||
left: -1px;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
|
||||
import {
|
||||
Tooltip,
|
||||
VariantType,
|
||||
@@ -114,7 +112,6 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
|
||||
defaultValue={isUntitled ? undefined : titleDisplay}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
suppressContentEditableWarning={true}
|
||||
aria-label="doc title input"
|
||||
onBlurCapture={(event) =>
|
||||
handleTitleSubmit(event.target.textContent || '')
|
||||
}
|
||||
|
||||
@@ -48,10 +48,20 @@ export interface Doc {
|
||||
abilities: {
|
||||
accesses_manage: boolean;
|
||||
accesses_view: boolean;
|
||||
attachment_upload: true;
|
||||
ai_transform: boolean;
|
||||
ai_translate: boolean;
|
||||
attachment_upload: boolean;
|
||||
children_create: boolean;
|
||||
children_list: boolean;
|
||||
collaboration_auth: boolean;
|
||||
destroy: boolean;
|
||||
favorite: boolean;
|
||||
invite_owner: boolean;
|
||||
link_configuration: boolean;
|
||||
media_auth: boolean;
|
||||
move: boolean;
|
||||
partial_update: boolean;
|
||||
restore: boolean;
|
||||
retrieve: boolean;
|
||||
update: boolean;
|
||||
versions_destroy: boolean;
|
||||
|
||||
@@ -51,7 +51,7 @@ export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => {
|
||||
return {
|
||||
groupName: docs.length > 0 ? t('Select a document') : '',
|
||||
elements: search ? docs : [],
|
||||
emptyString: t('No document found'),
|
||||
//emptyString: t('No document found'),
|
||||
endActions: hasNextPage
|
||||
? [{ content: <InView onChange={() => void fetchNextPage()} /> }]
|
||||
: [],
|
||||
@@ -96,6 +96,20 @@ export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => {
|
||||
renderElement={(doc) => <DocSearchItem doc={doc} />}
|
||||
/>
|
||||
)}
|
||||
{/* Message accessible pour les résultats vides */}
|
||||
{search && docsData.elements.length === 0 && (
|
||||
<p
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
marginTop: '1rem',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
{t('No document found')}
|
||||
</p>
|
||||
)}
|
||||
</Box>
|
||||
</QuickSearch>
|
||||
</Box>
|
||||
|
||||
@@ -49,7 +49,6 @@ export const DocsGridActions = ({
|
||||
callback: () => {
|
||||
openShareModal?.();
|
||||
},
|
||||
|
||||
testId: `docs-grid-actions-share-${doc.id}`,
|
||||
},
|
||||
|
||||
@@ -70,6 +69,7 @@ export const DocsGridActions = ({
|
||||
iconName="more_horiz"
|
||||
$theme="primary"
|
||||
$variation="600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</DropdownMenu>
|
||||
|
||||
|
||||
@@ -54,6 +54,9 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
|
||||
$css={css`
|
||||
flex: ${flexLeft};
|
||||
align-items: center;
|
||||
&:focus {
|
||||
outline: 2px solidrgb(33, 34, 82);
|
||||
}
|
||||
`}
|
||||
href={`/docs/${doc.id}`}
|
||||
>
|
||||
@@ -79,7 +82,11 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
|
||||
>
|
||||
<Tooltip
|
||||
content={
|
||||
<Text $textAlign="center" $variation="000">
|
||||
<Text
|
||||
id={`tooltip-access-${doc.id}`}
|
||||
$textAlign="center"
|
||||
$variation="000"
|
||||
>
|
||||
{isPublic
|
||||
? t('Accessible to anyone')
|
||||
: t('Accessible to authenticated users')}
|
||||
@@ -87,12 +94,17 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
|
||||
}
|
||||
placement="top"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-labelledby={`tooltip-access-${doc.id}`}
|
||||
>
|
||||
<Icon
|
||||
$theme="greyscale"
|
||||
$variation="600"
|
||||
$size="14px"
|
||||
iconName={isPublic ? 'public' : 'vpn_lock'}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
@@ -9,6 +9,7 @@ type Props = {
|
||||
doc: Doc;
|
||||
handleClick: () => void;
|
||||
};
|
||||
|
||||
export const DocsGridItemSharedButton = ({ doc, handleClick }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const sharedCount = doc.nb_accesses;
|
||||
@@ -18,11 +19,15 @@ export const DocsGridItemSharedButton = ({ doc, handleClick }: Props) => {
|
||||
return <Box $minWidth="50px"> </Box>;
|
||||
}
|
||||
|
||||
const tooltipContent = t('Shared with {{count}} users', {
|
||||
count: sharedCount,
|
||||
});
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={
|
||||
<Text $textAlign="center" $variation="000">
|
||||
{t('Shared with {{count}} users', { count: sharedCount })}
|
||||
<Text $variation="000" $textAlign="center">
|
||||
{tooltipContent}
|
||||
</Text>
|
||||
}
|
||||
placement="top"
|
||||
@@ -36,8 +41,14 @@ export const DocsGridItemSharedButton = ({ doc, handleClick }: Props) => {
|
||||
}}
|
||||
color="tertiary"
|
||||
size="nano"
|
||||
icon={<Icon $variation="800" $theme="primary" iconName="group" />}
|
||||
aria-label={tooltipContent} // Lecture directe pour les lecteurs d'écran
|
||||
>
|
||||
<Icon
|
||||
$variation="800"
|
||||
$theme="primary"
|
||||
iconName="group"
|
||||
aria-hidden="true" // Empêche la lecture de l'icône
|
||||
/>
|
||||
{sharedCount}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
@@ -47,9 +47,9 @@ export const SimpleDocItem = ({
|
||||
`}
|
||||
>
|
||||
{isPinned ? (
|
||||
<PinnedDocumentIcon aria-label={t('Pin document icon')} />
|
||||
<PinnedDocumentIcon aria-label={t('Pinned document.')} />
|
||||
) : (
|
||||
<SimpleFileIcon aria-label={t('Simple document icon')} />
|
||||
<SimpleFileIcon aria-label="" />
|
||||
)}
|
||||
</Box>
|
||||
<Box $justify="center">
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import Image from 'next/image';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { default as IconDocs } from '@/assets/icons/icon-docs.svg?url';
|
||||
import IconDocs from '@/assets/icons/icon-docs.svg';
|
||||
import { Box, StyledLink } from '@/components/';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { ButtonLogin } from '@/features/auth';
|
||||
@@ -51,7 +50,7 @@ export const Header = () => {
|
||||
$height="fit-content"
|
||||
$margin={{ top: 'auto' }}
|
||||
>
|
||||
<Image priority src={IconDocs} alt={t('Docs Logo')} width={25} />
|
||||
<IconDocs aria-label={t('Docs Logo')} width={25} />
|
||||
<Title />
|
||||
</Box>
|
||||
</StyledLink>
|
||||
|
||||
|
Before Width: | Height: | Size: 342 KiB After Width: | Height: | Size: 401 KiB |
|
Before Width: | Height: | Size: 336 KiB After Width: | Height: | Size: 410 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 407 KiB |
@@ -1,7 +1,7 @@
|
||||
import Image from 'next/image';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { default as IconDocs } from '@/assets/icons/icon-docs.svg?url';
|
||||
import IconDocs from '@/assets/icons/icon-docs.svg';
|
||||
import { Box } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { ButtonTogglePanel, Title } from '@/features/header/';
|
||||
@@ -61,7 +61,7 @@ export const HomeHeader = () => {
|
||||
$position="relative"
|
||||
$height="fit-content"
|
||||
>
|
||||
<Image priority src={IconDocs} alt={t('Docs Logo')} width={32} />
|
||||
<IconDocs aria-label={t('Docs Logo')} width={32} />
|
||||
<Title />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -175,7 +175,7 @@ export const HomeSection = ({
|
||||
height: 'fit-content',
|
||||
margin: 'auto',
|
||||
overflow: 'auto',
|
||||
flexBasis: direction === 'column' ? '100%' : '50%',
|
||||
flexBasis: direction === 'column' ? 'fit-content' : '50%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -54,6 +54,7 @@ export const LanguagePicker = () => {
|
||||
$theme="primary"
|
||||
$weight="bold"
|
||||
$variation="800"
|
||||
aria-hidden="true"
|
||||
>
|
||||
translate
|
||||
</Text>
|
||||
|
||||
@@ -52,6 +52,7 @@ export const LeftPanelTargetFilters = () => {
|
||||
|
||||
return (
|
||||
<Box
|
||||
role="tablist"
|
||||
$justify="center"
|
||||
$padding={{ horizontal: 'sm' }}
|
||||
$gap={spacing['2xs']}
|
||||
@@ -61,7 +62,7 @@ export const LeftPanelTargetFilters = () => {
|
||||
|
||||
return (
|
||||
<BoxButton
|
||||
aria-label={query.label}
|
||||
role="tab"
|
||||
key={query.label}
|
||||
onClick={() => onSelectQuery(query.targetQuery)}
|
||||
$direction="row"
|
||||
@@ -85,6 +86,7 @@ export const LeftPanelTargetFilters = () => {
|
||||
<Icon
|
||||
$variation={isActive ? '1000' : '700'}
|
||||
iconName={query.icon}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Text $variation={isActive ? '1000' : '700'} $size="sm">
|
||||
{query.label}
|
||||
|
||||
@@ -50,8 +50,11 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
|
||||
onClick={goToHome}
|
||||
size="medium"
|
||||
color="tertiary-text"
|
||||
aria-label={t('Back to home page')}
|
||||
icon={
|
||||
<Icon $variation="800" $theme="primary" iconName="house" />
|
||||
<span aria-hidden="true">
|
||||
<Icon $variation="800" $theme="primary" iconName="house" />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{authenticated && (
|
||||
@@ -59,8 +62,15 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
|
||||
onClick={searchModal.open}
|
||||
size="medium"
|
||||
color="tertiary-text"
|
||||
aria-label={t('Search')}
|
||||
icon={
|
||||
<Icon $variation="800" $theme="primary" iconName="search" />
|
||||
<span aria-hidden="true">
|
||||
<Icon
|
||||
$variation="800"
|
||||
$theme="primary"
|
||||
iconName="search"
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -195,10 +195,20 @@ export class ApiPlugin implements WorkboxPlugin {
|
||||
abilities: {
|
||||
accesses_manage: true,
|
||||
accesses_view: true,
|
||||
ai_transform: true,
|
||||
ai_translate: true,
|
||||
attachment_upload: true,
|
||||
children_create: true,
|
||||
children_list: true,
|
||||
collaboration_auth: true,
|
||||
destroy: true,
|
||||
favorite: true,
|
||||
invite_owner: true,
|
||||
link_configuration: true,
|
||||
media_auth: true,
|
||||
move: true,
|
||||
partial_update: true,
|
||||
restore: true,
|
||||
retrieve: true,
|
||||
update: true,
|
||||
versions_destroy: true,
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"AI seems busy! Please try again.": "KI scheint beschäftigt! Bitte versuchen Sie es erneut.",
|
||||
"Accessibility": "Barrierefreiheit",
|
||||
"Accessibility statement": "Erklärung zur Barrierefreiheit",
|
||||
"Accessible to anyone": "Für jeden zugänglich",
|
||||
"Accessible to authenticated users": "Für authentifizierte Benutzer zugänglich",
|
||||
"Add": "Hinzufügen",
|
||||
"Address:": "Anschrift:",
|
||||
"All docs": "Alle Dokumente",
|
||||
@@ -92,6 +94,7 @@
|
||||
"Pending invitations": "Ausstehende Einladungen",
|
||||
"Personal data and cookies": "Personenbezogene Daten und Cookies",
|
||||
"Pin": "Anheften",
|
||||
"Pinned document.": "Angeheftetes Dokument",
|
||||
"Pinned documents": "Angepinnte Dokumente",
|
||||
"Private": "Privat",
|
||||
"Public": "Öffentlich",
|
||||
@@ -118,6 +121,7 @@
|
||||
"Share with {{count}} users_many": "Teilen mit {{count}} Benutzern",
|
||||
"Share with {{count}} users_one": "Teilen mit {{count}} Benutzern",
|
||||
"Share with {{count}} users_other": "Teilen mit {{count}} Benutzern",
|
||||
"Shared with {{count}} users": "Mit {{count}} Benutzern geteilt",
|
||||
"Shared with me": "Mit mir geteilt",
|
||||
"Something bad happens, please retry.": "Etwas ist schiefgelaufen, bitte versuchen Sie es erneut.",
|
||||
"Stéphanie Schaer: Interministerial Digital Director (DINUM).": "Stéphanie Schaer: Interministerielle Digitaldirektorin (DINUM).",
|
||||
@@ -133,6 +137,7 @@
|
||||
"This site places a small text file (a \"cookie\") on your computer when you visit it.": "Diese Website platziert beim Besuch auf Ihrem Computer eine kleine Textdatei (ein \"Cookie\").",
|
||||
"This will protect your privacy, but will also prevent the owner from learning from your actions and creating a better experience for you and other users.": "Dies schützt Ihre Privatsphäre, verhindert jedoch auch, dass der Eigentümer aus Ihren Aktionen lernt und eine bessere Erfahrung für Sie und andere Benutzer schafft.",
|
||||
"Too many requests. Please wait 60 seconds.": "Zu viele Anfragen. Bitte warten Sie 60 Sekunden.",
|
||||
"Translate": "Übersetzen",
|
||||
"Type a name or email": "Geben Sie einen Namen oder eine E-Mail-Adresse ein",
|
||||
"Type the name of a document": "Geben Sie den Namen eines Dokuments ein",
|
||||
"Unless otherwise stated, all content on this site is under": "Sofern nicht anders angegeben, steht der gesamte Inhalt dieser Website unter",
|
||||
@@ -255,7 +260,7 @@
|
||||
"It's true, you didn't have to click on a block that covers half the page to say you agree to the placement of cookies — even if you don't know what it means!": "C'est vrai, vous n'avez pas à cliquer sur un bloc qui couvre la moitié de la page pour dire que vous acceptez le placement de cookies — même si vous ne savez pas ce que cela signifie !",
|
||||
"Language": "Langue",
|
||||
"Last update: {{update}}": "Dernière mise à jour : {{update}}",
|
||||
"Legal Notice": "Mentions Legales",
|
||||
"Legal Notice": "Mentions Légales",
|
||||
"Legal notice": "Mention légale",
|
||||
"Link Copied !": "Lien copié !",
|
||||
"Link parameters": "Paramètres du lien",
|
||||
@@ -289,6 +294,7 @@
|
||||
"Personal data and cookies": "Données personnelles et cookies",
|
||||
"Pin": "Épingler",
|
||||
"Pin document icon": "Icône épingler un document",
|
||||
"Pinned document.": "Document épinglé",
|
||||
"Pinned documents": "Documents épinglés",
|
||||
"Private": "Privé",
|
||||
"ProConnect Image": "Image ProConnect",
|
||||
@@ -317,6 +323,7 @@
|
||||
"Share with {{count}} users_many": "Partager avec {{count}} utilisateurs",
|
||||
"Share with {{count}} users_one": "Partager avec {{count}} utilisateur",
|
||||
"Share with {{count}} users_other": "Partager avec {{count}} utilisateurs",
|
||||
"Shared with {{count}} users": "Partagé avec {{count}} utilisateurs",
|
||||
"Shared with me": "Partagés avec moi",
|
||||
"Shared with {{count}} users_many": "Partager avec {{count}} utilisateurs",
|
||||
"Shared with {{count}} users_one": "Partager avec {{count}} utilisateur",
|
||||
@@ -342,6 +349,7 @@
|
||||
"This will protect your privacy, but will also prevent the owner from learning from your actions and creating a better experience for you and other users.": "Cela protégera votre vie privée, mais empêchera également le propriétaire d'apprendre de vos actions et de créer une meilleure expérience pour vous et les autres utilisateurs.",
|
||||
"To facilitate the circulation of documents, Docs allows you to export your content to the most common formats: PDF, Word or OpenDocument.": "Pour faciliter la circulation des documents, Docs permet d'exporter vos contenus vers les formats les plus courants : PDF, Word ou OpenDocument.",
|
||||
"Too many requests. Please wait 60 seconds.": "Trop de demandes. Veuillez patienter 60 secondes.",
|
||||
"Translate": "Traduire",
|
||||
"Type a name or email": "Tapez un nom ou un email",
|
||||
"Type the name of a document": "Tapez le nom d'un document",
|
||||
"Unless otherwise stated, all content on this site is under": "Sauf mention contraire, tout le contenu de ce site est sous",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "impress",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"private": true,
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eslint-config-impress",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"lint": "eslint --ext .js ."
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "packages-i18n",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"extract-translation": "yarn extract-translation:impress",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "server-y-provider",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"description": "Y.js provider for docs",
|
||||
"repository": "https://github.com/numerique-gouv/impress",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -93,4 +93,4 @@ releases:
|
||||
environments:
|
||||
dev:
|
||||
values:
|
||||
- version: 2.1.0
|
||||
- version: 2.2.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
type: application
|
||||
name: docs
|
||||
version: 2.1.0-beta-1
|
||||
version: 2.2.0-beta.1
|
||||
appVersion: latest
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<mj-include path="./partial/header.mjml" />
|
||||
|
||||
<mj-body mj-class="bg--blue-100">
|
||||
<mj-wrapper css-class="wrapper" padding="0 25px 0px 25px">
|
||||
<mj-wrapper css-class="wrapper" padding="5px 25px 0px 25px">
|
||||
<mj-section css-class="wrapper-logo">
|
||||
<mj-column>
|
||||
<mj-image
|
||||
@@ -14,7 +14,7 @@
|
||||
/>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg--white-100" padding="30px 20px 60px 20px">
|
||||
<mj-section mj-class="bg--white-100" padding="0px 20px 60px 20px">
|
||||
<mj-column>
|
||||
<mj-text align="center">
|
||||
<h1>{{title|capfirst}}</h1>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<mj-all
|
||||
font-family="Roboto, -apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif"
|
||||
font-size="16px"
|
||||
line-height="1.3em"
|
||||
line-height="normal"
|
||||
color="#3A3A3A"
|
||||
/>
|
||||
</mj-attributes>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||