Compare commits

..

2 Commits

Author SHA1 Message Date
virgile-deville
faf38de099 📝(readme) add changelog entry
Added changelog entry so the pr passes the test
2025-02-23 11:48:54 +01:00
virgile-deville
c120ad4b84 📝(readme) move front-end local run instructions
New comers should only see the main info.
I removed the special commands from the readme.
And moved them to the /docs/local.md
2025-02-21 15:21:21 +01:00
117 changed files with 1019 additions and 1944 deletions

View File

@@ -8,28 +8,17 @@ and this project adheres to
## [Unreleased]
## [2.3.0] - 2025-03-03
## Added
- 💄(frontend) add error pages #643
- 🔒️ Manage unsafe attachments #663
- ✨(frontend) Custom block quote with export #646
- ✨(frontend) add open source section homepage #666
## Changed
- 🛂(frontend) Restore version visibility #629
- 📝(doc) minor README.md formatting and wording enhancements
-Stop setting a default title on doc creation #634
- ♻️(frontend) misc ui improvements #644
- 📝(readme) remove front-end local run instructions local.md #651
## Fixed
- 🐛(backend) allow any type of extensions for media download #671
- ♻️(frontend) improve table pdf rendering
## [2.2.0] - 2025-02-10
## Added
@@ -416,8 +405,7 @@ and this project adheres to
- ✨(frontend) Coming Soon page (#67)
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.3.0...main
[v2.3.0]: https://github.com/numerique-gouv/impress/releases/v2.3.0
[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

View File

@@ -40,7 +40,7 @@ Docs is a collaborative text editor designed to address common challenges in kno
* 📚 Built-in wiki functionality to turn your team's collaborative work into organized knowledge `ETA 02/2025`
### Self-host
* 🚀 Easy to install, scalable and secure alternative to Notion, Outline or Confluence
* 🚀 Easy to install, scalable and secure alternative to Notion and Outline.
## Getting started 🔧
@@ -100,26 +100,6 @@ password: impress
$ make run
```
⚠️ For the frontend developer, it is often better to run the frontend in development mode locally.
To do so, install the frontend dependencies with the following command:
```shellscript
$ make frontend-development-install
```
And run the frontend locally in development mode with the following command:
```shellscript
$ make run-frontend-development
```
To start all the services, except the frontend container, you can use the following command:
```shellscript
$ make run-backend
```
**Adding content**
You can create a basic demo site by running:

View File

@@ -68,8 +68,6 @@ server {
# Get resource from Minio
proxy_pass http://minio:9000/impress-media-storage/;
proxy_set_header Host minio:9000;
add_header Content-Security-Policy "default-src 'none'" always;
}
location /media-auth {

92
docs/local.md Normal file
View File

@@ -0,0 +1,92 @@
# Run Docs locally
> ⚠️ Running Docs locally using the methods described below is for testing purposes only. It is based on building Docs using Minio as the S3 storage solution: if you want to use Minio for production deployment of Docs, you will need to comply with Minio's AGPL-3.0 licence.
**Prerequisite**
Make sure you have a recent version of Docker and [Docker Compose](https://docs.docker.com/compose/install) installed on your laptop:
```shellscript
$ docker -v
Docker version 20.10.2, build 2291f61
$ docker compose version
Docker Compose version v2.32.4
```
> ⚠️ You may need to run the following commands with sudo but this can be avoided by adding your user to the `docker` group.
**Project bootstrap**
The easiest way to start working on the project is to use GNU Make:
```shellscript
$ make bootstrap FLUSH_ARGS='--no-input'
```
This command builds the `app` container, installs dependencies, performs database migrations and compile translations. It's a good idea to use this command each time you are pulling code from the project repository to avoid dependency-related or migration-related issues.
Your Docker services should now be up and running 🎉
You can access to the project by going to <http://localhost:3000>.
You will be prompted to log in, the default credentials are:
```
username: impress
password: impress
```
📝 Note that if you need to run them afterwards, you can use the eponym Make rule:
```shellscript
$ make run
```
**Adding content**
You can create a basic demo site by running:
```shellscript
$ make demo
```
Finally, you can check all available Make rules using:
```shellscript
$ make help
```
**Django admin**
You can access the Django admin site at
<http://localhost:8071/admin>.
You first need to create a superuser account:
```shellscript
$ make superuser
```
## Front-end dev instructions
⚠️ For the frontend developer, it is often better to run the frontend in development mode locally.
To do so, install the frontend dependencies with the following command:
```shellscript
$ make frontend-development-install
```
And run the frontend locally in development mode with the following command:
```shellscript
$ make run-frontend-development
```
To start all the services, except the frontend container, you can use the following command:
```shellscript
$ make run-backend
```

View File

@@ -418,7 +418,6 @@ class FileUploadSerializer(serializers.Serializer):
self.context["expected_extension"] = extension
self.context["content_type"] = magic_mime_type
self.context["file_name"] = file.name
return file
@@ -427,7 +426,6 @@ class FileUploadSerializer(serializers.Serializer):
attrs["expected_extension"] = self.context["expected_extension"]
attrs["is_unsafe"] = self.context["is_unsafe"]
attrs["content_type"] = self.context["content_type"]
attrs["file_name"] = self.context["file_name"]
return attrs

View File

@@ -38,10 +38,10 @@ ATTACHMENTS_FOLDER = "attachments"
UUID_REGEX = (
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
)
FILE_EXT_REGEX = r"\.[a-zA-Z0-9]{1,10}"
FILE_EXT_REGEX = r"\.[a-zA-Z]{3,4}"
MEDIA_STORAGE_URL_PATTERN = re.compile(
f"{settings.MEDIA_URL:s}(?P<pk>{UUID_REGEX:s})/"
f"(?P<key>{ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}(?:-unsafe)?{FILE_EXT_REGEX:s})$"
f"(?P<key>{ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}{FILE_EXT_REGEX:s})$"
)
COLLABORATION_WS_URL_PATTERN = re.compile(rf"(?:^|&)room=(?P<pk>{UUID_REGEX})(?:&|$)")
@@ -915,31 +915,15 @@ class DocumentViewSet(
# Generate a generic yet unique filename to store the image in object storage
file_id = uuid.uuid4()
extension = serializer.validated_data["expected_extension"]
key = f"{document.key_base}/{ATTACHMENTS_FOLDER:s}/{file_id!s}.{extension:s}"
# Prepare metadata for storage
extra_args = {
"Metadata": {"owner": str(request.user.id)},
"ContentType": serializer.validated_data["content_type"],
}
file_unsafe = ""
if serializer.validated_data["is_unsafe"]:
extra_args["Metadata"]["is_unsafe"] = "true"
file_unsafe = "-unsafe"
key = f"{document.key_base}/{ATTACHMENTS_FOLDER:s}/{file_id!s}{file_unsafe}.{extension:s}"
file_name = serializer.validated_data["file_name"]
if (
not serializer.validated_data["content_type"].startswith("image/")
or serializer.validated_data["is_unsafe"]
):
extra_args.update(
{"ContentDisposition": f'attachment; filename="{file_name:s}"'}
)
else:
extra_args.update(
{"ContentDisposition": f'inline; filename="{file_name:s}"'}
)
file = serializer.validated_data["file"]
default_storage.connection.meta.client.upload_fileobj(

View File

@@ -697,7 +697,6 @@ class Document(MP_Node, BaseModel):
"document": self,
"domain": domain,
"link": f"{domain}/docs/{self.id}/",
"document_title": self.title or str(_("Untitled Document")),
"logo_img": settings.EMAIL_LOGO_IMG,
}
)
@@ -739,12 +738,8 @@ class Document(MP_Node, BaseModel):
'{name} invited you with the role "{role}" on the following document:'
).format(name=sender_name_email, role=role.lower()),
}
subject = (
context["title"]
if not self.title
else _("{name} shared a document with you: {title}").format(
name=sender_name, title=self.title
)
subject = _("{name} shared a document with you: {title}").format(
name=sender_name, title=self.title
)
self.send_email(subject, [email], context, language)
@@ -799,11 +794,9 @@ class Document(MP_Node, BaseModel):
ancestors_deleted_at = (
self.get_ancestors()
.filter(deleted_at__isnull=False)
.order_by("deleted_at")
.values_list("deleted_at", flat=True)
.first()
)
self.ancestors_deleted_at = ancestors_deleted_at
self.ancestors_deleted_at = min(ancestors_deleted_at, default=None)
self.save()
# Update descendants excluding those who were deleted prior to the deletion of the

View File

@@ -79,7 +79,6 @@ def test_api_documents_attachment_upload_anonymous_success():
assert file_head["Metadata"] == {"owner": "None"}
assert file_head["ContentType"] == "image/png"
assert file_head["ContentDisposition"] == 'inline; filename="test.png"'
@pytest.mark.parametrize(
@@ -218,7 +217,6 @@ def test_api_documents_attachment_upload_success(via, role, mock_user_teams):
)
assert file_head["Metadata"] == {"owner": str(user.id)}
assert file_head["ContentType"] == "image/png"
assert file_head["ContentDisposition"] == 'inline; filename="test.png"'
def test_api_documents_attachment_upload_invalid(client):
@@ -293,9 +291,7 @@ def test_api_documents_attachment_upload_fix_extension(
match = pattern.search(file_path)
file_id = match.group(1)
assert "-unsafe" in file_id
# Validate that file_id is a valid UUID
file_id = file_id.replace("-unsafe", "")
uuid.UUID(file_id)
# Now, check the metadata of the uploaded file
@@ -305,7 +301,6 @@ def test_api_documents_attachment_upload_fix_extension(
)
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
assert file_head["ContentType"] == content_type
assert file_head["ContentDisposition"] == f'attachment; filename="{name:s}"'
def test_api_documents_attachment_upload_empty_file():
@@ -345,9 +340,7 @@ def test_api_documents_attachment_upload_unsafe():
match = pattern.search(file_path)
file_id = match.group(1)
assert "-unsafe" in file_id
# Validate that file_id is a valid UUID
file_id = file_id.replace("-unsafe", "")
uuid.UUID(file_id)
# Now, check the metadata of the uploaded file
@@ -357,4 +350,3 @@ def test_api_documents_attachment_upload_unsafe():
)
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
assert file_head["ContentType"] == "application/octet-stream"
assert file_head["ContentDisposition"] == 'attachment; filename="script.exe"'

View File

@@ -64,30 +64,6 @@ def test_api_documents_media_auth_anonymous_public():
assert response.content.decode("utf-8") == "my prose"
def test_api_documents_media_auth_extensions():
"""Files with extensions of any format should work."""
document = factories.DocumentFactory(link_reach="public")
extensions = [
"c",
"go",
"gif",
"mp4",
"woff2",
"appimage",
]
for ext in extensions:
filename = f"{uuid.uuid4()!s}.{ext:s}"
key = f"{document.pk!s}/attachments/{filename:s}"
original_url = f"http://localhost/media/{key:s}"
response = APIClient().get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
@pytest.mark.parametrize("reach", ["authenticated", "restricted"])
def test_api_documents_media_auth_anonymous_authenticated_or_restricted(reach):
"""

View File

@@ -636,37 +636,6 @@ def test_models_documents__email_invitation__success():
assert f"docs/{document.id}/" in email_content
def test_models_documents__email_invitation__success_empty_title():
"""
The email invitation is sent successfully.
"""
document = factories.DocumentFactory(title=None)
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
sender = factories.UserFactory(full_name="Test Sender", email="sender@example.com")
document.send_invitation_email(
"guest@example.com", models.RoleChoices.EDITOR, sender, "en"
)
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert "Test sender shared a document with you!" in email.subject
assert (
"Test Sender (sender@example.com) invited you with the role &quot;editor&quot; "
"on the following document: Untitled Document" in email_content
)
assert f"docs/{document.id}/" in email_content
def test_models_documents__email_invitation__success_fr():
"""
The email invitation is sent successfully in french.

View File

@@ -210,6 +210,7 @@ class Base(Configuration):
"application/x-ms-regedit",
"application/x-msdownload",
"application/xml",
"image/svg+xml",
]
# Document versions

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-03-03 12:20+0000\n"
"PO-Revision-Date: 2025-03-03 12:22\n"
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
"PO-Revision-Date: 2025-02-10 14:14\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
@@ -54,15 +54,15 @@ msgstr "Ein neues Dokument wurde in Ihrem Namen erstellt!"
msgid "You have been granted ownership of a new document:"
msgstr "Sie sind Besitzer eines neuen Dokuments:"
#: build/lib/core/api/serializers.py:455 core/api/serializers.py:455
#: build/lib/core/api/serializers.py:453 core/api/serializers.py:453
msgid "Body"
msgstr "Inhalt"
#: build/lib/core/api/serializers.py:458 core/api/serializers.py:458
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
msgid "Body type"
msgstr "Typ"
#: build/lib/core/api/serializers.py:464 core/api/serializers.py:464
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
msgid "Format"
msgstr ""
@@ -230,8 +230,8 @@ msgstr "Benutzer"
msgid "users"
msgstr "Benutzer"
#: build/lib/core/models.py:373 build/lib/core/models.py:942 core/models.py:373
#: core/models.py:942
#: build/lib/core/models.py:373 build/lib/core/models.py:925 core/models.py:373
#: core/models.py:925
msgid "title"
msgstr "Titel"
@@ -251,143 +251,143 @@ msgstr "Dokumente"
msgid "Untitled Document"
msgstr "Unbenanntes Dokument"
#: build/lib/core/models.py:734 core/models.py:734
#: build/lib/core/models.py:719 core/models.py:719
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
#: build/lib/core/models.py:738 core/models.py:738
#: build/lib/core/models.py:723 core/models.py:723
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} hat Sie mit der Rolle \"{role}\" zu folgendem Dokument eingeladen:"
#: build/lib/core/models.py:741 core/models.py:741
#: build/lib/core/models.py:726 core/models.py:726
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} hat ein Dokument mit Ihnen geteilt: {title}"
#: build/lib/core/models.py:777 core/models.py:777
#: build/lib/core/models.py:762 core/models.py:762
msgid "This document is not deleted."
msgstr ""
#: build/lib/core/models.py:784 core/models.py:784
#: build/lib/core/models.py:769 core/models.py:769
msgid "This document was permanently deleted and cannot be restored."
msgstr ""
#: build/lib/core/models.py:837 core/models.py:837
#: build/lib/core/models.py:820 core/models.py:820
msgid "Document/user link trace"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:838 core/models.py:838
#: build/lib/core/models.py:821 core/models.py:821
msgid "Document/user link traces"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:844 core/models.py:844
#: build/lib/core/models.py:827 core/models.py:827
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:867 core/models.py:867
#: build/lib/core/models.py:850 core/models.py:850
msgid "Document favorite"
msgstr "Dokumentenfavorit"
#: build/lib/core/models.py:868 core/models.py:868
#: build/lib/core/models.py:851 core/models.py:851
msgid "Document favorites"
msgstr "Dokumentfavoriten"
#: build/lib/core/models.py:874 core/models.py:874
#: build/lib/core/models.py:857 core/models.py:857
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Dieses Dokument ist bereits durch den gleichen Benutzer favorisiert worden."
#: build/lib/core/models.py:896 core/models.py:896
#: build/lib/core/models.py:879 core/models.py:879
msgid "Document/user relation"
msgstr "Dokument/Benutzerbeziehung"
#: build/lib/core/models.py:897 core/models.py:897
#: build/lib/core/models.py:880 core/models.py:880
msgid "Document/user relations"
msgstr "Dokument/Benutzerbeziehungen"
#: build/lib/core/models.py:903 core/models.py:903
#: build/lib/core/models.py:886 core/models.py:886
msgid "This user is already in this document."
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:909 core/models.py:909
#: build/lib/core/models.py:892 core/models.py:892
msgid "This team is already in this document."
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:915 build/lib/core/models.py:1029
#: core/models.py:915 core/models.py:1029
#: build/lib/core/models.py:898 build/lib/core/models.py:1012
#: core/models.py:898 core/models.py:1012
msgid "Either user or team must be set, not both."
msgstr "Benutzer oder Team müssen gesetzt werden, nicht beides."
#: build/lib/core/models.py:943 core/models.py:943
#: build/lib/core/models.py:926 core/models.py:926
msgid "description"
msgstr "Beschreibung"
#: build/lib/core/models.py:944 core/models.py:944
#: build/lib/core/models.py:927 core/models.py:927
msgid "code"
msgstr "Code"
#: build/lib/core/models.py:945 core/models.py:945
#: build/lib/core/models.py:928 core/models.py:928
msgid "css"
msgstr "CSS"
#: build/lib/core/models.py:947 core/models.py:947
#: build/lib/core/models.py:930 core/models.py:930
msgid "public"
msgstr "öffentlich"
#: build/lib/core/models.py:949 core/models.py:949
#: build/lib/core/models.py:932 core/models.py:932
msgid "Whether this template is public for anyone to use."
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
#: build/lib/core/models.py:955 core/models.py:955
#: build/lib/core/models.py:938 core/models.py:938
msgid "Template"
msgstr "Vorlage"
#: build/lib/core/models.py:956 core/models.py:956
#: build/lib/core/models.py:939 core/models.py:939
msgid "Templates"
msgstr "Vorlagen"
#: build/lib/core/models.py:1010 core/models.py:1010
#: build/lib/core/models.py:993 core/models.py:993
msgid "Template/user relation"
msgstr "Vorlage/Benutzer-Beziehung"
#: build/lib/core/models.py:1011 core/models.py:1011
#: build/lib/core/models.py:994 core/models.py:994
msgid "Template/user relations"
msgstr "Vorlage/Benutzerbeziehungen"
#: build/lib/core/models.py:1017 core/models.py:1017
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "This user is already in this template."
msgstr "Dieser Benutzer ist bereits in dieser Vorlage."
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:1006 core/models.py:1006
msgid "This team is already in this template."
msgstr "Dieses Team ist bereits in diesem Template."
#: build/lib/core/models.py:1046 core/models.py:1046
#: build/lib/core/models.py:1029 core/models.py:1029
msgid "email address"
msgstr "E-Mail-Adresse"
#: build/lib/core/models.py:1065 core/models.py:1065
#: build/lib/core/models.py:1048 core/models.py:1048
msgid "Document invitation"
msgstr "Einladung zum Dokument"
#: build/lib/core/models.py:1066 core/models.py:1066
#: build/lib/core/models.py:1049 core/models.py:1049
msgid "Document invitations"
msgstr "Dokumenteinladungen"
#: build/lib/core/models.py:1086 core/models.py:1086
#: build/lib/core/models.py:1069 core/models.py:1069
msgid "This email is already associated to a registered user."
msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."
#: build/lib/impress/settings.py:235 impress/settings.py:235
#: build/lib/impress/settings.py:236 impress/settings.py:236
msgid "English"
msgstr "Englisch"
#: build/lib/impress/settings.py:236 impress/settings.py:236
#: build/lib/impress/settings.py:237 impress/settings.py:237
msgid "French"
msgstr "Französisch"
#: build/lib/impress/settings.py:237 impress/settings.py:237
#: build/lib/impress/settings.py:238 impress/settings.py:238
msgid "German"
msgstr "Deutsch"

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-03-03 12:20+0000\n"
"PO-Revision-Date: 2025-03-03 12:22\n"
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
"PO-Revision-Date: 2025-02-10 14:14\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -54,15 +54,15 @@ msgstr ""
msgid "You have been granted ownership of a new document:"
msgstr ""
#: build/lib/core/api/serializers.py:455 core/api/serializers.py:455
#: build/lib/core/api/serializers.py:453 core/api/serializers.py:453
msgid "Body"
msgstr ""
#: build/lib/core/api/serializers.py:458 core/api/serializers.py:458
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:464 core/api/serializers.py:464
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
msgid "Format"
msgstr ""
@@ -230,8 +230,8 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:373 build/lib/core/models.py:942 core/models.py:373
#: core/models.py:942
#: build/lib/core/models.py:373 build/lib/core/models.py:925 core/models.py:373
#: core/models.py:925
msgid "title"
msgstr ""
@@ -251,143 +251,143 @@ msgstr ""
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:734 core/models.py:734
#: build/lib/core/models.py:719 core/models.py:719
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:738 core/models.py:738
#: build/lib/core/models.py:723 core/models.py:723
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:741 core/models.py:741
#: build/lib/core/models.py:726 core/models.py:726
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:777 core/models.py:777
#: build/lib/core/models.py:762 core/models.py:762
msgid "This document is not deleted."
msgstr ""
#: build/lib/core/models.py:784 core/models.py:784
#: build/lib/core/models.py:769 core/models.py:769
msgid "This document was permanently deleted and cannot be restored."
msgstr ""
#: build/lib/core/models.py:837 core/models.py:837
#: build/lib/core/models.py:820 core/models.py:820
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:838 core/models.py:838
#: build/lib/core/models.py:821 core/models.py:821
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:844 core/models.py:844
#: build/lib/core/models.py:827 core/models.py:827
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:867 core/models.py:867
#: build/lib/core/models.py:850 core/models.py:850
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:868 core/models.py:868
#: build/lib/core/models.py:851 core/models.py:851
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:874 core/models.py:874
#: build/lib/core/models.py:857 core/models.py:857
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:896 core/models.py:896
#: build/lib/core/models.py:879 core/models.py:879
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:897 core/models.py:897
#: build/lib/core/models.py:880 core/models.py:880
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:903 core/models.py:903
#: build/lib/core/models.py:886 core/models.py:886
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:909 core/models.py:909
#: build/lib/core/models.py:892 core/models.py:892
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:915 build/lib/core/models.py:1029
#: core/models.py:915 core/models.py:1029
#: build/lib/core/models.py:898 build/lib/core/models.py:1012
#: core/models.py:898 core/models.py:1012
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:943 core/models.py:943
#: build/lib/core/models.py:926 core/models.py:926
msgid "description"
msgstr ""
#: build/lib/core/models.py:944 core/models.py:944
#: build/lib/core/models.py:927 core/models.py:927
msgid "code"
msgstr ""
#: build/lib/core/models.py:945 core/models.py:945
#: build/lib/core/models.py:928 core/models.py:928
msgid "css"
msgstr ""
#: build/lib/core/models.py:947 core/models.py:947
#: build/lib/core/models.py:930 core/models.py:930
msgid "public"
msgstr ""
#: build/lib/core/models.py:949 core/models.py:949
#: build/lib/core/models.py:932 core/models.py:932
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:955 core/models.py:955
#: build/lib/core/models.py:938 core/models.py:938
msgid "Template"
msgstr ""
#: build/lib/core/models.py:956 core/models.py:956
#: build/lib/core/models.py:939 core/models.py:939
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1010 core/models.py:1010
#: build/lib/core/models.py:993 core/models.py:993
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1011 core/models.py:1011
#: build/lib/core/models.py:994 core/models.py:994
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1017 core/models.py:1017
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:1006 core/models.py:1006
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1046 core/models.py:1046
#: build/lib/core/models.py:1029 core/models.py:1029
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1065 core/models.py:1065
#: build/lib/core/models.py:1048 core/models.py:1048
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1066 core/models.py:1066
#: build/lib/core/models.py:1049 core/models.py:1049
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1086 core/models.py:1086
#: build/lib/core/models.py:1069 core/models.py:1069
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/impress/settings.py:235 impress/settings.py:235
#: build/lib/impress/settings.py:236 impress/settings.py:236
msgid "English"
msgstr ""
#: build/lib/impress/settings.py:236 impress/settings.py:236
#: build/lib/impress/settings.py:237 impress/settings.py:237
msgid "French"
msgstr ""
#: build/lib/impress/settings.py:237 impress/settings.py:237
#: build/lib/impress/settings.py:238 impress/settings.py:238
msgid "German"
msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-03-03 12:20+0000\n"
"PO-Revision-Date: 2025-03-04 08:20\n"
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
"PO-Revision-Date: 2025-02-10 14:14\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -54,15 +54,15 @@ msgstr "Un nouveau document a été créé pour vous !"
msgid "You have been granted ownership of a new document:"
msgstr "Vous avez été déclaré propriétaire d'un nouveau document :"
#: build/lib/core/api/serializers.py:455 core/api/serializers.py:455
#: build/lib/core/api/serializers.py:453 core/api/serializers.py:453
msgid "Body"
msgstr ""
#: build/lib/core/api/serializers.py:458 core/api/serializers.py:458
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:464 core/api/serializers.py:464
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
msgid "Format"
msgstr ""
@@ -230,8 +230,8 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:373 build/lib/core/models.py:942 core/models.py:373
#: core/models.py:942
#: build/lib/core/models.py:373 build/lib/core/models.py:925 core/models.py:373
#: core/models.py:925
msgid "title"
msgstr ""
@@ -249,145 +249,145 @@ msgstr ""
#: build/lib/core/models.py:418 core/models.py:418
msgid "Untitled Document"
msgstr "Document sans titre"
msgstr ""
#: build/lib/core/models.py:734 core/models.py:734
#: build/lib/core/models.py:719 core/models.py:719
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} a partagé un document avec vous!"
#: build/lib/core/models.py:738 core/models.py:738
#: build/lib/core/models.py:723 core/models.py:723
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} vous a invité avec le rôle \"{role}\" sur le document suivant:"
#: build/lib/core/models.py:741 core/models.py:741
#: build/lib/core/models.py:726 core/models.py:726
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} a partagé un document avec vous: {title}"
#: build/lib/core/models.py:777 core/models.py:777
#: build/lib/core/models.py:762 core/models.py:762
msgid "This document is not deleted."
msgstr ""
#: build/lib/core/models.py:784 core/models.py:784
#: build/lib/core/models.py:769 core/models.py:769
msgid "This document was permanently deleted and cannot be restored."
msgstr ""
#: build/lib/core/models.py:837 core/models.py:837
#: build/lib/core/models.py:820 core/models.py:820
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:838 core/models.py:838
#: build/lib/core/models.py:821 core/models.py:821
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:844 core/models.py:844
#: build/lib/core/models.py:827 core/models.py:827
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:867 core/models.py:867
#: build/lib/core/models.py:850 core/models.py:850
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:868 core/models.py:868
#: build/lib/core/models.py:851 core/models.py:851
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:874 core/models.py:874
#: build/lib/core/models.py:857 core/models.py:857
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:896 core/models.py:896
#: build/lib/core/models.py:879 core/models.py:879
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:897 core/models.py:897
#: build/lib/core/models.py:880 core/models.py:880
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:903 core/models.py:903
#: build/lib/core/models.py:886 core/models.py:886
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:909 core/models.py:909
#: build/lib/core/models.py:892 core/models.py:892
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:915 build/lib/core/models.py:1029
#: core/models.py:915 core/models.py:1029
#: build/lib/core/models.py:898 build/lib/core/models.py:1012
#: core/models.py:898 core/models.py:1012
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:943 core/models.py:943
#: build/lib/core/models.py:926 core/models.py:926
msgid "description"
msgstr ""
#: build/lib/core/models.py:944 core/models.py:944
#: build/lib/core/models.py:927 core/models.py:927
msgid "code"
msgstr ""
#: build/lib/core/models.py:945 core/models.py:945
#: build/lib/core/models.py:928 core/models.py:928
msgid "css"
msgstr ""
#: build/lib/core/models.py:947 core/models.py:947
#: build/lib/core/models.py:930 core/models.py:930
msgid "public"
msgstr ""
#: build/lib/core/models.py:949 core/models.py:949
#: build/lib/core/models.py:932 core/models.py:932
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:955 core/models.py:955
#: build/lib/core/models.py:938 core/models.py:938
msgid "Template"
msgstr ""
#: build/lib/core/models.py:956 core/models.py:956
#: build/lib/core/models.py:939 core/models.py:939
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1010 core/models.py:1010
#: build/lib/core/models.py:993 core/models.py:993
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1011 core/models.py:1011
#: build/lib/core/models.py:994 core/models.py:994
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1017 core/models.py:1017
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:1006 core/models.py:1006
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1046 core/models.py:1046
#: build/lib/core/models.py:1029 core/models.py:1029
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1065 core/models.py:1065
#: build/lib/core/models.py:1048 core/models.py:1048
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1066 core/models.py:1066
#: build/lib/core/models.py:1049 core/models.py:1049
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1086 core/models.py:1086
#: build/lib/core/models.py:1069 core/models.py:1069
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/impress/settings.py:235 impress/settings.py:235
#: build/lib/impress/settings.py:236 impress/settings.py:236
msgid "English"
msgstr ""
#: build/lib/impress/settings.py:236 impress/settings.py:236
#: build/lib/impress/settings.py:237 impress/settings.py:237
msgid "French"
msgstr ""
#: build/lib/impress/settings.py:237 impress/settings.py:237
#: build/lib/impress/settings.py:238 impress/settings.py:238
msgid "German"
msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-03-03 12:20+0000\n"
"PO-Revision-Date: 2025-03-03 12:22\n"
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
"PO-Revision-Date: 2025-02-10 14:14\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
@@ -54,15 +54,15 @@ msgstr ""
msgid "You have been granted ownership of a new document:"
msgstr ""
#: build/lib/core/api/serializers.py:455 core/api/serializers.py:455
#: build/lib/core/api/serializers.py:453 core/api/serializers.py:453
msgid "Body"
msgstr ""
#: build/lib/core/api/serializers.py:458 core/api/serializers.py:458
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:464 core/api/serializers.py:464
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
msgid "Format"
msgstr ""
@@ -230,8 +230,8 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:373 build/lib/core/models.py:942 core/models.py:373
#: core/models.py:942
#: build/lib/core/models.py:373 build/lib/core/models.py:925 core/models.py:373
#: core/models.py:925
msgid "title"
msgstr ""
@@ -251,143 +251,143 @@ msgstr ""
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:734 core/models.py:734
#: build/lib/core/models.py:719 core/models.py:719
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:738 core/models.py:738
#: build/lib/core/models.py:723 core/models.py:723
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:741 core/models.py:741
#: build/lib/core/models.py:726 core/models.py:726
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:777 core/models.py:777
#: build/lib/core/models.py:762 core/models.py:762
msgid "This document is not deleted."
msgstr ""
#: build/lib/core/models.py:784 core/models.py:784
#: build/lib/core/models.py:769 core/models.py:769
msgid "This document was permanently deleted and cannot be restored."
msgstr ""
#: build/lib/core/models.py:837 core/models.py:837
#: build/lib/core/models.py:820 core/models.py:820
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:838 core/models.py:838
#: build/lib/core/models.py:821 core/models.py:821
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:844 core/models.py:844
#: build/lib/core/models.py:827 core/models.py:827
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:867 core/models.py:867
#: build/lib/core/models.py:850 core/models.py:850
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:868 core/models.py:868
#: build/lib/core/models.py:851 core/models.py:851
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:874 core/models.py:874
#: build/lib/core/models.py:857 core/models.py:857
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:896 core/models.py:896
#: build/lib/core/models.py:879 core/models.py:879
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:897 core/models.py:897
#: build/lib/core/models.py:880 core/models.py:880
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:903 core/models.py:903
#: build/lib/core/models.py:886 core/models.py:886
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:909 core/models.py:909
#: build/lib/core/models.py:892 core/models.py:892
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:915 build/lib/core/models.py:1029
#: core/models.py:915 core/models.py:1029
#: build/lib/core/models.py:898 build/lib/core/models.py:1012
#: core/models.py:898 core/models.py:1012
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:943 core/models.py:943
#: build/lib/core/models.py:926 core/models.py:926
msgid "description"
msgstr ""
#: build/lib/core/models.py:944 core/models.py:944
#: build/lib/core/models.py:927 core/models.py:927
msgid "code"
msgstr ""
#: build/lib/core/models.py:945 core/models.py:945
#: build/lib/core/models.py:928 core/models.py:928
msgid "css"
msgstr ""
#: build/lib/core/models.py:947 core/models.py:947
#: build/lib/core/models.py:930 core/models.py:930
msgid "public"
msgstr ""
#: build/lib/core/models.py:949 core/models.py:949
#: build/lib/core/models.py:932 core/models.py:932
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:955 core/models.py:955
#: build/lib/core/models.py:938 core/models.py:938
msgid "Template"
msgstr ""
#: build/lib/core/models.py:956 core/models.py:956
#: build/lib/core/models.py:939 core/models.py:939
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1010 core/models.py:1010
#: build/lib/core/models.py:993 core/models.py:993
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1011 core/models.py:1011
#: build/lib/core/models.py:994 core/models.py:994
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1017 core/models.py:1017
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:1006 core/models.py:1006
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1046 core/models.py:1046
#: build/lib/core/models.py:1029 core/models.py:1029
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1065 core/models.py:1065
#: build/lib/core/models.py:1048 core/models.py:1048
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1066 core/models.py:1066
#: build/lib/core/models.py:1049 core/models.py:1049
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1086 core/models.py:1086
#: build/lib/core/models.py:1069 core/models.py:1069
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/impress/settings.py:235 impress/settings.py:235
#: build/lib/impress/settings.py:236 impress/settings.py:236
msgid "English"
msgstr ""
#: build/lib/impress/settings.py:236 impress/settings.py:236
#: build/lib/impress/settings.py:237 impress/settings.py:237
msgid "French"
msgstr ""
#: build/lib/impress/settings.py:237 impress/settings.py:237
#: build/lib/impress/settings.py:238 impress/settings.py:238
msgid "German"
msgstr ""

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "2.3.0"
version = "2.2.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",

View File

@@ -17,13 +17,13 @@ test.describe('404', () => {
'It seems that the page you are looking for does not exist or cannot be displayed correctly.',
),
).toBeVisible();
await expect(page.getByText('Home')).toBeVisible();
await expect(page.getByText('Back to home page')).toBeVisible();
});
test('checks go back to home page redirects to home page', async ({
page,
}) => {
await page.getByText('Home').click();
await page.getByText('Back to home page').click();
await expect(page).toHaveURL('/');
});
});

View File

@@ -1,22 +0,0 @@
<html>
<head>
<title>Test unsafe file</title>
</head>
<body>
<h1>Hello svg</h1>
<img src="test.jpg" alt="test" />
<svg
xmlns="http://www.w3.org/2000/svg"
width="100"
height="100"
viewBox="0 0 100 100"
>
<circle cx="50" cy="30" r="20" fill="#3498db" />
<polygon
points="50,10 55,20 65,20 58,30 60,40 50,35 40,40 42,30 35,20 45,20"
fill="#f1c40f"
/>
<text x="50" y="70" text-anchor="middle" fill="white">Hello svg</text>
</svg>
</body>
</html>

View File

@@ -97,7 +97,7 @@ export const addNewMember = async (
// Choose a role
await page.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: role }).click();
await page.getByRole('button', { name: role }).click();
await page.getByRole('button', { name: 'Invite' }).click();
return users[index].email;

View File

@@ -1,7 +1,8 @@
/* eslint-disable playwright/no-conditional-expect */
/* eslint-disable playwright/no-conditional-in-test */
import path from 'path';
import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
import {
createDoc,
@@ -36,10 +37,10 @@ test.describe('Doc Editor', () => {
// Change language to French
await header.click();
await header.getByRole('button', { name: /Language/ }).click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await header.getByRole('combobox').getByText('English').click();
await header.getByRole('option', { name: 'Français' }).click();
await expect(
header.getByRole('button').getByText('Français'),
header.getByRole('combobox').getByText('Français'),
).toBeVisible();
// Trigger slash menu to show french menu
@@ -129,7 +130,7 @@ test.describe('Doc Editor', () => {
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Connected',
})
.click();
@@ -414,8 +415,6 @@ test.describe('Doc Editor', () => {
const editor = page.locator('.ProseMirror');
await editor.getByText('Hello').dblclick();
/* eslint-disable playwright/no-conditional-expect */
/* eslint-disable playwright/no-conditional-in-test */
if (!ai_transform && !ai_translate) {
await expect(page.getByRole('button', { name: 'AI' })).toBeHidden();
return;
@@ -442,45 +441,6 @@ test.describe('Doc Editor', () => {
page.getByRole('menuitem', { name: 'Language' }),
).toBeHidden();
}
/* eslint-enable playwright/no-conditional-expect */
/* eslint-enable playwright/no-conditional-in-test */
});
});
test('it downloads unsafe files', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
const fileChooserPromise = page.waitForEvent('filechooser');
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`html`);
});
await verifyDocName(page, randomDoc);
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Embedded file').click();
await page.getByText('Upload file').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, 'assets/test.html'));
await page.locator('.bn-block-content[data-name="test.html"]').click();
await page.getByRole('button', { name: 'Download file' }).click();
await expect(
page.getByText('This file is flagged as unsafe.'),
).toBeVisible();
await page.getByRole('button', { name: 'Download' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toContain(`-unsafe.html`);
const svgBuffer = await cs.toBuffer(await download.createReadStream());
expect(svgBuffer.toString()).toContain('Hello svg');
});
});

View File

@@ -197,49 +197,4 @@ test.describe('Doc Export', () => {
expect(pdfText).toContain('Hello World');
});
test('it exports the doc with quotes', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'export-quotes', browserName, 1);
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
const editor = page.locator('.ProseMirror');
// Trigger slash menu to show menu
await editor.click();
await editor.fill('/');
await page.getByText('Add a quote block').click();
await expect(
editor.locator('.bn-block-content[data-content-type="quote"]'),
).toBeVisible();
await editor.fill('Hello World');
await expect(editor.getByText('Hello World')).toHaveCSS(
'font-style',
'italic',
);
await page
.getByRole('button', {
name: 'download',
})
.click();
await page
.getByRole('button', {
name: 'Download',
})
.click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfData = await pdf(pdfBuffer);
expect(pdfData.text).toContain('Hello World'); // This is the pdf text
});
});

View File

@@ -7,9 +7,17 @@ type SmallDoc = {
title: string;
};
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Documents Grid mobile', () => {
test.use({ viewport: { width: 500, height: 1200 } });
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('it checks the grid when mobile', async ({ page }) => {
await page.route('**/documents/**', async (route) => {
const request = route.request();
@@ -86,10 +94,6 @@ test.describe('Documents Grid mobile', () => {
});
test.describe('Document grid item options', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('it pins a document', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, `Favorite doc`, browserName);
@@ -208,8 +212,6 @@ test.describe('Document grid item options', () => {
test.describe('Documents filters', () => {
test('it checks the prebuild left panel filters', async ({ page }) => {
await page.goto('/');
// All Docs
const response = await page.waitForResponse(
(response) =>
@@ -280,9 +282,11 @@ test.describe('Documents filters', () => {
});
test.describe('Documents Grid', () => {
test('checks all the elements are visible', async ({ page }) => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('checks all the elements are visible', async ({ page }) => {
let docs: SmallDoc[] = [];
const response = await page.waitForResponse(
(response) =>
@@ -310,12 +314,11 @@ test.describe('Documents Grid', () => {
test('checks the infinite scroll', async ({ page }) => {
let docs: SmallDoc[] = [];
const responsePromisePage1 = page.waitForResponse((response) => {
return (
const responsePromisePage1 = page.waitForResponse(
(response) =>
response.url().endsWith(`/documents/?page=1`) &&
response.status() === 200
);
});
response.status() === 200,
);
const responsePromisePage2 = page.waitForResponse(
(response) =>
@@ -323,8 +326,6 @@ test.describe('Documents Grid', () => {
response.status() === 200,
);
await page.goto('/');
const responsePage1 = await responsePromisePage1;
expect(responsePage1.ok()).toBeTruthy();
let result = await responsePage1.json();

View File

@@ -89,7 +89,7 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Delete document',
})
.click();
@@ -99,7 +99,9 @@ test.describe('Doc Header', () => {
).toBeVisible();
await expect(
page.getByText(`Are you sure you want to delete this document ?`),
page.getByText(
`Are you sure you want to delete the document "${randomDoc}"?`,
),
).toBeVisible();
await page
@@ -151,7 +153,7 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
page.getByRole('button', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options
@@ -175,7 +177,7 @@ test.describe('Doc Header', () => {
await invitationCard.getByRole('button', { name: 'more_horiz' }).click();
await expect(
page.getByRole('menuitem', {
page.getByRole('button', {
name: 'delete',
}),
).toBeEnabled();
@@ -193,7 +195,7 @@ test.describe('Doc Header', () => {
await memberCard.getByRole('button', { name: 'more_horiz' }).click();
await expect(
page.getByRole('menuitem', {
page.getByRole('button', {
name: 'delete',
}),
).toBeEnabled();
@@ -231,7 +233,7 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
page.getByRole('button', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options
@@ -293,7 +295,7 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
page.getByRole('button', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options
@@ -350,7 +352,7 @@ test.describe('Doc Header', () => {
// Copy content to clipboard
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Copy as Markdown' }).click();
await page.getByRole('button', { name: 'Copy as Markdown' }).click();
await expect(page.getByText('Copied to clipboard')).toBeVisible();
// Test that clipboard is in Markdown format
@@ -385,7 +387,7 @@ test.describe('Doc Header', () => {
// Copy content to clipboard
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Copy as HTML' }).click();
await page.getByRole('button', { name: 'Copy as HTML' }).click();
await expect(page.getByText('Copied to clipboard')).toBeVisible();
// Test that clipboard is in HTML format
@@ -458,7 +460,7 @@ test.describe('Documents Header mobile', () => {
await expect(page.getByRole('button', { name: 'Copy link' })).toBeHidden();
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Share' }).click();
await page.getByRole('button', { name: 'Share' }).click();
await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Link Copied !')).toBeVisible();
// Test that clipboard is in HTML format
@@ -492,7 +494,7 @@ test.describe('Documents Header mobile', () => {
await goToGridDoc(page);
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Share' }).click();
await page.getByRole('button', { name: 'Share' }).click();
await expect(page.getByLabel('Share modal')).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();

View File

@@ -65,15 +65,15 @@ test.describe('Document create member', () => {
// Check roles are displayed
await list.getByLabel('doc-role-dropdown').click();
await expect(page.getByRole('menuitem', { name: 'Reader' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Editor' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Owner' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Reader' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Editor' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Owner' })).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Administrator' }),
page.getByRole('button', { name: 'Administrator' }),
).toBeVisible();
// Validate
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Invite' }).click();
// Check invitation added
@@ -121,7 +121,7 @@ test.describe('Document create member', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Owner' }).click();
await page.getByRole('button', { name: 'Owner' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -139,7 +139,7 @@ test.describe('Document create member', () => {
// Choose a role
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Owner' }).click();
await page.getByRole('button', { name: 'Owner' }).click();
const responsePromiseCreateInvitationFail = page.waitForResponse(
(response) =>
@@ -162,8 +162,8 @@ test.describe('Document create member', () => {
await createDoc(page, 'user-invitation', browserName, 1);
const header = page.locator('header').first();
await header.getByRole('button', { name: /Language/ }).click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await header.getByRole('combobox').getByText('EN').click();
await header.getByRole('option', { name: 'translate Français' }).click();
await page.getByRole('button', { name: 'Partager' }).click();
@@ -178,7 +178,7 @@ test.describe('Document create member', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Administrateur' }).click();
await page.getByRole('button', { name: 'Administrateur' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -212,7 +212,7 @@ test.describe('Document create member', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Administrator' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -232,14 +232,14 @@ test.describe('Document create member', () => {
await expect(userInvitation).toBeVisible();
await userInvitation.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Reader' }).click();
await page.getByRole('button', { name: 'Reader' }).click();
const moreActions = userInvitation.getByRole('button', {
name: 'more_horiz',
});
await moreActions.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(userInvitation).toBeHidden();
});

View File

@@ -161,12 +161,12 @@ test.describe('Document list members', () => {
await list.click();
await currentUserRole.click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Administrator' }).click();
await list.click();
await expect(currentUserRole).toBeVisible();
await currentUserRole.click();
await page.getByRole('menuitem', { name: 'Reader' }).click();
await page.getByRole('button', { name: 'Reader' }).click();
await list.click();
await expect(currentUserRole).toBeHidden();
});
@@ -215,13 +215,13 @@ test.describe('Document list members', () => {
await expect(mySelfMoreActions).toBeVisible();
await userReaderMoreActions.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(userReader).toBeHidden();
await mySelfMoreActions.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(
page.getByText('You do not have permission to view this document.'),
page.getByText('You do not have permission to perform this action.'),
).toBeVisible();
});
});

View File

@@ -19,7 +19,7 @@ test.describe('Doc Version', () => {
await page.getByLabel('Open the document options').click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Version history',
})
.click();
@@ -59,7 +59,7 @@ test.describe('Doc Version', () => {
await page.getByLabel('Open the document options').click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Version history',
})
.click();
@@ -91,7 +91,7 @@ test.describe('Doc Version', () => {
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Version history' }),
page.getByRole('button', { name: 'Version history' }),
).toBeDisabled();
});
@@ -120,7 +120,7 @@ test.describe('Doc Version', () => {
await page.getByLabel('Open the document options').click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Version history',
})
.click();

View File

@@ -50,7 +50,7 @@ test.describe('Doc Visibility', () => {
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Connected',
})
.click();
@@ -60,7 +60,7 @@ test.describe('Doc Visibility', () => {
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Public',
})
.click();
@@ -100,9 +100,7 @@ test.describe('Doc Visibility: Restricted', () => {
await page.goto(urlDoc);
await expect(
page.getByText('Log in to access the document.'),
).toBeVisible();
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
});
test('A doc is not accessible when authentified but not member.', async ({
@@ -135,7 +133,7 @@ test.describe('Doc Visibility: Restricted', () => {
await page.goto(urlDoc);
await expect(
page.getByText('You do not have permission to view this document.'),
page.getByText('You do not have permission to perform this action.'),
).toBeVisible();
});
@@ -162,7 +160,7 @@ test.describe('Doc Visibility: Restricted', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Invite' }).click();
@@ -215,7 +213,7 @@ test.describe('Doc Visibility: Public', () => {
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Public',
})
.click();
@@ -227,7 +225,7 @@ test.describe('Doc Visibility: Public', () => {
await expect(page.getByLabel('Visibility mode')).toBeVisible();
await page.getByLabel('Visibility mode').click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Reading',
})
.click();
@@ -289,7 +287,7 @@ test.describe('Doc Visibility: Public', () => {
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Public',
})
.click();
@@ -357,7 +355,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Connected',
})
.click();
@@ -381,10 +379,7 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeHidden();
await expect(
page.getByText('Log in to access the document.'),
).toBeVisible();
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
});
test('It checks a authenticated doc in read only mode', async ({
@@ -407,7 +402,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Connected',
})
.click();
@@ -416,14 +411,6 @@ test.describe('Doc Visibility: Authenticated', () => {
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await expect(
page
.getByLabel('It is the card information about the document.')
.getByText('Document accessible to any connected person', {
exact: true,
}),
).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
const urlDoc = page.url();
@@ -469,7 +456,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('menuitem', {
.getByRole('button', {
name: 'Connected',
})
.click();

View File

@@ -12,7 +12,7 @@ test.describe('Home page', () => {
const footer = page.locator('footer').first();
await expect(header).toBeVisible();
await expect(
header.getByRole('button', { name: /Language/ }),
header.getByRole('combobox', { name: 'Language' }),
).toBeVisible();
await expect(
header.getByRole('button', { name: 'Les services de La Suite numé' }),
@@ -26,7 +26,6 @@ test.describe('Home page', () => {
// Check the titles
const h2 = page.locator('h2');
await expect(h2.getByText('Govs ❤️ Open Source.')).toBeVisible();
await expect(
h2.getByText('Collaborative writing, Simplified.'),
).toBeVisible();

View File

@@ -9,20 +9,19 @@ test.describe('Language', () => {
await expect(page.getByLabel('Logout')).toBeVisible();
const header = page.locator('header').first();
await header
.getByRole('button', { name: /Language/ })
.getByText('English')
.click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await header.getByRole('combobox').getByText('English').click();
await header.getByRole('option', { name: 'Français' }).click();
await expect(
header.getByRole('button').getByText('Français'),
header.getByRole('combobox').getByText('Français'),
).toBeVisible();
await expect(page.getByLabel('Se déconnecter')).toBeVisible();
await header.getByRole('button').getByText('Français').click();
await page.getByRole('menuitem', { name: 'Deutsch' }).click();
await expect(header.getByRole('button').getByText('Deutsch')).toBeVisible();
await header.getByRole('combobox').getByText('Français').click();
await header.getByRole('option', { name: 'Deutsch' }).click();
await expect(
header.getByRole('combobox').getByText('Deutsch'),
).toBeVisible();
await expect(page.getByLabel('Abmelden')).toBeVisible();
});
@@ -54,11 +53,8 @@ test.describe('Language', () => {
// Switch language to French
const header = page.locator('header').first();
await header
.getByRole('button', { name: /Language/ })
.getByText('English')
.click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await header.getByRole('combobox').getByText('English').click();
await header.getByRole('option', { name: 'Français' }).click();
// Check for French 404 response
await check404Response('Pas trouvé.');

View File

@@ -29,7 +29,7 @@ test.describe('Left panel mobile', () => {
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('button', { name: /Language/ });
const languageButton = page.getByRole('combobox', { name: 'Language' });
const logoutButton = page.getByRole('button', { name: 'Logout' });
await expect(homeButton).not.toBeInViewport();

View File

@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "2.3.0",
"version": "2.2.0",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",

View File

@@ -357,15 +357,6 @@ const config = {
components: {
alert: {
'border-radius': '0',
error: {
'background-color': 'var(--c--theme--colors--danger-100)',
'border-left-color': 'var(--c--theme--colors--danger-400)',
close: {
color: 'white',
'background-color': 'var(--c--theme--colors--danger-400)',
'background-color-hover': 'var(--c--theme--colors--danger-600)',
},
},
},
modal: {
'width-small': '342px',
@@ -390,7 +381,6 @@ const config = {
'color-active': 'var(--c--theme--colors--primary-100)',
},
'color-hover': 'var(--c--theme--colors--primary-text)',
color: 'var(--c--theme--colors--primary-800)',
},
secondary: {
background: {

View File

@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "2.3.0",
"version": "2.2.0",
"private": true,
"scripts": {
"dev": "next dev",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

View File

@@ -8,20 +8,17 @@ import {
import { Button, Popover } from 'react-aria-components';
import styled from 'styled-components';
import { BoxProps } from './Box';
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;
`;
interface StyledButtonProps {
$css?: BoxProps['$css'];
}
const StyledButton = styled(Button)<StyledButtonProps>`
const StyledButton = styled(Button)`
cursor: pointer;
border: none;
background: none;
@@ -32,12 +29,10 @@ const StyledButton = styled(Button)<StyledButtonProps>`
font-size: 0.938rem;
padding: 0;
text-wrap: nowrap;
${({ $css }) => $css};
`;
export interface DropButtonProps {
button: ReactNode;
buttonCss?: BoxProps['$css'];
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
label?: string;
@@ -45,7 +40,6 @@ export interface DropButtonProps {
export const DropButton = ({
button,
buttonCss,
isOpen = false,
onOpenChange,
children,
@@ -70,7 +64,6 @@ export const DropButton = ({
ref={triggerRef}
onPress={() => onOpenChangeHandler(true)}
aria-label={label}
$css={buttonCss}
>
{button}
</StyledButton>

View File

@@ -1,4 +1,4 @@
import { PropsWithChildren, useRef, useState } from 'react';
import { PropsWithChildren, useState } from 'react';
import { css } from 'styled-components';
import { Box, BoxButton, BoxProps, DropButton, Icon, Text } from '@/components';
@@ -20,7 +20,6 @@ export type DropdownMenuProps = {
showArrow?: boolean;
label?: string;
arrowCss?: BoxProps['$css'];
buttonCss?: BoxProps['$css'];
disabled?: boolean;
topMessage?: string;
};
@@ -31,7 +30,6 @@ export const DropdownMenu = ({
disabled = false,
showArrow = false,
arrowCss,
buttonCss,
label,
topMessage,
}: PropsWithChildren<DropdownMenuProps>) => {
@@ -39,7 +37,6 @@ export const DropdownMenu = ({
const spacings = theme.spacingsTokens();
const colors = theme.colorsTokens();
const [isOpen, setIsOpen] = useState(false);
const blockButtonRef = useRef<HTMLDivElement>(null);
const onOpenChange = (isOpen: boolean) => {
setIsOpen(isOpen);
@@ -54,17 +51,10 @@ export const DropdownMenu = ({
isOpen={isOpen}
onOpenChange={onOpenChange}
label={label}
buttonCss={buttonCss}
button={
showArrow ? (
<Box
ref={blockButtonRef}
$direction="row"
$align="center"
$position="relative"
aria-controls="menu"
>
<Box>{children}</Box>
<Box $direction="row" $align="center">
<div>{children}</div>
<Icon
$variation="600"
$css={
@@ -77,17 +67,11 @@ export const DropdownMenu = ({
/>
</Box>
) : (
<Box ref={blockButtonRef} aria-controls="menu">
{children}
</Box>
children
)
}
>
<Box
$maxWidth="320px"
$minWidth={`${blockButtonRef.current?.clientWidth}px`}
role="menu"
>
<Box $maxWidth="320px">
{topMessage && (
<Text
$variation="700"
@@ -106,7 +90,6 @@ export const DropdownMenu = ({
const isDisabled = option.disabled !== undefined && option.disabled;
return (
<BoxButton
role="menuitem"
aria-label={option.label}
data-testid={option.testId}
$direction="row"

View File

@@ -25,9 +25,7 @@ export interface TextProps extends BoxProps {
$size?: TextSizes | (string & {});
$theme?:
| 'primary'
| 'primary-text'
| 'secondary'
| 'secondary-text'
| 'info'
| 'success'
| 'warning'

View File

@@ -35,7 +35,6 @@ export const TextErrors = ({
<Text
key={`causes-${i}`}
$theme="danger"
$variation="600"
$textAlign="center"
{...textProps}
>
@@ -44,12 +43,7 @@ export const TextErrors = ({
))}
{!causes && (
<Text
$theme="danger"
$variation="600"
$textAlign="center"
{...textProps}
>
<Text $theme="danger" $textAlign="center" {...textProps}>
{defaultMessage || t('Something bad happens, please retry.')}
</Text>
)}

View File

@@ -3,7 +3,7 @@ import { PropsWithChildren, useEffect } from 'react';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { CrispProvider, PostHogProvider } from '@/services';
import { PostHogProvider, configureCrispSession } from '@/services';
import { useSentryStore } from '@/stores/useSentryStore';
import { useConfig } from './api/useConfig';
@@ -29,6 +29,14 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
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">
@@ -37,11 +45,5 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
);
}
return (
<PostHogProvider conf={conf.POSTHOG_KEY}>
<CrispProvider websiteId={conf?.CRISP_WEBSITE_ID}>
{children}
</CrispProvider>
</PostHogProvider>
);
return <PostHogProvider conf={conf.POSTHOG_KEY}>{children}</PostHogProvider>;
};

View File

@@ -310,7 +310,7 @@ input:-webkit-autofill:focus {
}
/**
* Checkbox
* Others
*/
.c__checkbox:focus-within {
border-color: transparent;
@@ -365,8 +365,7 @@ input:-webkit-autofill:focus {
}
.c__button--medium {
height: auto;
min-height: var(--c--components--button--medium-height);
padding: 0.9rem var(--c--theme--spacings--s);
}
.c__button--small {
@@ -407,10 +406,6 @@ input:-webkit-autofill:focus {
);
}
.c__button--primary-text {
color: var(--c--components--button--primary-text--color);
}
.c__button--primary-text:hover,
.c__button--primary-text:focus-visible {
background-color: var(
@@ -552,8 +547,6 @@ input:-webkit-autofill:focus {
.c__modal__close .c__button {
padding: 0 !important;
top: -0.65rem;
right: -0.65rem;
}
.c__modal--full .c__modal__content {
@@ -612,22 +605,3 @@ input:-webkit-autofill:focus {
.c__tooltip {
padding: 4px 6px;
}
/**
* Alert
*/
.c__alert--error {
background-color: var(--c--components--alert--error--background-color);
border-left-color: var(--c--components--alert--error--border-left-color);
}
.c__alert--error .c__button--tertiary {
background-color: var(--c--components--alert--error--close--background-color);
color: var(--c--components--alert--error--close--color);
}
.c__alert.c__alert--error .c__button--tertiary:hover {
background-color: var(
--c--components--alert--error--close--background-color-hover
);
}

View File

@@ -484,19 +484,6 @@
--c--theme--logo--widthFooter: 220px;
--c--theme--logo--alt: gouvernement logo;
--c--components--alert--border-radius: 0;
--c--components--alert--error--background-color: var(
--c--theme--colors--danger-100
);
--c--components--alert--error--border-left-color: var(
--c--theme--colors--danger-400
);
--c--components--alert--error--close--color: white;
--c--components--alert--error--close--background-color: var(
--c--theme--colors--danger-400
);
--c--components--alert--error--close--background-color-hover: var(
--c--theme--colors--danger-600
);
--c--components--modal--width-small: 342px;
--c--components--button--medium-height: 40px;
--c--components--button--medium-text-height: 40px;
@@ -518,9 +505,6 @@
--c--components--button--primary-text--color-hover: var(
--c--theme--colors--primary-text
);
--c--components--button--primary-text--color: var(
--c--theme--colors--primary-800
);
--c--components--button--secondary--background--color-hover: #f6f6f6;
--c--components--button--secondary--background--color-active: #ededed;
--c--components--button--secondary--border--color: var(

View File

@@ -483,18 +483,7 @@ export const tokens = {
},
},
components: {
alert: {
'border-radius': '0',
error: {
'background-color': 'var(--c--theme--colors--danger-100)',
'border-left-color': 'var(--c--theme--colors--danger-400)',
close: {
color: 'white',
'background-color': 'var(--c--theme--colors--danger-400)',
'background-color-hover': 'var(--c--theme--colors--danger-600)',
},
},
},
alert: { 'border-radius': '0' },
modal: { 'width-small': '342px' },
button: {
'medium-height': '40px',
@@ -516,7 +505,6 @@ export const tokens = {
'color-active': 'var(--c--theme--colors--primary-100)',
},
'color-hover': 'var(--c--theme--colors--primary-text)',
color: 'var(--c--theme--colors--primary-800)',
},
secondary: {
background: { 'color-hover': '#F6F6F6', 'color-active': '#EDEDED' },

View File

@@ -1,6 +1,6 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { APIError, fetchAPI } from '@/api';
import { User } from './types';
@@ -17,10 +17,7 @@ import { User } from './types';
export const getMe = async (): Promise<User> => {
const response = await fetchAPI(`users/me/`);
if (!response.ok) {
throw new APIError(
`Couldn't fetch user data: ${response.statusText}`,
await errorCauses(response),
);
throw new Error(`Couldn't fetch user data: ${response.statusText}`);
}
return response.json() as Promise<User>;
};

View File

@@ -5,7 +5,6 @@ import { PropsWithChildren } from 'react';
import { Box } from '@/components';
import { useAuth } from '../hooks';
import { getAuthUrl } from '../utils';
export const Auth = ({ children }: PropsWithChildren) => {
const { isLoading, pathAllowed, isFetchedAfterMount, authenticated } =
@@ -20,22 +19,6 @@ export const Auth = ({ children }: PropsWithChildren) => {
);
}
/**
* If the user is authenticated and wanted initially to access a document,
* we redirect to the document page.
*/
if (authenticated) {
const authUrl = getAuthUrl();
if (authUrl) {
void replace(authUrl);
return (
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
<Loader />
</Box>
);
}
}
/**
* If the user is not authenticated and the path is not allowed, we redirect to the login page.
*/

View File

@@ -14,11 +14,7 @@ export const ButtonLogin = () => {
if (!authenticated) {
return (
<Button
onClick={() => gotoLogin()}
color="primary-text"
aria-label={t('Login')}
>
<Button onClick={gotoLogin} color="primary-text" aria-label={t('Login')}>
{t('Login')}
</Button>
);
@@ -36,7 +32,7 @@ export const ProConnectButton = () => {
return (
<BoxButton
onClick={() => gotoLogin()}
onClick={gotoLogin}
aria-label={t('Proconnect Login')}
$css={css`
background-color: var(--c--theme--colors--primary-text);

View File

@@ -2,12 +2,13 @@ import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useAuthQuery } from '../api';
import { getAuthUrl } from '../utils';
const regexpUrlsAuth = [/\/docs\/$/g, /\/docs$/g, /^\/$/g];
export const useAuth = () => {
const { data: user, ...authStates } = useAuthQuery();
const { pathname } = useRouter();
const { pathname, replace } = useRouter();
const [pathAllowed, setPathAllowed] = useState<boolean>(
!regexpUrlsAuth.some((regexp) => !!pathname.match(regexp)),
@@ -17,10 +18,17 @@ export const useAuth = () => {
setPathAllowed(!regexpUrlsAuth.some((regexp) => !!pathname.match(regexp)));
}, [pathname]);
return {
user,
authenticated: !!user && authStates.isSuccess,
pathAllowed,
...authStates,
};
// Redirect to the path before login
useEffect(() => {
if (!user) {
return;
}
const authUrl = getAuthUrl();
if (authUrl) {
void replace(authUrl);
}
}, [user, replace]);
return { user, authenticated: !!user, pathAllowed, ...authStates };
};

View File

@@ -1,4 +1,4 @@
export * from './api';
export * from './api/types';
export * from './components';
export * from './hooks';
export * from './utils';

View File

@@ -16,11 +16,8 @@ export const setAuthUrl = () => {
}
};
export const gotoLogin = (withRedirect = true) => {
if (withRedirect) {
setAuthUrl();
}
export const gotoLogin = () => {
setAuthUrl();
window.location.replace(LOGIN_URL);
};

View File

@@ -20,7 +20,7 @@ import {
AITransformActions,
useDocAITransform,
useDocAITranslate,
} from '../../api';
} from '../api/';
type LanguageTranslate = {
value: string;

View File

@@ -1,7 +1,6 @@
import {
BlockNoteSchema,
Dictionary,
defaultBlockSpecs,
locales,
withPageBreak,
} from '@blocknote/core';
@@ -26,17 +25,9 @@ import { cssEditor } from '../styles';
import { randomColor } from '../utils';
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
import { QuoteBlock } from './custom-blocks';
import { BlockNoteToolbar } from './BlockNoteToolbar';
export const blockNoteSchema = withPageBreak(
BlockNoteSchema.create({
blockSpecs: {
...defaultBlockSpecs,
quote: QuoteBlock,
},
}),
);
export const blockNoteSchema = withPageBreak(BlockNoteSchema.create());
interface BlockNoteEditorProps {
doc: Doc;
@@ -134,7 +125,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
$css={cssEditor(readOnly)}
>
{errorAttachment && (
<Box $margin={{ bottom: 'big', top: 'none', horizontal: 'large' }}>
<Box $margin={{ bottom: 'big' }}>
<TextErrors
causes={errorAttachment.cause}
canClose
@@ -150,8 +141,8 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
editable={!readOnly}
theme="light"
>
<BlockNoteSuggestionMenu />
<BlockNoteToolbar />
<BlockNoteSuggestionMenu />
</BlockNoteView>
</Box>
);

View File

@@ -5,19 +5,13 @@ import {
getDefaultReactSlashMenuItems,
getPageBreakReactSlashMenuItems,
useBlockNoteEditor,
useDictionary,
} from '@blocknote/react';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { DocsBlockSchema } from '../types';
import { getQuoteReactSlashMenuItems } from './custom-blocks';
import { DocsBlockNoteEditor } from '../types';
export const BlockNoteSuggestionMenu = () => {
const editor = useBlockNoteEditor<DocsBlockSchema>();
const { t } = useTranslation();
const basicBlocksName = useDictionary().slash_menu.page_break.group;
const editor = useBlockNoteEditor() as DocsBlockNoteEditor;
const getSlashMenuItems = useMemo(() => {
return async (query: string) =>
@@ -26,12 +20,11 @@ export const BlockNoteSuggestionMenu = () => {
combineByGroup(
getDefaultReactSlashMenuItems(editor),
getPageBreakReactSlashMenuItems(editor),
getQuoteReactSlashMenuItems(editor, t, basicBlocksName),
),
query,
),
);
}, [basicBlocksName, editor, t]);
}, [editor]);
return (
<SuggestionMenuController

View File

@@ -1,75 +0,0 @@
import '@blocknote/mantine/style.css';
import {
FormattingToolbar,
FormattingToolbarController,
blockTypeSelectItems,
getFormattingToolbarItems,
useDictionary,
} from '@blocknote/react';
import React, { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { getQuoteFormattingToolbarItems } from '../custom-blocks';
import { AIGroupButton } from './AIButton';
import { FileDownloadButton } from './FileDownloadButton';
import { MarkdownButton } from './MarkdownButton';
import { ModalConfirmDownloadUnsafe } from './ModalConfirmDownloadUnsafe';
export const BlockNoteToolbar = () => {
const dict = useDictionary();
const [confirmOpen, setIsConfirmOpen] = useState(false);
const [onConfirm, setOnConfirm] = useState<() => void | Promise<void>>();
const { t } = useTranslation();
const toolbarItems = useMemo(() => {
const toolbarItems = getFormattingToolbarItems([
...blockTypeSelectItems(dict),
getQuoteFormattingToolbarItems(t),
]);
const fileDownloadButtonIndex = toolbarItems.findIndex(
(item) => item.key === 'fileDownloadButton',
);
if (fileDownloadButtonIndex !== -1) {
toolbarItems.splice(
fileDownloadButtonIndex,
1,
<FileDownloadButton
key="fileDownloadButton"
open={(onConfirm) => {
setIsConfirmOpen(true);
setOnConfirm(() => onConfirm);
}}
/>,
);
}
return toolbarItems;
}, [dict, t]);
const formattingToolbar = useCallback(() => {
return (
<FormattingToolbar>
{toolbarItems}
{/* Extra button to do some AI powered actions */}
<AIGroupButton key="AIButton" />
{/* Extra button to convert from markdown to json */}
<MarkdownButton key="customButton" />
</FormattingToolbar>
);
}, [toolbarItems]);
return (
<>
<FormattingToolbarController formattingToolbar={formattingToolbar} />
{confirmOpen && (
<ModalConfirmDownloadUnsafe
onClose={() => setIsConfirmOpen(false)}
onConfirm={onConfirm}
/>
)}
</>
);
};

View File

@@ -1,111 +0,0 @@
import {
BlockSchema,
InlineContentSchema,
StyleSchema,
checkBlockIsFileBlock,
checkBlockIsFileBlockWithPlaceholder,
} from '@blocknote/core';
import {
useBlockNoteEditor,
useComponentsContext,
useDictionary,
useSelectedBlocks,
} from '@blocknote/react';
import { useCallback, useMemo } from 'react';
import { RiDownload2Fill } from 'react-icons/ri';
import { downloadFile, exportResolveFileUrl } from '@/features/docs/doc-export';
export const FileDownloadButton = ({
open,
}: {
open: (onConfirm: () => Promise<void> | void) => void;
}) => {
const dict = useDictionary();
const Components = useComponentsContext();
const editor = useBlockNoteEditor<
BlockSchema,
InlineContentSchema,
StyleSchema
>();
const selectedBlocks = useSelectedBlocks(editor);
const fileBlock = useMemo(() => {
// Checks if only one block is selected.
if (selectedBlocks.length !== 1) {
return undefined;
}
const block = selectedBlocks[0];
if (checkBlockIsFileBlock(block, editor)) {
return block;
}
return undefined;
}, [editor, selectedBlocks]);
const onClick = useCallback(async () => {
if (fileBlock && fileBlock.props.url) {
editor.focus();
const url = fileBlock.props.url as string;
/**
* If not hosted on our domain, means not a file uploaded by the user,
* we do what Blocknote was doing initially.
*/
if (!url.includes(window.location.hostname)) {
if (!editor.resolveFileUrl) {
window.open(url);
} else {
void editor
.resolveFileUrl(url)
.then((downloadUrl) => window.open(downloadUrl));
}
return;
}
if (!url.includes('-unsafe')) {
const blob = (await exportResolveFileUrl(url, undefined)) as Blob;
downloadFile(blob, url.split('/').pop() || 'file');
} else {
const onConfirm = async () => {
const blob = (await exportResolveFileUrl(url, undefined)) as Blob;
downloadFile(blob, url.split('/').pop() || 'file (unsafe)');
};
open(onConfirm);
}
}
}, [editor, fileBlock, open]);
if (
!fileBlock ||
checkBlockIsFileBlockWithPlaceholder(fileBlock, editor) ||
!Components
) {
return null;
}
return (
<>
<Components.FormattingToolbar.Button
className="bn-button"
label={
dict.formatting_toolbar.file_download.tooltip[fileBlock.type] ||
dict.formatting_toolbar.file_download.tooltip['file']
}
mainTooltip={
dict.formatting_toolbar.file_download.tooltip[fileBlock.type] ||
dict.formatting_toolbar.file_download.tooltip['file']
}
icon={<RiDownload2Fill />}
onClick={() => void onClick()}
/>
</>
);
};

View File

@@ -1,74 +0,0 @@
import { Button, Modal, ModalSize } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { Box, Text } from '@/components';
interface ModalConfirmDownloadUnsafeProps {
onClose: () => void;
onConfirm?: () => Promise<void> | void;
}
export const ModalConfirmDownloadUnsafe = ({
onConfirm,
onClose,
}: ModalConfirmDownloadUnsafeProps) => {
const { t } = useTranslation();
return (
<Modal
isOpen
closeOnClickOutside
onClose={() => onClose()}
rightActions={
<>
<Button
aria-label={t('Close the modal')}
color="secondary"
onClick={() => onClose()}
>
{t('Cancel')}
</Button>
<Button
aria-label={t('Download')}
color="danger"
onClick={() => {
console.log('onClick');
if (onConfirm) {
void onConfirm();
}
onClose();
}}
>
{t('Download anyway')}
</Button>
</>
}
size={ModalSize.SMALL}
title={
<Text
$gap="0.7rem"
$size="h6"
$align="flex-start"
$variation="1000"
$direction="row"
>
<Text $isMaterialIcon $theme="warning">
warning
</Text>
{t('Warning')}
</Text>
}
>
<Box aria-label={t('Modal confirmation to download the attachment')}>
<Box>
<Box $direction="column" $gap="0.35rem" $margin={{ top: 'sm' }}>
<Text $variation="700">{t('This file is flagged as unsafe.')}</Text>
<Text $variation="600">
{t('Please download it only if it comes from a trusted source.')}
</Text>
</Box>
</Box>
</Box>
</Modal>
);
};

View File

@@ -0,0 +1,30 @@
import '@blocknote/mantine/style.css';
import {
FormattingToolbar,
FormattingToolbarController,
FormattingToolbarProps,
getFormattingToolbarItems,
} from '@blocknote/react';
import React, { useCallback } from 'react';
import { AIGroupButton } from './AIButton';
import { MarkdownButton } from './MarkdownButton';
export const BlockNoteToolbar = () => {
const formattingToolbar = useCallback(
({ blockTypeSelectItems }: FormattingToolbarProps) => (
<FormattingToolbar>
{getFormattingToolbarItems(blockTypeSelectItems)}
{/* Extra button to do some AI powered actions */}
<AIGroupButton key="AIButton" />
{/* Extra button to convert from markdown to json */}
<MarkdownButton key="customButton" />
</FormattingToolbar>
),
[],
);
return <FormattingToolbarController formattingToolbar={formattingToolbar} />;
};

View File

@@ -1,77 +0,0 @@
import { defaultProps, insertOrUpdateBlock } from '@blocknote/core';
import { BlockTypeSelectItem, createReactBlockSpec } from '@blocknote/react';
import { TFunction } from 'i18next';
import React from 'react';
import { Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { DocsBlockNoteEditor } from '../../types';
export const QuoteBlock = createReactBlockSpec(
{
type: 'quote',
propSchema: {
textAlignment: defaultProps.textAlignment,
textColor: defaultProps.textColor,
},
content: 'inline',
},
{
render: (props) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { colorsTokens } = useCunninghamTheme();
return (
<Text
className="inline-content"
$margin="0 0 1rem 0"
$padding="0.5rem 1rem"
$variation="600"
style={{
borderLeft: `4px solid ${colorsTokens()['greyscale-300']}`,
fontStyle: 'italic',
flexGrow: 1,
}}
ref={props.contentRef}
/>
);
},
},
);
export const getQuoteReactSlashMenuItems = (
editor: DocsBlockNoteEditor,
t: TFunction<'translation', undefined>,
group: string,
) => [
{
title: t('Quote'),
onItemClick: () => {
insertOrUpdateBlock(editor, {
type: 'quote',
});
},
aliases: ['quote', 'blockquote', 'citation'],
group,
icon: (
<Text $isMaterialIcon $size="18px">
format_quote
</Text>
),
subtext: t('Add a quote block'),
},
];
export const getQuoteFormattingToolbarItems = (
t: TFunction<'translation', undefined>,
): BlockTypeSelectItem => ({
name: t('Quote'),
type: 'quote',
icon: () => (
<Text $isMaterialIcon $size="16px">
format_quote
</Text>
),
isSelected: (block) => block.type === 'quote',
});

View File

@@ -1 +0,0 @@
export * from './QuoteBlock';

View File

@@ -1,2 +1 @@
export * from './DocEditor';
export * from './custom-blocks/';

View File

@@ -6,14 +6,6 @@ export const cssEditor = (readonly: boolean) => css`
& .ProseMirror {
height: 100%;
img.bn-visual-media[src*='-unsafe'] {
pointer-events: none;
}
.bn-side-menu[data-block-type='quote'] {
height: 46px;
}
.collaboration-cursor-custom__base {
position: relative;
}
@@ -84,13 +76,13 @@ export const cssEditor = (readonly: boolean) => css`
.bn-block-outer:not(:first-child) {
&:has(h1) {
margin-top: 32px;
padding-top: 32px;
}
&:has(h2) {
margin-top: 24px;
padding-top: 24px;
}
&:has(h3) {
margin-top: 16px;
padding-top: 16px;
}
}
@@ -100,16 +92,9 @@ export const cssEditor = (readonly: boolean) => css`
border-radius: 4px;
}
@media screen and (width <= 768px) {
& .bn-editor {
padding-right: 36px;
}
}
@media screen and (width <= 560px) {
& .bn-editor {
${readonly && `padding-left: 10px;`}
padding-right: 10px;
}
.bn-side-menu[data-block-type='heading'][data-level='1'] {
height: 46px;

View File

@@ -17,12 +17,8 @@ export type HeadingBlock = {
};
};
export type DocsBlockSchema = typeof blockNoteSchema.blockSchema;
export type DocsInlineContentSchema =
typeof blockNoteSchema.inlineContentSchema;
export type DocsStyleSchema = typeof blockNoteSchema.styleSchema;
export type DocsBlockNoteEditor = BlockNoteEditor<
DocsBlockSchema,
DocsInlineContentSchema,
DocsStyleSchema
typeof blockNoteSchema.blockSchema,
typeof blockNoteSchema.inlineContentSchema,
typeof blockNoteSchema.styleSchema
>;

View File

@@ -1,25 +0,0 @@
import { Text } from '@react-pdf/renderer';
import { DocsExporterPDF } from '../types';
export const blockMappingHeadingPDF: DocsExporterPDF['mappings']['blockMapping']['heading'] =
(block, exporter) => {
const PIXELS_PER_POINT = 0.75;
const MERGE_RATIO = 7.5;
const FONT_SIZE = 16;
const fontSizeEM =
block.props.level === 1 ? 2 : block.props.level === 2 ? 1.5 : 1.17;
return (
<Text
key={block.id}
style={{
fontSize: fontSizeEM * FONT_SIZE * PIXELS_PER_POINT,
fontWeight: 700,
marginTop: `${fontSizeEM * MERGE_RATIO}px`,
marginBottom: `${fontSizeEM * MERGE_RATIO}px`,
}}
>
{exporter.transformInlineContent(block.content)}
</Text>
);
};

View File

@@ -1,5 +0,0 @@
export * from './headingPDF';
export * from './paragraphPDF';
export * from './quoteDocx';
export * from './quotePDF';
export * from './tablePDF';

View File

@@ -1,31 +0,0 @@
import { Text } from '@react-pdf/renderer';
import { DocsExporterPDF } from '../types';
export const blockMappingParagraphPDF: DocsExporterPDF['mappings']['blockMapping']['paragraph'] =
(block, exporter) => {
/**
* Breakline in the editor are not rendered in the PDF
* By adding a space if the block is empty we ensure that the block is rendered
*/
if (Array.isArray(block.content)) {
block.content.forEach((content) => {
if (content.type === 'text' && !content.text) {
content.text = ' ';
}
});
if (!block.content.length) {
block.content.push({
styles: {},
text: ' ',
type: 'text',
});
}
}
return (
<Text key={block.id}>
{exporter.transformInlineContent(block.content)}
</Text>
);
};

View File

@@ -1,33 +0,0 @@
import { Paragraph } from 'docx';
import { DocsExporterDocx } from '../types';
import { docxBlockPropsToStyles } from '../utils';
export const blockMappingQuoteDocx: DocsExporterDocx['mappings']['blockMapping']['quote'] =
(block, exporter) => {
if (Array.isArray(block.content)) {
block.content.forEach((content) => {
if (content.type === 'text') {
content.styles = {
...content.styles,
italic: true,
textColor: 'gray',
};
}
});
}
return new Paragraph({
...docxBlockPropsToStyles(block.props, exporter.options.colors),
spacing: { before: 10, after: 10 },
border: {
left: {
color: '#cecece',
space: 4,
style: 'thick',
},
},
style: 'Normal',
children: exporter.transformInlineContent(block.content),
});
};

View File

@@ -1,21 +0,0 @@
import { Text } from '@react-pdf/renderer';
import { DocsExporterPDF } from '../types';
export const blockMappingQuotePDF: DocsExporterPDF['mappings']['blockMapping']['quote'] =
(block, exporter) => {
return (
<Text
style={{
fontStyle: 'italic',
marginVertical: 10,
paddingVertical: 5,
paddingLeft: 10,
borderLeft: '4px solid #cecece',
color: '#666',
}}
>
{exporter.transformInlineContent(block.content)}
</Text>
);
};

View File

@@ -1,52 +0,0 @@
import { TD, TH, TR, Table } from '@ag-media/react-pdf-table';
import { View } from '@react-pdf/renderer';
import { DocsExporterPDF } from '../types';
export const blockMappingTablePDF: DocsExporterPDF['mappings']['blockMapping']['table'] =
(block, exporter) => {
return (
<Table>
{block.content.rows.map((row, index) => {
if (index === 0) {
return (
<TH key={index}>
{row.cells.map((cell, index) => {
// Make empty cells are rendered.
if (cell.length === 0) {
cell.push({
styles: {},
text: ' ',
type: 'text',
});
}
return (
<TD key={index}>{exporter.transformInlineContent(cell)}</TD>
);
})}
</TH>
);
}
return (
<TR key={index}>
{row.cells.map((cell, index) => {
// Make empty cells are rendered.
if (cell.length === 0) {
cell.push({
styles: {},
text: ' ',
type: 'text',
});
}
return (
<TD key={index}>
<View>{exporter.transformInlineContent(cell)}</View>
</TD>
);
})}
</TR>
);
})}
</Table>
);
};

View File

@@ -1 +0,0 @@
export * from './ModalExport';

View File

@@ -1,2 +0,0 @@
export * from './components';
export * from './utils';

View File

@@ -1,12 +0,0 @@
import { docxDefaultSchemaMappings } from '@blocknote/xl-docx-exporter';
import { blockMappingQuoteDocx } from './blocks-mapping/';
import { DocsExporterDocx } from './types';
export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = {
...docxDefaultSchemaMappings,
blockMapping: {
...docxDefaultSchemaMappings.blockMapping,
quote: blockMappingQuoteDocx,
},
};

View File

@@ -1,20 +0,0 @@
import { pdfDefaultSchemaMappings } from '@blocknote/xl-pdf-exporter';
import {
blockMappingHeadingPDF,
blockMappingParagraphPDF,
blockMappingQuotePDF,
blockMappingTablePDF,
} from './blocks-mapping';
import { DocsExporterPDF } from './types';
export const pdfDocsSchemaMappings: DocsExporterPDF['mappings'] = {
...pdfDefaultSchemaMappings,
blockMapping: {
...pdfDefaultSchemaMappings.blockMapping,
heading: blockMappingHeadingPDF,
paragraph: blockMappingParagraphPDF,
quote: blockMappingQuotePDF,
table: blockMappingTablePDF,
},
};

View File

@@ -1,53 +0,0 @@
import { Exporter } from '@blocknote/core';
import { Link, Text, TextProps } from '@react-pdf/renderer';
import {
IRunPropertiesOptions,
Paragraph,
ParagraphChild,
Table,
TextRun,
} from 'docx';
import {
DocsBlockSchema,
DocsInlineContentSchema,
DocsStyleSchema,
} from '../doc-editor';
import { Access } from '../doc-management';
export interface Template {
id: string;
abilities: {
destroy: boolean;
generate_document: boolean;
accesses_manage: boolean;
retrieve: boolean;
update: boolean;
partial_update: boolean;
};
accesses: Access[];
title: string;
is_public: boolean;
css: string;
code: string;
}
export type DocsExporterPDF = Exporter<
NoInfer<DocsBlockSchema>,
NoInfer<DocsInlineContentSchema>,
NoInfer<DocsStyleSchema>,
React.ReactElement<Text>,
React.ReactElement<Link> | React.ReactElement<Text>,
TextProps['style'],
React.ReactElement<Text>
>;
export type DocsExporterDocx = Exporter<
NoInfer<DocsBlockSchema>,
NoInfer<DocsInlineContentSchema>,
NoInfer<DocsStyleSchema>,
Promise<Paragraph[] | Paragraph | Table> | Paragraph[] | Paragraph | Table,
ParagraphChild,
IRunPropertiesOptions,
TextRun
>;

View File

@@ -1,75 +0,0 @@
import {
COLORS_DEFAULT,
DefaultProps,
UnreachableCaseError,
} from '@blocknote/core';
import { IParagraphOptions, ShadingType } from 'docx';
export function downloadFile(blob: Blob, filename: string) {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}
export const exportResolveFileUrl = async (
url: string,
resolveFileUrl: ((url: string) => Promise<string | Blob>) | undefined,
) => {
if (!url.includes(window.location.hostname) && resolveFileUrl) {
return resolveFileUrl(url);
}
try {
const response = await fetch(url, {
credentials: 'include',
});
return response.blob();
} catch {
console.error(`Failed to fetch image: ${url}`);
}
return url;
};
export function docxBlockPropsToStyles(
props: Partial<DefaultProps>,
colors: typeof COLORS_DEFAULT,
): IParagraphOptions {
return {
shading:
props.backgroundColor === 'default' || !props.backgroundColor
? undefined
: {
type: ShadingType.SOLID,
color:
colors[
props.backgroundColor as keyof typeof colors
].background.slice(1),
},
run:
props.textColor === 'default' || !props.textColor
? undefined
: {
color: colors[props.textColor as keyof typeof colors].text.slice(1),
},
alignment:
!props.textAlignment || props.textAlignment === 'left'
? undefined
: props.textAlignment === 'center'
? 'center'
: props.textAlignment === 'right'
? 'right'
: props.textAlignment === 'justify'
? 'distribute'
: (() => {
throw new UnreachableCaseError(props.textAlignment);
})(),
};
}

View File

@@ -27,7 +27,6 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
const { t } = useTranslation();
const docIsPublic = doc.link_reach === LinkReach.PUBLIC;
const docIsAuth = doc.link_reach === LinkReach.AUTHENTICATED;
const { transRole } = useTrans();
@@ -39,7 +38,7 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
$gap={spacings['base']}
aria-label={t('It is the card information about the document.')}
>
{(docIsPublic || docIsAuth) && (
{docIsPublic && (
<Box
aria-label={t('Public document')}
$color={colors['primary-800']}
@@ -58,12 +57,10 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
$theme="primary"
$variation="800"
data-testid="public-icon"
iconName={docIsPublic ? 'public' : 'vpn_lock'}
iconName="public"
/>
<Text $theme="primary" $variation="800">
{docIsPublic
? t('Public document')
: t('Document accessible to any connected person')}
{t('Public document')}
</Text>
</Box>
)}
@@ -79,9 +76,8 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
$css="flex:1;"
$gap="0.5rem 1rem"
$align="center"
$maxWidth="100%"
>
<Box $gap={spacings['3xs']} $overflow="auto">
<Box $gap={spacings['3xs']}>
<DocTitle doc={doc} />
<Box $direction="row">

View File

@@ -33,13 +33,11 @@ export const DocTitle = ({ doc }: DocTitleProps) => {
};
interface DocTitleTextProps {
title?: string;
title: string;
}
export const DocTitleText = ({ title }: DocTitleTextProps) => {
const { isMobile } = useResponsiveStore();
const { untitledDocument } = useTrans();
return (
<Text
as="h2"
@@ -47,7 +45,7 @@ export const DocTitleText = ({ title }: DocTitleTextProps) => {
$size={isMobile ? 'h4' : 'h2'}
$variation="1000"
>
{title || untitledDocument}
{title}
</Text>
);
};
@@ -103,37 +101,39 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
}, [doc]);
return (
<Tooltip content={t('Rename')} placement="top">
<Box
as="span"
role="textbox"
contentEditable
defaultValue={titleDisplay || undefined}
onKeyDownCapture={handleKeyDown}
suppressContentEditableWarning={true}
aria-label="doc title input"
onBlurCapture={(event) =>
handleTitleSubmit(event.target.textContent || '')
}
$color={colorsTokens()['greyscale-1000']}
$minHeight="40px"
$padding={{ right: 'big' }}
$css={css`
&[contenteditable='true']:empty:not(:focus):before {
content: '${untitledDocument}';
color: grey;
pointer-events: none;
font-style: italic;
<>
<Tooltip content={t('Rename')} placement="top">
<Box
as="span"
role="textbox"
contentEditable
defaultValue={titleDisplay || undefined}
onKeyDownCapture={handleKeyDown}
suppressContentEditableWarning={true}
aria-label="doc title input"
onBlurCapture={(event) =>
handleTitleSubmit(event.target.textContent || '')
}
font-size: ${isDesktop
? css`var(--c--theme--font--sizes--h2)`
: css`var(--c--theme--font--sizes--sm)`};
font-weight: 700;
outline: none;
`}
>
{titleDisplay}
</Box>
</Tooltip>
$color={colorsTokens()['greyscale-1000']}
$margin={{ left: '-2px', right: '10px' }}
$css={css`
&[contenteditable='true']:empty:not(:focus):before {
content: '${untitledDocument}';
color: grey;
pointer-events: none;
font-style: italic;
}
font-size: ${isDesktop
? css`var(--c--theme--font--sizes--h2)`
: css`var(--c--theme--font--sizes--sm)`};
font-weight: 700;
outline: none;
`}
>
{titleDisplay}
</Box>
</Tooltip>
</>
);
};

View File

@@ -18,12 +18,7 @@ import {
} from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useEditorStore } from '@/features/docs/doc-editor/';
import { ModalExport } from '@/features/docs/doc-export/';
import {
Doc,
ModalRemoveDoc,
useCopyDocLink,
} from '@/features/docs/doc-management';
import { Doc, ModalRemoveDoc } from '@/features/docs/doc-management';
import { DocShareModal } from '@/features/docs/doc-share';
import {
KEY_LIST_DOC_VERSIONS,
@@ -31,13 +26,15 @@ import {
} from '@/features/docs/doc-versioning';
import { useResponsiveStore } from '@/stores';
import { ModalExport } from './ModalExport';
interface DocToolBoxProps {
doc: Doc;
}
export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { t } = useTranslation();
const hasAccesses = doc.nb_accesses > 1 && doc.abilities.accesses_view;
const hasAccesses = doc.nb_accesses > 1;
const queryClient = useQueryClient();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
@@ -53,7 +50,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { isSmallMobile, isDesktop } = useResponsiveStore();
const { editor } = useEditorStore();
const { toast } = useToastProvider();
const copyDocLink = useCopyDocLink(doc.id);
const options: DropdownMenuOption[] = [
...(isSmallMobile
@@ -70,11 +66,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
setIsModalExportOpen(true);
},
},
{
label: t('Copy link'),
icon: 'add_link',
callback: copyDocLink,
},
]
: []),

View File

@@ -6,7 +6,7 @@ import { useCunninghamTheme } from '@/cunningham';
import { DocTitleText } from './DocTitle';
interface DocVersionHeaderProps {
title?: string;
title: string;
}
export const DocVersionHeader = ({ title }: DocVersionHeaderProps) => {

View File

@@ -1,5 +1,11 @@
import { DOCXExporter } from '@blocknote/xl-docx-exporter';
import { PDFExporter } from '@blocknote/xl-pdf-exporter';
import {
DOCXExporter,
docxDefaultSchemaMappings,
} from '@blocknote/xl-docx-exporter';
import {
PDFExporter,
pdfDefaultSchemaMappings,
} from '@blocknote/xl-pdf-exporter';
import {
Button,
Loader,
@@ -9,20 +15,20 @@ import {
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { pdf } from '@react-pdf/renderer';
import { Text as PDFText, pdf } from '@react-pdf/renderer';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Text } from '@/components';
import { useEditorStore } from '@/features/docs/doc-editor';
import { Doc, useTrans } from '@/features/docs/doc-management';
import { Doc } from '@/features/docs/doc-management';
import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
import { docxDocsSchemaMappings } from '../mappingDocx';
import { pdfDocsSchemaMappings } from '../mappingPDF';
import { downloadFile, exportResolveFileUrl } from '../utils';
import { Table } from './blocks/Table';
enum DocDownloadFormat {
PDF = 'pdf',
DOCX = 'docx',
@@ -45,7 +51,6 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
const [format, setFormat] = useState<DocDownloadFormat>(
DocDownloadFormat.PDF,
);
const { untitledDocument } = useTrans();
const templateOptions = useMemo(() => {
const templateOptions = (templates?.pages || [])
@@ -73,7 +78,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
setIsExporting(true);
const title = (doc.title || untitledDocument)
const title = doc.title
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
@@ -90,25 +95,91 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
if (format === DocDownloadFormat.PDF) {
const defaultExporter = new PDFExporter(
editor.schema,
pdfDocsSchemaMappings,
pdfDefaultSchemaMappings,
);
const exporter = new PDFExporter(editor.schema, pdfDocsSchemaMappings, {
resolveFileUrl: async (url) =>
exportResolveFileUrl(url, defaultExporter.options.resolveFileUrl),
});
const exporter = new PDFExporter(
editor.schema,
{
...pdfDefaultSchemaMappings,
blockMapping: {
...pdfDefaultSchemaMappings.blockMapping,
heading: (block, exporter) => {
const PIXELS_PER_POINT = 0.75;
const MERGE_RATIO = 7.5;
const FONT_SIZE = 16;
const fontSizeEM =
block.props.level === 1
? 2
: block.props.level === 2
? 1.5
: 1.17;
return (
<PDFText
key={block.id}
style={{
fontSize: fontSizeEM * FONT_SIZE * PIXELS_PER_POINT,
fontWeight: 700,
marginTop: `${fontSizeEM * MERGE_RATIO}px`,
marginBottom: `${fontSizeEM * MERGE_RATIO}px`,
}}
>
{exporter.transformInlineContent(block.content)}
</PDFText>
);
},
paragraph: (block, exporter) => {
/**
* Breakline in the editor are not rendered in the PDF
* By adding a space if the block is empty we ensure that the block is rendered
*/
if (Array.isArray(block.content)) {
block.content.forEach((content) => {
if (content.type === 'text' && !content.text) {
content.text = ' ';
}
});
if (!block.content.length) {
block.content.push({
styles: {},
text: ' ',
type: 'text',
});
}
}
return (
<PDFText key={block.id}>
{exporter.transformInlineContent(block.content)}
</PDFText>
);
},
table: (block, transformer) => {
return <Table data={block.content} transformer={transformer} />;
},
},
},
{
resolveFileUrl: async (url) =>
exportResolveFileUrl(url, defaultExporter.options.resolveFileUrl),
},
);
const pdfDocument = await exporter.toReactPDFDocument(exportDocument);
blobExport = await pdf(pdfDocument).toBlob();
} else {
const defaultExporter = new DOCXExporter(
editor.schema,
docxDocsSchemaMappings,
docxDefaultSchemaMappings,
);
const exporter = new DOCXExporter(editor.schema, docxDocsSchemaMappings, {
resolveFileUrl: async (url) =>
exportResolveFileUrl(url, defaultExporter.options.resolveFileUrl),
});
const exporter = new DOCXExporter(
editor.schema,
docxDefaultSchemaMappings,
{
resolveFileUrl: async (url) =>
exportResolveFileUrl(url, defaultExporter.options.resolveFileUrl),
},
);
blobExport = await exporter.toBlob(exportDocument);
}

View File

@@ -0,0 +1,76 @@
import { TD, TH, TR, Table as TablePDF } from '@ag-media/react-pdf-table';
import {
DefaultBlockSchema,
Exporter,
InlineContentSchema,
StyleSchema,
TableContent,
} from '@blocknote/core';
import { View } from '@react-pdf/renderer';
import { ReactNode } from 'react';
export const Table = (props: {
data: TableContent<InlineContentSchema>;
transformer: Exporter<
DefaultBlockSchema,
InlineContentSchema,
StyleSchema,
unknown,
unknown,
unknown,
unknown
>;
}) => {
return (
<TablePDF>
{props.data.rows.map((row, index) => {
if (index === 0) {
return (
<TH key={index}>
{row.cells.map((cell, index) => {
// Make empty cells are rendered.
if (cell.length === 0) {
cell.push({
styles: {},
text: ' ',
type: 'text',
});
}
return (
<TD key={index}>
{props.transformer.transformInlineContent(cell)}
</TD>
);
})}
</TH>
);
}
return (
<TR key={index}>
{row.cells.map((cell, index) => {
// Make empty cells are rendered.
if (cell.length === 0) {
cell.push({
styles: {},
text: ' ',
type: 'text',
});
}
return (
<TD key={index}>
<View>
{
props.transformer.transformInlineContent(
cell,
) as ReactNode
}
</View>
</TD>
);
})}
</TR>
);
})}
</TablePDF>
);
};

View File

@@ -0,0 +1,18 @@
import { Access } from '../doc-management';
export interface Template {
id: string;
abilities: {
destroy: boolean;
generate_document: boolean;
accesses_manage: boolean;
retrieve: boolean;
update: boolean;
partial_update: boolean;
};
accesses: Access[];
title: string;
is_public: boolean;
css: string;
code: string;
}

View File

@@ -0,0 +1,32 @@
export function downloadFile(blob: Blob, filename: string) {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}
export const exportResolveFileUrl = async (
url: string,
resolveFileUrl: ((url: string) => Promise<string | Blob>) | undefined,
) => {
if (!url.includes(window.location.hostname) && resolveFileUrl) {
return resolveFileUrl(url);
}
try {
const response = await fetch(url, {
credentials: 'include',
});
return response.blob();
} catch {
console.error(`Failed to fetch image: ${url}`);
}
return url;
};

View File

@@ -71,15 +71,9 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
</Button>
</>
}
size={ModalSize.SMALL}
size={ModalSize.MEDIUM}
title={
<Text
$size="h6"
as="h6"
$margin={{ all: '0' }}
$align="flex-start"
$variation="1000"
>
<Text $size="h6" as="h6" $margin={{ all: '0' }} $align="flex-start">
{t('Delete a doc')}
</Text>
}
@@ -87,7 +81,9 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
<Box aria-label={t('Content modal to delete document')}>
{!isError && (
<Text $size="sm" $variation="600">
{t('Are you sure you want to delete this document ?')}
{t('Are you sure you want to delete the document "{{title}}"?', {
title: doc.title,
})}
</Text>
)}

View File

@@ -36,7 +36,7 @@ export type Base64 = string;
export interface Doc {
id: string;
title?: string;
title: string;
content: Base64;
creator: string;
is_favorite: boolean;

View File

@@ -33,7 +33,9 @@ export const DocShareModalFooter = ({ doc, onClose }: Props) => {
>
<Button
fullWidth={false}
onClick={copyDocLink}
onClick={() => {
copyDocLink();
}}
color="tertiary"
icon={<span className="material-icons">add_link</span>}
>

View File

@@ -100,21 +100,17 @@ export const ModalConfirmationVersion = ({
</Button>
</>
}
size={ModalSize.SMALL}
size={ModalSize.MEDIUM}
title={
<Text $size="h6" $align="flex-start" $variation="1000">
<Text $size="h6" $align="flex-start">
{t('Warning')}
</Text>
}
>
<Box aria-label={t('Modal confirmation to restore the version')}>
<Box>
<Text $variation="600">
{t('Your current document will revert to this version.')}
</Text>
<Text $variation="600">
{t('If a member is editing, his works can be lost.')}
</Text>
<Text>{t('Your current document will revert to this version.')}</Text>
<Text>{t('If a member is editing, his works can be lost.')}</Text>
</Box>
</Box>
</Modal>

View File

@@ -61,8 +61,12 @@ export const DocsGrid = ({
$position="relative"
$width="100%"
$maxWidth="960px"
$maxHeight="calc(100vh - 52px - 2rem)"
$maxHeight="calc(100vh - 52px - 1rem)"
$align="center"
$css={css`
overflow-x: hidden;
overflow-y: auto;
`}
>
<DocsGridLoader isLoading={isRefetching || loading} />
<Card
@@ -71,7 +75,8 @@ export const DocsGrid = ({
$height="100%"
$width="100%"
$css={css`
${!isDesktop ? 'border: none;' : ''}
overflow-x: hidden;
overflow-y: auto;
`}
$padding={{
top: 'base',
@@ -96,7 +101,7 @@ export const DocsGrid = ({
</Box>
)}
{hasDocs && (
<Box $gap="6px" $overflow="auto">
<Box $gap="6px">
<Box
$direction="row"
$padding={{ horizontal: 'xs' }}
@@ -117,30 +122,28 @@ export const DocsGrid = ({
)}
</Box>
{/* Body */}
{data?.pages.map((currentPage) => {
return currentPage.results.map((doc) => (
<DocsGridItem doc={doc} key={doc.id} />
));
})}
{hasNextPage && !loading && (
<InView
data-testid="infinite-scroll-trigger"
as="div"
onChange={loadMore}
>
{!isFetching && hasNextPage && (
<Button
onClick={() => void fetchNextPage()}
color="primary-text"
>
{t('More docs')}
</Button>
)}
</InView>
)}
</Box>
)}
{hasNextPage && !loading && (
<InView
data-testid="infinite-scroll-trigger"
as="div"
onChange={loadMore}
>
{!isFetching && hasNextPage && (
<Button onClick={() => void fetchNextPage()} color="primary-text">
{t('More docs')}
</Button>
)}
</InView>
)}
</Card>
</Box>
);

View File

@@ -54,7 +54,6 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
$css={css`
flex: ${flexLeft};
align-items: center;
min-width: 0;
`}
href={`/docs/${doc.id}`}
>
@@ -65,7 +64,6 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
$gap={spacings.xs}
$flex={flexLeft}
$padding={{ right: isDesktop ? 'md' : '3xs' }}
$maxWidth="100%"
>
<SimpleDocItem isPinned={doc.is_favorite} doc={doc} />
{showAccesses && (

View File

@@ -38,7 +38,7 @@ export const SimpleDocItem = ({
const { untitledDocument } = useTrans();
return (
<Box $direction="row" $gap={spacings.sm} $overflow="auto">
<Box $direction="row" $gap={spacings.sm}>
<Box
$direction="row"
$align="center"
@@ -53,7 +53,7 @@ export const SimpleDocItem = ({
<SimpleFileIcon aria-label={t('Simple document icon')} />
)}
</Box>
<Box $justify="center" $overflow="auto">
<Box $justify="center">
<Text
aria-describedby="doc-title"
aria-label={doc.title}

View File

@@ -55,7 +55,7 @@ export default function HomeBanner() {
$textAlign="center"
$margin="none"
$css={css`
line-height: ${!isMobile ? '56px' : '45px'};
line-height: 56px;
`}
>
{t('Collaborative writing, Simplified.')}
@@ -74,7 +74,7 @@ export default function HomeBanner() {
<ProConnectButton />
) : (
<Button
onClick={() => gotoLogin()}
onClick={gotoLogin}
icon={
<Text $isMaterialIcon $color="white">
bolt

View File

@@ -1,5 +1,7 @@
import { Button } from '@openfun/cunningham-react';
import Image from 'next/image';
import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import DocLogo from '@/assets/icons/icon-docs.svg?url';
import { Box, Text } from '@/components';
@@ -8,15 +10,137 @@ import { ProConnectButton } from '@/features/auth';
import { Title } from '@/features/header';
import { useResponsiveStore } from '@/stores';
import SC5 from '../assets/SC5.png';
import GithubIcon from '../assets/github.svg';
import { HomeSection } from './HomeSection';
export function HomeBottom() {
const { componentTokens } = useCunninghamTheme();
const withProConnect = componentTokens()['home-proconnect'].activated;
if (!withProConnect) {
return null;
if (withProConnect) {
return <HomeProConnect />;
} else {
return <HomeOpenSource />;
}
}
return <HomeProConnect />;
function HomeOpenSource() {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const { isTablet } = useResponsiveStore();
return (
<HomeSection
isColumn={false}
isSmallDevice={isTablet}
illustration={SC5}
title={t('Govs ❤️ Open Source.')}
tag={t('Open Source')}
textWidth="60%"
description={
<Box
$css={css`
& a {
color: ${colorsTokens()['primary-600']};
}
`}
>
<Text as="p" $display="inline">
<Trans t={t} i18nKey="home-content-open-source-part1">
Docs is built on top of{' '}
<a href="https://www.django-rest-framework.org/" target="_blank">
Django Rest Framework
</a>
,{' '}
<a href="https://nextjs.org/" target="_blank">
Next.js
</a>
, and{' '}
<a href="https://min.io/" target="_blank">
MinIO
</a>
. We also use{' '}
<a href="https://github.com/yjs" target="_blank">
Yjs
</a>{' '}
and{' '}
<a href="https://www.blocknotejs.org/" target="_blank">
BlockNote.js
</a>{' '}
of which we are proud sponsors.
</Trans>
</Text>
<Text as="p" $display="inline">
<Trans t={t} i18nKey="home-content-open-source-part2">
You can easily self-hosted Docs (check our installation{' '}
<a
href="https://github.com/suitenumerique/docs/tree/main/docs"
target="_blank"
>
documentation
</a>{' '}
with production-ready examples).
<br />
Docs uses an innovation and business friendly{' '}
<a
href="https://github.com/suitenumerique/docs/blob/main/LICENSE"
target="_blank"
>
licence
</a>
.<br />
Contributions are welcome (see our roadmap{' '}
<a
href="https://github.com/orgs/numerique-gouv/projects/13/views/11"
target="_blank"
>
here
</a>
).
</Trans>
</Text>
<Text as="p" $display="inline">
<Trans t={t} i18nKey="home-content-open-source-part3">
Docs is the result of a joint effort lead by the French 🇫🇷🥖
<a href="https://www.numerique.gouv.fr/dinum/" target="_blank">
(DINUM)
</a>{' '}
and German 🇩🇪🥨 governments{' '}
<a href="https://zendis.de/" target="_blank">
(ZenDiS)
</a>
. We are always looking for new public partners (we are currently
onboarding the Netherlands 🇳🇱🧀). Feel free to reach out if you
are interested in using or contributing to docs.
</Trans>
</Text>
<Box $direction="row" $gap="1rem" $margin={{ top: 'small' }}>
<Button
icon={
<Text $isMaterialIcon $color="white">
chat
</Text>
}
href="https://matrix.to/#/#docs-official:matrix.org"
target="_blank"
>
<Text $color="white">Matrix</Text>
</Button>
<Button
color="secondary"
icon={<GithubIcon />}
href="https://github.com/suitenumerique/docs"
target="_blank"
>
Github
</Button>
</Box>
</Box>
}
/>
);
}
function HomeProConnect() {

View File

@@ -1,9 +1,7 @@
import { Button } from '@openfun/cunningham-react';
import { Trans, useTranslation } from 'react-i18next';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Box } from '@/components';
import { Footer } from '@/features/footer';
import { LeftPanel } from '@/features/left-panel';
import { useLanguage } from '@/i18n/hooks/useLanguage';
@@ -19,8 +17,6 @@ import SC4En from '../assets/SC4-en.png';
import SC4Fr from '../assets/SC4-fr.png';
import SC4ResponsiveEn from '../assets/SC4-responsive-en.png';
import SC4ResponsiveFr from '../assets/SC4-responsive-fr.png';
import SC5 from '../assets/SC5.png';
import GithubIcon from '../assets/github.svg';
import HomeBanner from './HomeBanner';
import { HomeBottom } from './HomeBottom';
@@ -29,8 +25,7 @@ import { HomeSection } from './HomeSection';
export function HomeContent() {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const { isMobile, isSmallMobile, isTablet } = useResponsiveStore();
const { isMobile, isSmallMobile } = useResponsiveStore();
const lang = useLanguage();
const isFrLanguage = lang.language === 'fr';
@@ -63,142 +58,19 @@ export function HomeContent() {
$gap={isMobile ? '115px' : '230px'}
$padding={{ bottom: '3rem' }}
>
<Box $gap="30px">
<HomeSection
isColumn={false}
isSmallDevice={isTablet}
illustration={SC5}
title={t('Govs ❤️ Open Source.')}
tag={t('Open Source')}
textWidth="60%"
$css={`min-height: calc(100vh - ${getHeaderHeight(isSmallMobile)}px);`}
description={
<Box
$css={css`
& a {
color: ${colorsTokens()['primary-600']};
}
`}
>
<Text as="p" $display="inline">
<Trans t={t} i18nKey="home-content-open-source-part1">
Docs is built on top of{' '}
<a
href="https://www.django-rest-framework.org/"
target="_blank"
>
Django Rest Framework
</a>
,{' '}
<a href="https://nextjs.org/" target="_blank">
Next.js
</a>
, and{' '}
<a href="https://min.io/" target="_blank">
MinIO
</a>
. We also use{' '}
<a href="https://github.com/yjs" target="_blank">
Yjs
</a>{' '}
and{' '}
<a href="https://www.blocknotejs.org/" target="_blank">
BlockNote.js
</a>{' '}
of which we are proud sponsors.
</Trans>
</Text>
<Text as="p" $display="inline">
<Trans t={t} i18nKey="home-content-open-source-part2">
You can easily self-hosted Docs (check our installation{' '}
<a
href="https://github.com/suitenumerique/docs/tree/main/docs"
target="_blank"
>
documentation
</a>{' '}
with production-ready examples).
<br />
Docs uses an innovation and business friendly{' '}
<a
href="https://github.com/suitenumerique/docs/blob/main/LICENSE"
target="_blank"
>
licence
</a>
.<br />
Contributions are welcome (see our roadmap{' '}
<a
href="https://github.com/orgs/numerique-gouv/projects/13/views/11"
target="_blank"
>
here
</a>
).
</Trans>
</Text>
<Text as="p" $display="inline">
<Trans t={t} i18nKey="home-content-open-source-part3">
Docs is the result of a joint effort lead by the French
🇫🇷🥖
<a
href="https://www.numerique.gouv.fr/dinum/"
target="_blank"
>
(DINUM)
</a>{' '}
and German 🇩🇪🥨 governments{' '}
<a href="https://zendis.de/" target="_blank">
(ZenDiS)
</a>
. We are always looking for new public partners (we are
currently onboarding the Netherlands 🇳🇱🧀). Feel free to
reach out if you are interested in using or contributing
to docs.
</Trans>
</Text>
<Box
$direction="row"
$gap="1rem"
$margin={{ top: 'small' }}
>
<Button
icon={
<Text $isMaterialIcon $color="white">
chat
</Text>
}
href="https://matrix.to/#/#docs-official:matrix.org"
target="_blank"
>
<Text $color="white">Matrix</Text>
</Button>
<Button
color="secondary"
icon={<GithubIcon />}
href="https://github.com/suitenumerique/docs"
target="_blank"
>
Github
</Button>
</Box>
</Box>
}
/>
<HomeSection
isColumn={true}
isSmallDevice={isMobile}
illustration={isFrLanguage ? SC1ResponsiveFr : SC1ResponsiveEn}
video={
isFrLanguage ? `/assets/SC1-fr.webm` : `/assets/SC1-en.webm`
}
title={t('An uncompromising writing experience.')}
tag={t('Write')}
description={t(
'Docs offers an intuitive writing experience. Its minimalist interface favors content over layout, while offering the essentials: media import, offline mode and keyboard shortcuts for greater efficiency.',
)}
/>
</Box>
<HomeSection
isColumn={true}
isSmallDevice={isMobile}
illustration={isFrLanguage ? SC1ResponsiveFr : SC1ResponsiveEn}
video={
isFrLanguage ? `/assets/SC1-fr.webm` : `/assets/SC1-en.webm`
}
title={t('An uncompromising writing experience.')}
tag={t('Write')}
description={t(
'Docs offers an intuitive writing experience. Its minimalist interface favors content over layout, while offering the essentials: media import, offline mode and keyboard shortcuts for greater efficiency.',
)}
/>
<HomeSection
isColumn={false}
isSmallDevice={isMobile}

View File

@@ -3,7 +3,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, BoxType, Text } from '@/components';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useResponsiveStore } from '@/stores';
@@ -18,12 +18,10 @@ export type HomeSectionProps = {
reverse?: boolean;
textWidth?: string;
video?: string;
$css?: BoxType['$css'];
};
export const HomeSection = ({
availableSoon = false,
$css,
description,
illustration,
isSmallDevice,
@@ -91,7 +89,6 @@ export const HomeSection = ({
$hasTransition="slow"
$css={css`
opacity: ${isVisible ? 1 : 0};
${$css}
`}
>
<Box
@@ -119,17 +116,13 @@ export const HomeSection = ({
`}
$variation="1000"
$weight="bold"
$size={!isSmallDevice ? 'xs-alt' : isSmallMobile ? 'h6' : 'h4'}
$textAlign="left"
$size={!isSmallDevice ? 'xs-alt' : 'h4'}
$textAlign={isSmallMobile ? 'center' : 'left'}
$margin="none"
>
{title}
</Text>
<Text
$variation="700"
$weight="400"
$size={isSmallMobile ? 'ml' : 'md'}
>
<Text $variation="700" $weight="400" $size="md">
{description}
</Text>
</Box>

View File

@@ -1,61 +1,84 @@
import { Select } from '@openfun/cunningham-react';
import { Settings } from 'luxon';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import styled from 'styled-components';
import { DropdownMenu, Text } from '@/components/';
import { Box, Text } from '@/components/';
import { LANGUAGES_ALLOWED } from '@/i18n/conf';
const SelectStyled = styled(Select)<{ $isSmall?: boolean }>`
flex-shrink: 0;
width: auto;
.c__select__wrapper {
min-height: 2rem;
height: auto;
border-color: transparent;
padding: 0 0.15rem 0 0.45rem;
border-radius: 1px;
.labelled-box .labelled-box__children {
padding-right: 2rem;
.c_select__render .typo-text {
${({ $isSmall }) => $isSmall && `display: none;`}
}
}
&:hover {
box-shadow: none !important;
}
}
`;
export const LanguagePicker = () => {
const { t, i18n } = useTranslation();
const { preload: languages } = i18n.options;
const language = i18n.language;
Settings.defaultLocale = language;
Settings.defaultLocale = i18n.language;
const optionsPicker = useMemo(() => {
return (languages || []).map((lang) => ({
label: LANGUAGES_ALLOWED[lang],
isSelected: language === lang,
callback: () => {
i18n.changeLanguage(lang).catch((err) => {
console.error('Error changing language', err);
});
},
value: lang,
label: lang,
render: () => (
<Box
className="c_select__render"
$direction="row"
$gap="0.7rem"
$align="center"
>
<Text
$isMaterialIcon
$size="1rem"
$theme="primary"
$weight="bold"
$variation="800"
>
translate
</Text>
<Text $theme="primary" $weight="500" $variation="800">
{LANGUAGES_ALLOWED[lang]}
</Text>
</Box>
),
}));
}, [i18n, language, languages]);
}, [languages]);
return (
<DropdownMenu
<SelectStyled
label={t('Language')}
showLabelWhenSelected={false}
clearable={false}
hideLabel
defaultValue={i18n.language}
className="c_select__no_bg"
options={optionsPicker}
showArrow
buttonCss={css`
&:hover {
background-color: var(
--c--components--button--primary-text--background--color-hover
);
}
border-radius: 4px;
padding: 0.5rem 0.6rem;
& > div {
gap: 0.2rem;
display: flex;
}
& .material-icons {
color: var(--c--components--button--primary-text--color) !important;
}
`}
>
<Text
$theme="primary"
aria-label={t('Language')}
$direction="row"
$gap="0.5rem"
>
<Text $isMaterialIcon $color="inherit" $size="xl">
translate
</Text>
{LANGUAGES_ALLOWED[language]}
</Text>
</DropdownMenu>
onChange={(e) => {
i18n.changeLanguage(e.target.value as string).catch((err) => {
console.error('Error changing language', err);
});
}}
/>
);
};

View File

@@ -39,7 +39,7 @@ export const LeftPanelFavoriteItem = ({ doc }: LeftPanelFavoriteItemProps) => {
`}
key={doc.id}
>
<StyledLink href={`/docs/${doc.id}`} $css="overflow: auto;">
<StyledLink href={`/docs/${doc.id}`}>
<SimpleDocItem showAccesses doc={doc} />
</StyledLink>
<div className="pinned-actions">

View File

@@ -146,11 +146,20 @@ export class ApiPlugin implements WorkboxPlugin {
await RequestSerializer.fromRequest(this.initialRequest)
).toObject();
if (!requestData.body) {
return new Response('Body found', { status: 404 });
}
const jsonObject = RequestSerializer.arrayBufferToJson<Partial<Doc>>(
requestData.body,
);
// Add a new doc id to the create request
const uuid = self.crypto.randomUUID();
const newRequestData = {
...requestData,
body: RequestSerializer.objectToArrayBuffer({
...jsonObject,
id: uuid,
}),
};
@@ -166,8 +175,16 @@ export class ApiPlugin implements WorkboxPlugin {
'doc-mutation',
);
/**
* Create new item in the cache
*/
const bodyMutate = (await this.initialRequest
.clone()
.json()) as Partial<Doc>;
const newResponse: Doc = {
title: '',
...bodyMutate,
id: uuid,
content: '',
created_at: new Date().toISOString(),

View File

@@ -346,8 +346,13 @@ describe('ApiPlugin', () => {
headers: new Headers({
'Content-Type': 'application/json',
}),
arrayBuffer: () => RequestSerializer.objectToArrayBuffer({}),
json: () => ({}),
arrayBuffer: () =>
RequestSerializer.objectToArrayBuffer({
title: 'my new doc',
}),
json: () => ({
title: 'my new doc',
}),
} as unknown as Request,
} as any;
@@ -384,7 +389,9 @@ describe('ApiPlugin', () => {
);
expect(mockedPut).toHaveBeenCalledWith(
'doc-item',
expect.objectContaining({}),
expect.objectContaining({
title: 'my new doc',
}),
'http://test.jest/documents/444555/',
);
expect(mockedPut).toHaveBeenCalledWith(
@@ -393,6 +400,7 @@ describe('ApiPlugin', () => {
results: expect.arrayContaining([
expect.objectContaining({
id: '444555',
title: 'my new doc',
}),
]),
}),

View File

@@ -15,6 +15,8 @@
"Anyone with the link can edit the document if they are logged in": "Jeder mit dem Link kann das Dokument bearbeiten, wenn er angemeldet ist",
"Anyone with the link can see the document": "Jeder mit dem Link kann das Dokument ansehen",
"Anyone with the link can view the document if they are logged in": "Jeder mit dem Link kann das Dokument ansehen, wenn er angemeldet ist",
"Are you sure you want to delete the document \"{{title}}\"?": "Sind Sie sicher, dass Sie das Dokument \"{{title}}\" löschen möchten?",
"Back to home page": "Zurück zur Startseite",
"Can't load this page, please check your internet connection.": "Diese Seite kann nicht geladen werden. Bitte überprüfen Sie Ihre Internetverbindung.",
"Cancel": "Abbrechen",
"Close the modal": "Pop up schliessen",
@@ -173,7 +175,6 @@
"Accessible to anyone": "Accessible à tout le monde",
"Accessible to authenticated users": "Accessible aux utilisateurs authentifiés",
"Add": "Ajouter",
"Add a quote block": "Ajouter un bloc de citation",
"Address:": "Adresse :",
"Administrator": "Administrateur",
"All docs": "Tous les documents",
@@ -183,8 +184,9 @@
"Anyone with the link can edit the document if they are logged in": "N'importe qui avec le lien peut éditer le document à condition qu'il soit connecté",
"Anyone with the link can see the document": "N'importe qui avec le lien peut voir le document",
"Anyone with the link can view the document if they are logged in": "N'importe qui avec le lien peut voir le document à condition qu'il soit connecté",
"Are you sure you want to delete this document ?": "Êtes-vous sûr(e) de vouloir supprimer ce document ?",
"Are you sure you want to delete the document \"{{title}}\"?": "Êtes-vous sûr de vouloir supprimer le document \"{{title}}\" ?",
"Available soon": "Disponible prochainement",
"Back to home page": "Retour à l'accueil",
"Banner image": "Image de la bannière",
"Can't load this page, please check your internet connection.": "Impossible de charger cette page, veuillez vérifier votre connexion Internet.",
"Cancel": "Annuler",
@@ -216,12 +218,10 @@
"Docs offers an intuitive writing experience. Its minimalist interface favors content over layout, while offering the essentials: media import, offline mode and keyboard shortcuts for greater efficiency.": "Docs propose une expérience d'écriture intuitive. Son interface minimaliste privilégie le contenu sur la mise en page, tout en offrant l'essentiel : import de médias, mode hors-ligne et raccourcis clavier pour plus d'efficacité.",
"Docs transforms your documents into knowledge bases thanks to subpages, powerful search and the ability to pin your important documents.": "Docs transforme vos documents en bases de connaissances grâce aux sous-pages, une recherche performante et la possibilité d'épingler vos documents importants.",
"Docs: Your new companion to collaborate on documents efficiently, intuitively, and securely.": "Docs : Votre nouveau compagnon pour collaborer sur des documents efficacement, intuitivement et en toute sécurité.",
"Document accessible to any connected person": "Document accessible à toute personne connectée",
"Document owner": "Propriétaire du document",
"Document title updated successfully": "Titre du document mis à jour avec succès",
"Docx": "Docx",
"Download": "Télécharger",
"Download anyway": "Télécharger malgré tout",
"Download your document in a .docx or .pdf format.": "Téléchargez votre document au format .docx ou .pdf.",
"E-mail:": "E-mail:",
"Edition": "Édition",
@@ -241,15 +241,12 @@
"Flexible export.": "Un export flexible.",
"Format": "Format",
"French Interministerial Directorate for Digital Affairs (DINUM), 20 avenue de Ségur 75007 Paris.": "Direction interministérielle des affaires numériques (DINUM), 20 avenue de Segur 75007 Paris.",
"Govs ❤️ Open Source.": "Gouvernements ❤️ Open Source.",
"Govs ❤️ Open Source.": "Gouvs ❤️ Open Source.",
"History": "Historique",
"Home": "Accueil",
"If a member is editing, his works can be lost.": "Si un membre est en train d'éditer, ses travaux peuvent être perdus.",
"If you are unable to access a content or a service, you can contact the person responsible for https://lasuite.numerique.gouv.fr to be directed to an accessible alternative or to obtain the content in another form.": "Si vous ne pouvez pas accéder à un contenu ou à un service, vous pouvez contacter la personne responsable de https://lasuite. umerique.gouv.fr pour être dirigé vers une alternative accessible ou pour obtenir le contenu sous une autre forme.",
"Illustration": "Image",
"Illustration:": "Illustration :",
"Image 401": "Image 401",
"Image 403": "Image 403",
"Improvement and contact": "Amélioration et contact",
"Invite": "Inviter",
"It is the card information about the document.": "Il s'agit de la carte d'information du document.",
@@ -265,10 +262,8 @@
"List invitation card": "Carte de liste d'invitation",
"List members card": "Carte liste des membres",
"Load more": "Afficher plus",
"Log in to access the document.": "Connectez-vous pour accéder au document.",
"Login": "Connexion",
"Logout": "Se déconnecter",
"Modal confirmation to download the attachment": "Modale de confirmation pour télécharger la pièce jointe",
"Modal confirmation to restore the version": "Modale de confirmation pour restaurer la version",
"More docs": "Plus de documents",
"More info?": "Plus d'infos ?",
@@ -295,15 +290,14 @@
"Pin": "Épingler",
"Pin document icon": "Icône épingler un document",
"Pinned documents": "Documents épinglés",
"Please download it only if it comes from a trusted source.": "Veuillez le télécharger uniquement s'il provient d'une source fiable.",
"Private": "Privé",
"ProConnect Image": "Image ProConnect",
"Proconnect Login": "Login Proconnect",
"Public": "Public",
"Public document": "Document public",
"Publication Director": "Directeur de la publication",
"Publisher": "Éditeur",
"Quick search input": "Saisie de recherche rapide",
"Quote": "Citation",
"Reader": "Lecteur",
"Reading": "Lecture seule",
"Remedies": "Voie de recours",
@@ -320,13 +314,13 @@
"Share": "Partager",
"Share modal": "Modale de partage",
"Share the document": "Partager le document",
"Share with {{count}} users_many": "Partagé entre {{count}} utilisateurs",
"Share with {{count}} users_one": "Partagé entre {{count}} utilisateur",
"Share with {{count}} users_other": "Partagé entre {{count}} utilisateurs",
"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 me": "Partagés avec moi",
"Shared with {{count}} users_many": "Partagé entre {{count}} utilisateurs",
"Shared with {{count}} users_one": "Partagé entre {{count}} utilisateur",
"Shared with {{count}} users_other": "Partagé entre {{count}} utilisateurs",
"Shared with {{count}} users_many": "Partager avec {{count}} utilisateurs",
"Shared with {{count}} users_one": "Partager avec {{count}} utilisateur",
"Shared with {{count}} users_other": "Partager avec {{count}} utilisateurs",
"Show more": "Voir plus",
"Simple and secure collaboration.": "Une collaboration simple et sécurisée.",
"Simple document icon": "Icône simple du document",
@@ -342,7 +336,6 @@
"The team in charge of the digital workspace \"La Suite numérique\" can be contacted directly at": "L'équipe responsable de l'espace de travail numérique \"La Suite numérique\" peut être contactée directement à l'adresse",
"This accessibility statement applies to the site hosted on": "Cette déclaration d'accessibilité s'applique au site hébergé sur",
"This allows us to measure the number of visits and understand which pages are the most viewed.": "Cela nous permet de mesurer le nombre de visites et de comprendre quelles pages sont les plus consultées.",
"This file is flagged as unsafe.": "Ce fichier est marqué comme non sûr.",
"This procedure should be used in the following case:": "Cette procédure devrait être utilisée dans le cas suivant:",
"This site does not display a cookie consent banner, why?": "Ce site n'affiche pas de bannière de consentement des cookies, pourquoi?",
"This site places a small text file (a \"cookie\") on your computer when you visit it.": "Ce site place un petit fichier texte (un « cookie ») sur votre ordinateur lorsque vous le visitez.",
@@ -368,7 +361,6 @@
"You can oppose the tracking of your browsing on this website.": "Vous pouvez vous opposer au suivi de votre navigation sur ce site.",
"You can:": "Vous pouvez:",
"You cannot update the role or remove other owner.": "Vous ne pouvez pas mettre à jour le rôle ou supprimer un autre propriétaire.",
"You do not have permission to view this document.": "Vous n'avez pas la permission de voir ce document.",
"You do not have permission to view users sharing this document or modify link settings.": "Vous n'avez pas la permission de voir les utilisateurs partageant ce document ou de modifier les paramètres du lien.",
"Your current document will revert to this version.": "Votre document actuel va revenir à cette version.",
"Your {{format}} was downloaded succesfully": "Votre {{format}} a été téléchargé avec succès",

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